solverforge-cli 1.1.3

CLI for scaffolding and managing SolverForge constraint solver projects
/* app.js — {{project_name}} SolverForge UI */

(async function () {
  'use strict';

  var config = await fetch('/sf-config.json').then(function (r) { return r.json(); });

  var app = document.getElementById('sf-app');
  var currentPlan = null;

  // Backend and solver
  var backend = SF.createBackend({ baseUrl: '' });
  var statusBar = SF.createStatusBar({ constraints: config.constraints });
  var solver = SF.createSolver({
    backend: backend,
    statusBar: statusBar,
    onSolution: function (snapshot) { renderAll(snapshot && snapshot.solution ? snapshot.solution : null); },
    onPaused: function (snapshot) { renderAll(snapshot && snapshot.solution ? snapshot.solution : null); },
    onCancelled: function (snapshot) { renderAll(snapshot && snapshot.solution ? snapshot.solution : null); },
    onComplete: function (snapshot) { renderAll(snapshot && snapshot.solution ? snapshot.solution : null); },
    onError: function (message) { console.error('Solver lifecycle failed:', message); },
  });

  // Header
  var header = SF.createHeader({
    logo: '/sf/img/ouroboros.svg',
    title: config.title,
    subtitle: config.subtitle,
    tabs: [
      { id: 'sequences', label: 'Sequences', icon: 'fa-list-ol', active: true },
      { id: 'data', label: 'Data', icon: 'fa-table' },
      { id: 'api', label: 'REST API', icon: 'fa-book' },
    ],
    actions: {
      onSolve: function () { loadAndSolve(); },
      onPause: function () { solver.pause().catch(function (err) { console.error('Pause failed:', err); }); },
      onResume: function () { solver.resume().catch(function (err) { console.error('Resume failed:', err); }); },
      onCancel: function () { solver.cancel().catch(function (err) { console.error('Cancel failed:', err); }); },
      onAnalyze: function () { openAnalysis(); },
    },
    onTabChange: function (tab) {
      sequencesPanel.style.display = tab === 'sequences' ? '' : 'none';
      dataPanel.style.display = tab === 'data' ? '' : 'none';
      apiPanel.style.display = tab === 'api' ? '' : 'none';
    },
  });
  app.appendChild(header);
  statusBar.bindHeader(header);
  app.appendChild(statusBar.el);

  // Sequences panel (hero)
  var sequencesPanel = SF.el('div', { className: 'sf-content' });
  var sequencesContainer = SF.el('div', { id: 'sf-sequences' });
  sequencesPanel.appendChild(sequencesContainer);
  app.appendChild(sequencesPanel);

  // Data panel
  var dataPanel = SF.el('div', { className: 'sf-content', style: { display: 'none' } });
  var tablesContainer = SF.el('div', { id: 'sf-tables' });
  dataPanel.appendChild(tablesContainer);
  app.appendChild(dataPanel);

  // API panel
  var apiPanel = SF.el('div', { className: 'sf-content', style: { display: 'none' } });
  var guide = SF.createApiGuide({
    endpoints: [
      { method: 'GET', path: '/demo-data/STANDARD', description: 'Fetch demo data', curl: 'curl http://localhost:7860/demo-data/STANDARD' },
      { method: 'POST', path: '/jobs', description: 'Create a retained solving job', curl: 'curl -X POST -H "Content-Type: application/json" http://localhost:7860/jobs -d @plan.json' },
      { method: 'GET', path: '/jobs/{id}', description: 'Get current job summary', curl: 'curl http://localhost:7860/jobs/{id}' },
      { method: 'GET', path: '/jobs/{id}/snapshot', description: 'Fetch the latest retained snapshot', curl: 'curl http://localhost:7860/jobs/{id}/snapshot' },
      { method: 'GET', path: '/jobs/{id}/analysis?snapshot_revision={n}', description: 'Analyze an exact snapshot revision', curl: 'curl "http://localhost:7860/jobs/{id}/analysis?snapshot_revision=3"' },
      { method: 'POST', path: '/jobs/{id}/pause', description: 'Request an exact runtime pause', curl: 'curl -X POST http://localhost:7860/jobs/{id}/pause' },
      { method: 'POST', path: '/jobs/{id}/resume', description: 'Resume a paused retained job', curl: 'curl -X POST http://localhost:7860/jobs/{id}/resume' },
      { method: 'POST', path: '/jobs/{id}/cancel', description: 'Cancel a live or paused job', curl: 'curl -X POST http://localhost:7860/jobs/{id}/cancel' },
      { method: 'DELETE', path: '/jobs/{id}', description: 'Delete a terminal retained job', curl: 'curl -X DELETE http://localhost:7860/jobs/{id}' },
      { method: 'GET', path: '/jobs/{id}/events', description: 'Stream job lifecycle updates (SSE)', curl: 'curl -N http://localhost:7860/jobs/{id}/events' },
    ],
  });
  apiPanel.appendChild(guide);
  app.appendChild(apiPanel);

  // Footer
  var footer = SF.createFooter({
    links: [
      { label: 'SolverForge', url: 'https://www.solverforge.org' },
      { label: 'Docs', url: 'https://www.solverforge.org/docs' },
    ],
  });
  app.appendChild(footer);

  // Analysis modal
  var analysisModal = SF.createModal({ title: 'Score Analysis', width: '700px' });

  // Load demo data on startup
  fetch('/demo-data/STANDARD')
    .then(function (r) { return r.json(); })
    .then(function (data) { renderAll(data); })
    .catch(function () {});

  function loadAndSolve() {
    cleanupTerminalJob()
      .then(function () {
        if (currentPlan) return currentPlan;
        return fetch('/demo-data/STANDARD').then(function (r) { return r.json(); });
      })
      .then(function (data) {
        return solver.start(clonePlan(data));
      })
      .catch(function (err) { console.error('Demo load failed:', err); });
  }

  function cleanupTerminalJob() {
    var state = solver.getLifecycleState();
    if (!solver.getJobId() || state === 'IDLE' || state === 'PAUSED' || solver.isRunning()) {
      return Promise.resolve();
    }
    return solver.delete().catch(function (err) {
      console.error('Delete failed:', err);
    });
  }

  function openAnalysis() {
    var id = solver.getJobId();
    if (!id) return;
    solver.analyzeSnapshot()
      .then(function (analysis) {
        analysisModal.setBody(buildAnalysisHtml(analysis));
        analysisModal.open();
      })
      .catch(function () {});
  }

  function buildAnalysisHtml(analysis) {
    if (!analysis || !analysis.constraints) return '<p>No analysis available.</p>';
    var html = '<p><strong>Score:</strong> ' + SF.escHtml(analysis.score) + '</p>';
    html += '<table class="sf-table"><thead><tr><th>Constraint</th><th>Type</th><th>Score</th><th>Matches</th></tr></thead><tbody>';
    analysis.constraints.forEach(function (c) {
      var matchCount = c.matchCount != null ? c.matchCount : (c.matches ? c.matches.length : 0);
      html += '<tr><td>' + SF.escHtml(c.name) + '</td><td>' + SF.escHtml(c.constraintType || c.type || '') + '</td><td>' + SF.escHtml(c.score) + '</td><td>' + matchCount + '</td></tr>';
    });
    html += '</tbody></table>';
    return html;
  }

  function renderAll(data) {
    if (!data) return;
    currentPlan = clonePlan(data);
    renderSequences(data);
    renderTables(data);
  }

  function clonePlan(data) {
    return JSON.parse(JSON.stringify(data));
  }

  function renderSequences(data) {
    sequencesContainer.innerHTML = '';
    var containers = data.containers || [];
    if (!containers.length) return;
    var itemsById = buildItemsById(data);
    var metrics = deriveSequenceMetrics(containers);
    var sortedContainers = containers.slice().sort(compareContainers);
    var horizon = Math.max(metrics.longestSequence, 1);

    sequencesContainer.appendChild(buildSequenceOverview(metrics));
    sequencesContainer.appendChild(SF.rail.createHeader({
      label: config.entities[0] ? config.entities[0].label : 'Container',
      labelWidth: 220,
      columns: Array.from({ length: horizon }, function (_, i) { return String(i + 1); }),
    }));

    sortedContainers.forEach(function (container) {
      sequencesContainer.appendChild(buildSequenceCard(container, itemsById, metrics, horizon).el);
    });
  }

  function buildItemsById(data) {
    var items = data.items || data.itemFacts || data.item_facts || [];
    return items.reduce(function (map, item) {
      if (item && item.id) map[item.id] = item;
      return map;
    }, {});
  }

  function deriveSequenceMetrics(containers) {
    var lengths = containers.map(function (container) {
      return (container.items || []).length;
    });
    var totalItems = lengths.reduce(function (sum, count) { return sum + count; }, 0);
    var longestSequence = lengths.reduce(function (maxCount, count) {
      return Math.max(maxCount, count);
    }, 0);
    var emptyContainers = lengths.filter(function (count) { return count === 0; }).length;
    return {
      totalContainers: containers.length,
      totalItems: totalItems,
      longestSequence: longestSequence,
      emptyContainers: emptyContainers,
      averageItems: containers.length ? (totalItems / containers.length).toFixed(1) : '0.0',
    };
  }

  function compareContainers(a, b) {
    var aCount = (a.items || []).length;
    var bCount = (b.items || []).length;
    if (bCount !== aCount) return bCount - aCount;
    return String(a.name || '').localeCompare(String(b.name || ''));
  }

  function buildSequenceOverview(metrics) {
    var section = SF.el('div', { className: 'sf-section' });
    section.appendChild(SF.createTable({
      columns: ['Containers', 'Items', 'Longest sequence', 'Empty containers', 'Average items / container'],
      rows: [[
        String(metrics.totalContainers),
        String(metrics.totalItems),
        String(metrics.longestSequence),
        String(metrics.emptyContainers),
        String(metrics.averageItems),
      ]],
    }));
    return section;
  }

  function buildSequenceCard(container, itemsById, metrics, horizon) {
    var sequence = container.items || [];
    var firstItem = sequence.length ? describeItem(sequence[0], itemsById).name : '';
    var lastItem = sequence.length ? describeItem(sequence[sequence.length - 1], itemsById).name : '';
    var length = sequence.length;
    var fullnessPct = metrics.longestSequence > 0
      ? Math.round((length / metrics.longestSequence) * 100)
      : 0;
    var card = SF.rail.createCard({
      id: 'container-' + String(container.id != null ? container.id : container.name),
      name: container.name || 'Unnamed container',
      labelWidth: 220,
      columns: horizon,
      type: 'Sequence',
      badges: containerBadges(length, metrics.longestSequence),
      gauges: [
        {
          label: 'Length',
          pct: Math.min(fullnessPct, 100),
          style: length === 0 ? 'heat' : 'load',
          text: String(length) + '/' + String(Math.max(metrics.longestSequence, 1)),
        },
      ],
      stats: [
        { label: 'Items', value: length },
        { label: 'First', value: firstItem },
        { label: 'Last', value: lastItem },
      ],
    });

    sequence.forEach(function (itemId, index) {
      var item = describeItem(itemId, itemsById);
      card.addBlock({
        id: 'container-' + String(container.id || container.name) + '-item-' + String(index),
        label: item.name,
        meta: 'Pos ' + String(index + 1),
        start: index,
        end: index + 1,
        horizon: horizon,
        color: SF.colors.pick(String(item.key)),
      });
    });

    return card;
  }

  function containerBadges(length, longestSequence) {
    if (length === 0) return ['Empty'];
    var badges = [];
    if (length === longestSequence) badges.push('Longest');
    if (length === 1) badges.push('Single');
    return badges;
  }

  function describeItem(itemId, itemsById) {
    var item = itemsById[itemId];
    if (!item) {
      return { key: itemId || 'item', name: itemId || 'Unnamed' };
    }
    return {
      key: item.id || itemId || 'item',
      name: item.name || item.id || itemId || 'Unnamed',
    };
  }

  function renderTables(data) {
    tablesContainer.innerHTML = '';

    config.entities.forEach(function (entity) {
      var items = data[entity.plural] || data[entity.name + 's'] || [];
      if (!items.length) return;
      var cols = Object.keys(items[0]);
      var rows = items.map(function (item) {
        return cols.map(function (k) {
          var v = item[k];
          if (v === null || v === undefined) return '';
          if (Array.isArray(v)) return v.join(', ');
          if (typeof v === 'object') return JSON.stringify(v);
          return String(v);
        });
      });
      var section = SF.el('div', { className: 'sf-section' });
      section.appendChild(SF.el('h3', null, entity.label));
      section.appendChild(SF.createTable({ columns: cols, rows: rows }));
      tablesContainer.appendChild(section);
    });

    config.facts.forEach(function (fact) {
      var items = data[fact.plural] || data[fact.name + 's'] || [];
      if (!items.length) return;
      var cols = Object.keys(items[0]);
      var rows = items.map(function (item) {
        return cols.map(function (k) {
          var v = item[k];
          if (v === null || v === undefined) return '';
          if (typeof v === 'object') return JSON.stringify(v);
          return String(v);
        });
      });
      var section = SF.el('div', { className: 'sf-section' });
      section.appendChild(SF.el('h3', null, fact.label));
      section.appendChild(SF.createTable({ columns: cols, rows: rows }));
      tablesContainer.appendChild(section);
    });
  }

})();