/* app.js — solverforge-lessons SolverForge UI */ // biome-ignore-all lint: don't lint import { renderByGroup, renderByRoom, renderByTeacher } from './views.js?v=20260514d'; 'use strict'; var SLOT_MINUTES = 60; var DEFAULT_VIEWPORT_SLOTS = 12; var TIMELINE_TONES = ['emerald', 'blue', 'amber', 'rose', 'violet', 'slate']; var config = await fetch('/sf-config.json').then(function (response) { return response.json(); }); var uiModel = await fetch('/generated/ui-model.json').then(function (response) { return response.json(); }); var app = document.getElementById('sf-app'); app.className = 'sf-app solverforge-lessons-app'; var backend = SF.createBackend({ baseUrl: '' }); var statusBar = SF.createStatusBar({ constraints: buildStatusBarConstraints(uiModel.constraints || config.constraints || []), onConstraintClick: function () { openAnalysis(); }, }); var currentPlan = null; var lastAnalysis = null; var bootstrapError = null; var demoCatalog = { defaultId: null, availableIds: [] }; var activeTab = 'by-group'; var viewPanels = {}; var viewTimelines = {}; // var tabs = (uiModel.views || []).map(function (view, index) { // return { // id: view.id, // label: view.label, // icon: view.kind === 'list' ? 'fa-list-ol' : 'fa-table-cells-large', // active: index === 0, // }; // }); // if (!tabs.length) { // tabs.push({ id: 'overview', label: 'Overview', icon: 'fa-compass', active: true }); // } var tabs = [] tabs.push({ id: 'by-group', label: 'By Group', icon: 'fa-users', active: true }); tabs.push({ id: 'by-room', label: 'By Room', icon: 'fa-door-open' }); tabs.push({ id: 'by-teacher', label: 'By Teacher', icon: 'fa-chalkboard-user' }); tabs.push({ id: 'data', label: 'Data', icon: 'fa-table' }); tabs.push({ id: 'api', label: 'REST API', icon: 'fa-book' }); var header = SF.createHeader({ logo: '/sf/img/ouroboros.svg', title: config.title, subtitle: config.subtitle, tabs: tabs, actions: { onSolve: function () { loadAndSolve(); }, onPause: function () { pauseSolve(); }, onResume: function () { resumeSolve(); }, onCancel: function () { cancelSolve(); }, onAnalyze: function () { openAnalysis(); }, }, onTabChange: function (tab) { activeTab = tab; Object.keys(viewPanels).forEach(function (key) { viewPanels[key].style.display = key === tab ? '' : 'none'; }); overviewPanel.style.display = tab === 'overview' ? '' : 'none'; dataPanel.style.display = tab === 'data' ? '' : 'none'; apiPanel.style.display = tab === 'api' ? '' : 'none'; byGroupPanel.style.display = tab === 'by-group' ? '' : 'none'; byRoomPanel.style.display = tab === 'by-room' ? '' : 'none'; byTeacherPanel.style.display = tab === 'by-teacher' ? '' : 'none'; }, }); app.appendChild(header); statusBar.bindHeader(header); app.appendChild(statusBar.el); var bootstrapNotice = SF.el('div', { className: 'sf-content', style: { display: 'none', padding: '16px', marginBottom: '16px', borderRadius: '12px', border: '1px solid #dc2626', background: '#fef2f2', color: '#991b1b', }, }); app.appendChild(bootstrapNotice); var overviewPanel = SF.el('div', { className: 'sf-content', style: { display: activeTab === 'overview' ? '' : 'none' } }); var overviewContainer = SF.el('div', { id: 'sf-overview' }); overviewPanel.appendChild(overviewContainer); app.appendChild(overviewPanel); (uiModel.views || []).forEach(function (view) { var panel = SF.el('div', { className: 'sf-content', style: { display: activeTab === view.id ? '' : 'none' } }); panel.appendChild(SF.el('div', { id: 'view-' + view.id })); viewPanels[view.id] = panel; app.appendChild(panel); }); var dataPanel = SF.el('div', { className: 'sf-content', style: { display: activeTab === 'data' ? '' : 'none' } }); var tablesContainer = SF.el('div', { id: 'sf-tables' }); dataPanel.appendChild(tablesContainer); app.appendChild(dataPanel); var apiPanel = SF.el('div', { className: 'sf-content', style: { display: activeTab === 'api' ? '' : 'none' } }); var apiGuideContainer = SF.el('div'); apiPanel.appendChild(apiGuideContainer); app.appendChild(apiPanel); // Custom view panels var byGroupPanel = SF.el('div', { className: 'sf-content', style: { display: activeTab === 'by-group' ? '' : 'none' } }); var byGroupContainer = SF.el('div', { id: 'sf-by-group' }); byGroupPanel.appendChild(byGroupContainer); app.appendChild(byGroupPanel); viewPanels['by-group'] = byGroupPanel; var byRoomPanel = SF.el('div', { className: 'sf-content', style: { display: activeTab === 'by-room' ? '' : 'none' } }); var byRoomContainer = SF.el('div', { id: 'sf-by-room' }); byRoomPanel.appendChild(byRoomContainer); app.appendChild(byRoomPanel); viewPanels['by-room'] = byRoomPanel; var byTeacherPanel = SF.el('div', { className: 'sf-content', style: { display: activeTab === 'by-teacher' ? '' : 'none' } }); var byTeacherContainer = SF.el('div', { id: 'sf-by-teacher' }); byTeacherPanel.appendChild(byTeacherContainer); app.appendChild(byTeacherPanel); viewPanels['by-teacher'] = byTeacherPanel; app.appendChild(SF.createFooter({ links: [ { label: 'SolverForge', url: 'https://www.solverforge.org' }, { label: 'Docs', url: 'https://www.solverforge.org/docs' }, ], })); var analysisModal = SF.createModal({ title: 'Score Analysis', width: '700px' }); var solver = SF.createSolver({ backend: backend, statusBar: statusBar, onProgress: function (meta) { syncLifecycleMarkers(meta); }, onPauseRequested: function (meta) { syncLifecycleMarkers(meta); }, onSolution: function (snapshot, meta) { if (snapshot && snapshot.solution) { renderAll(snapshot.solution); } syncLifecycleMarkers(meta); }, onPaused: function (snapshot, meta) { if (snapshot && snapshot.solution) { renderAll(snapshot.solution); } syncLifecycleMarkers(meta); }, onResumed: function (meta) { syncLifecycleMarkers(meta); }, onCancelled: function (snapshot, meta) { if (snapshot && snapshot.solution) { renderAll(snapshot.solution); } syncLifecycleMarkers(meta); }, onComplete: function (snapshot, meta) { if (snapshot && snapshot.solution) { renderAll(snapshot.solution); } syncLifecycleMarkers(meta); }, onFailure: function (message, meta, snapshot, analysis) { if (snapshot && snapshot.solution) { renderAll(snapshot.solution); } if (analysis) { lastAnalysis = analysis; } console.error('Solver job failed:', message); syncLifecycleMarkers(meta); }, onAnalysis: function (analysis) { lastAnalysis = analysis; syncLifecycleMarkers(); }, onError: function (message) { console.error('Solver lifecycle failed:', message); syncLifecycleMarkers(); }, }); renderApiGuide(); updateSolveActionAvailability(); bootstrapDemoData(); window.addEventListener('beforeunload', destroyAllTimelines); function loadAndSolve() { if (solver.isRunning() || solver.getLifecycleState() === 'PAUSED' || !canSolve()) return; cleanupTerminalJob() .then(function (data) { return data || resolvePlanForSolve(); }) .then(function (data) { return solver.start(data); }) .then(function () { syncLifecycleMarkers(); }) .catch(function (err) { console.error('Solve start failed:', err); }); } function pauseSolve() { solver.pause() .then(function () { syncLifecycleMarkers(); }) .catch(function (err) { console.error('Pause failed:', err); }); } function resumeSolve() { solver.resume() .then(function () { syncLifecycleMarkers(); }) .catch(function (err) { console.error('Resume failed:', err); }); } function cancelSolve() { solver.cancel() .then(function () { syncLifecycleMarkers(); }) .catch(function (err) { console.error('Cancel failed:', err); }); } function openAnalysis() { if (!solver.getJobId()) return; solver.analyzeSnapshot() .then(function (analysis) { lastAnalysis = analysis; analysisModal.setBody(buildAnalysisHtml(analysis)); analysisModal.open(); }) .catch(function () { }); } function buildStatusBarConstraints(constraints) { var scoreLevels = { assign_timeslot: 'medium', assign_room: 'medium', teacher_availability: 'hard', group_availability: 'hard', room_kind: 'soft', room_capacity: 'hard', no_group_conflict: 'hard', no_room_conflict: 'hard', no_teacher_conflict: 'hard', late_lesson: 'soft', repeated_subject_day: 'soft', }; return (constraints || []).map(function (constraint) { var name = typeof constraint === 'string' ? constraint : constraint.name; return { name: title((name || '').replace(/_/g, ' ')), type: scoreLevels[name] || 'hard', }; }); } function renderAll(data) { currentPlan = clonePlan(data); renderOverview(data); renderViews(data); renderTables(data); renderByGroup(data, byGroupContainer, SF, toneForKey, entityLabel, customTimelines); renderByRoom(data, byRoomContainer, SF, toneForKey, entityLabel, customTimelines); renderByTeacher(data, byTeacherContainer, SF, toneForKey, entityLabel, customTimelines); } function resolvePlanForSolve() { if (currentPlan) { return Promise.resolve(clonePlan(currentPlan)); } if (!demoCatalog.defaultId) { return Promise.reject(new Error('demo data catalog is unavailable')); } return fetchDemoPlan(demoCatalog.defaultId); } function bootstrapDemoData() { fetchDemoCatalog() .then(function (catalog) { demoCatalog = catalog; clearBootstrapError(); renderApiGuide(); return fetchDemoPlan(catalog.defaultId); }) .then(function (data) { renderAll(data); updateSolveActionAvailability(); }) .catch(function (err) { reportBootstrapError(err); }); } function fetchDemoCatalog() { return requestJson('/demo-data', 'demo data catalog') .then(function (catalog) { if (!catalog || typeof catalog.defaultId !== 'string' || !Array.isArray(catalog.availableIds)) { throw new Error('demo data catalog is missing defaultId or availableIds'); } if (catalog.availableIds.indexOf(catalog.defaultId) === -1) { throw new Error('demo data catalog defaultId is not present in availableIds'); } return { defaultId: catalog.defaultId, availableIds: catalog.availableIds.slice(), }; }); } function fetchDemoPlan(demoId) { return requestJson('/demo-data/' + encodeURIComponent(demoId), 'demo data "' + demoId + '"'); } function requestJson(path, label) { return fetch(path) .then(function (response) { if (!response.ok) { throw new Error(label + ' returned HTTP ' + response.status); } return response.json(); }); } function canSolve() { return !bootstrapError && !!demoCatalog.defaultId; } function reportBootstrapError(err) { bootstrapError = describeError(err); bootstrapNotice.textContent = 'Demo data bootstrap failed: ' + bootstrapError; bootstrapNotice.style.display = ''; app.dataset.bootstrapError = 'true'; renderApiGuide(); updateSolveActionAvailability(); console.error('Demo data bootstrap failed:', err); } function clearBootstrapError() { bootstrapError = null; bootstrapNotice.textContent = ''; bootstrapNotice.style.display = 'none'; delete app.dataset.bootstrapError; } function describeError(err) { if (err && err.message) { return err.message; } return String(err || 'unknown error'); } function updateSolveActionAvailability() { var solveButton = findHeaderButton('Solve'); var disabled = !canSolve(); if (!solveButton) return; solveButton.disabled = disabled; solveButton.setAttribute('aria-disabled', disabled ? 'true' : 'false'); solveButton.title = disabled ? (bootstrapError ? 'Demo data bootstrap failed.' : 'Loading demo data catalog...') : ''; } function findHeaderButton(label) { var buttons = header.querySelectorAll('button'); for (var i = 0; i < buttons.length; i += 1) { var text = (buttons[i].textContent || '').trim(); if (text === label) { return buttons[i]; } } return null; } function renderApiGuide() { apiGuideContainer.innerHTML = ''; apiGuideContainer.appendChild(SF.createApiGuide({ endpoints: buildApiGuideEndpoints(), })); } function buildApiGuideEndpoints() { var defaultDemoPath = demoCatalog.defaultId ? '/demo-data/' + demoCatalog.defaultId : '/demo-data/{defaultId}'; return [ { method: 'GET', path: '/demo-data', description: 'Discover the default and available demo data IDs', curl: buildCurlCommand('GET', '/demo-data') }, { method: 'GET', path: defaultDemoPath, description: 'Fetch the discovered default demo data', curl: buildCurlCommand('GET', defaultDemoPath) }, { method: 'POST', path: '/jobs', description: 'Create a retained solving job', curl: buildCurlCommand('POST', '/jobs', { json: true, data: '@plan.json' }) }, { method: 'GET', path: '/jobs/{id}', description: 'Get current job summary', curl: buildCurlCommand('GET', '/jobs/{id}') }, { method: 'GET', path: '/jobs/{id}/snapshot', description: 'Fetch the latest retained snapshot', curl: buildCurlCommand('GET', '/jobs/{id}/snapshot') }, { method: 'GET', path: '/jobs/{id}/analysis?snapshot_revision={n}', description: 'Analyze an exact snapshot revision', curl: buildCurlCommand('GET', '/jobs/{id}/analysis?snapshot_revision=3', { quoteUrl: true }) }, { method: 'POST', path: '/jobs/{id}/pause', description: 'Request an exact runtime pause', curl: buildCurlCommand('POST', '/jobs/{id}/pause') }, { method: 'POST', path: '/jobs/{id}/resume', description: 'Resume a paused retained job', curl: buildCurlCommand('POST', '/jobs/{id}/resume') }, { method: 'POST', path: '/jobs/{id}/cancel', description: 'Cancel a live or paused job', curl: buildCurlCommand('POST', '/jobs/{id}/cancel') }, { method: 'DELETE', path: '/jobs/{id}', description: 'Delete a terminal retained job', curl: buildCurlCommand('DELETE', '/jobs/{id}') }, { method: 'GET', path: '/jobs/{id}/events', description: 'Stream job lifecycle updates (SSE)', curl: buildCurlCommand('GET', '/jobs/{id}/events', { stream: true }) }, ]; } function buildCurlCommand(method, path, options) { var parts = ['curl']; if (options && options.stream) { parts.push('-N'); } if (method && method !== 'GET') { parts.push('-X', method); } if (options && options.json) { parts.push('-H', '"Content-Type: application/json"'); } var url = buildApiUrl(path); parts.push(options && options.quoteUrl ? '"' + url + '"' : url); if (options && options.data) { parts.push('-d', options.data); } return parts.join(' '); } function buildApiUrl(path) { return currentOrigin() + path; } function currentOrigin() { return window.location.origin || (window.location.protocol + '//' + window.location.host); } function cleanupTerminalJob() { var state = solver.getLifecycleState(); if (!solver.getJobId() || state === 'IDLE' || state === 'PAUSED' || solver.isRunning()) { return Promise.resolve(null); } return solver.delete() .then(function () { lastAnalysis = null; syncLifecycleMarkers(); return null; }) .catch(function (err) { console.error('Delete failed:', err); throw err; }); } function syncLifecycleMarkers(meta) { var jobId = solver.getJobId(); var snapshotRevision = solver.getSnapshotRevision(); var lifecycleState = meta && meta.lifecycleState ? meta.lifecycleState : solver.getLifecycleState(); if (jobId) { app.dataset.jobId = String(jobId); } else { delete app.dataset.jobId; } if (snapshotRevision != null) { app.dataset.snapshotRevision = String(snapshotRevision); } else { delete app.dataset.snapshotRevision; } if (lifecycleState && lifecycleState !== 'IDLE') { app.dataset.lifecycleState = lifecycleState; } else { delete app.dataset.lifecycleState; } updateSolveActionAvailability(); } function clonePlan(data) { return JSON.parse(JSON.stringify(data)); } function renderOverview(data) { overviewContainer.innerHTML = ''; if ((uiModel.views || []).length) { overviewContainer.appendChild(SF.el( 'p', null, 'The generated views now mount the standard solverforge-ui timeline surface for every planning variable declared in your project.' )); overviewContainer.appendChild(SF.createTable({ columns: ['Active views', 'Constraints', 'Current score'], rows: [[ String(uiModel.views.length), String((uiModel.constraints || []).length), String(data.score || '—'), ]], })); return; } overviewContainer.appendChild(SF.el('p', null, 'No planning variables are declared yet. Use `solverforge generate entity`, `generate fact`, and `generate variable` to shape the app.')); } function renderViews(data) { (uiModel.views || []).forEach(function (view) { var container = document.getElementById('view-' + view.id); if (!container) return; if (view.kind === 'list') { renderTimelinePanel( container, view.id, buildListViewPayload(data, view), 'This list-variable timeline will appear once the referenced facts and entities contain data.' ); } else { renderTimelinePanel( container, view.id, buildScalarViewPayload(data, view), 'This scalar-variable timeline will appear once the referenced facts and entities contain data.' ); } }); } function renderTimelinePanel(container, viewId, payload, emptyMessage) { container.innerHTML = ''; if (!payload) { destroyTimeline(viewId); container.appendChild(SF.el('p', null, emptyMessage)); return; } container.appendChild(payload.summary); container.appendChild(ensureTimeline(viewId, payload.timeline).el); } function ensureTimeline(viewId, timelineConfig) { var timeline = viewTimelines[viewId]; if (!timeline) { timeline = SF.rail.createTimeline(timelineConfig); viewTimelines[viewId] = timeline; return timeline; } timeline.setModel(timelineConfig.model); return timeline; } function destroyTimeline(viewId) { var timeline = viewTimelines[viewId]; if (!timeline) return; timeline.destroy(); delete viewTimelines[viewId]; } function destroyAllTimelines() { Object.keys(viewTimelines).forEach(function (viewId) { destroyTimeline(viewId); }); Object.keys(customTimelines || {}).forEach(function (key) { if (customTimelines[key]) { customTimelines[key].destroy(); delete customTimelines[key]; } }); } // Custom timelines registry var customTimelines = {}; function buildScalarViewPayload(data, view) { var entities = data[view.entityPlural] || []; var facts = data[view.sourcePlural] || []; if (!entities.length || !facts.length) return null; var byIndex = {}; facts.forEach(function (fact, index) { byIndex[index] = fact; }); var assignments = facts.map(function () { return []; }); var detached = []; entities.forEach(function (entity) { var idx = entity[view.variableField]; if (idx == null || byIndex[idx] == null) { detached.push(entity); return; } assignments[idx].push(entity); }); var peakLoad = assignments.reduce(function (maxCount, items) { return Math.max(maxCount, items.length); }, 0); var horizon = Math.max(peakLoad, detached.length, 1); var axis = buildSlotAxis(horizon); var lanes = facts.map(function (fact, factIndex) { var items = assignments[factIndex] || []; return { id: view.id + '-lane-' + factIndex, label: String(factLabel(fact, factIndex)), mode: 'detailed', badges: items.length ? [] : ['Empty'], stats: [{ label: title(view.entityPlural), value: items.length }], items: items.map(function (entity, itemIndex) { return buildTimelineItem( view.id + '-fact-' + factIndex + '-entity-' + itemIndex, itemIndex, entityLabel(entity, itemIndex), 'Assignment ' + String(itemIndex + 1), entityLabel(entity, itemIndex) ); }), }; }); if (detached.length) { lanes.push({ id: view.id + '-detached', label: view.allowsUnassigned ? 'Unassigned' : 'Unmapped', mode: 'detailed', badges: [view.allowsUnassigned ? 'Needs assignment' : 'Out of range'], stats: [{ label: title(view.entityPlural), value: detached.length }], items: detached.map(function (entity, itemIndex) { return buildTimelineItem( view.id + '-detached-' + itemIndex, itemIndex, entityLabel(entity, itemIndex), view.allowsUnassigned ? 'Awaiting assignment' : 'Invalid source index', entityLabel(entity, itemIndex) ); }), }); } return { summary: buildSummarySection( ['Source lanes', title(view.entityPlural), 'Peak load', 'Unassigned'], [ String(facts.length), String(entities.length), String(peakLoad), String(detached.length), ] ), timeline: { label: title(view.sourcePlural), labelWidth: 280, title: view.label, subtitle: title(view.entityPlural) + ' grouped by ' + title(view.sourcePlural), model: { axis: axis, lanes: lanes, }, }, }; } function buildListViewPayload(data, view) { var entities = data[view.entityPlural] || []; var facts = data[view.sourcePlural] || []; if (!entities.length || !facts.length) return null; var byIndex = {}; facts.forEach(function (fact, index) { byIndex[index] = fact; }); var rows = entities.map(function (entity, entityIndex) { var sequence = Array.isArray(entity[view.variableField]) ? entity[view.variableField] : []; return { entity: entity, entityIndex: entityIndex, sequence: sequence, }; }); rows.sort(function (left, right) { if (right.sequence.length !== left.sequence.length) { return right.sequence.length - left.sequence.length; } return String(entityLabel(left.entity, left.entityIndex)).localeCompare( String(entityLabel(right.entity, right.entityIndex)) ); }); var totalItems = rows.reduce(function (sum, row) { return sum + row.sequence.length; }, 0); var longestSequence = rows.reduce(function (maxCount, row) { return Math.max(maxCount, row.sequence.length); }, 0); var emptyEntities = rows.filter(function (row) { return row.sequence.length === 0; }).length; var horizon = Math.max(longestSequence, 1); var axis = buildSlotAxis(horizon); var lanes = rows.map(function (row) { return { id: view.id + '-entity-' + row.entityIndex, label: entityLabel(row.entity, row.entityIndex), mode: 'detailed', badges: listLaneBadges(row.sequence.length, longestSequence), stats: [{ label: title(view.sourcePlural), value: row.sequence.length }], items: row.sequence.map(function (factIndex, sequenceIndex) { var fact = byIndex[factIndex]; return buildTimelineItem( view.id + '-entity-' + row.entityIndex + '-item-' + sequenceIndex, sequenceIndex, factLabel(fact, factIndex), 'Position ' + String(sequenceIndex + 1), factLabel(fact, factIndex) ); }), }; }); return { summary: buildSummarySection( [title(view.entityPlural), title(view.sourcePlural), 'Longest sequence', 'Empty lanes', 'Average items / lane'], [ String(rows.length), String(totalItems), String(longestSequence), String(emptyEntities), rows.length ? (totalItems / rows.length).toFixed(1) : '0.0', ] ), timeline: { label: title(view.entityPlural), labelWidth: 280, title: view.label, subtitle: title(view.sourcePlural) + ' ordered inside each ' + title(view.entityPlural), model: { axis: axis, lanes: lanes, }, }, }; } function buildSummarySection(columns, row) { var section = SF.el('div', { className: 'sf-section' }); section.appendChild(SF.createTable({ columns: columns, rows: [row], })); return section; } function buildSlotAxis(slotCount) { var normalizedSlots = Math.max(slotCount, 1); var groupSize = normalizedSlots > 24 ? 8 : (normalizedSlots > 12 ? 6 : 4); var days = []; var ticks = []; for (var startSlot = 0; startSlot < normalizedSlots; startSlot += groupSize) { var endSlot = Math.min(normalizedSlots, startSlot + groupSize); days.push({ id: 'window-' + startSlot, label: 'Window ' + String(days.length + 1), subLabel: slotRangeLabel(startSlot, endSlot), startMinute: startSlot * SLOT_MINUTES, endMinute: endSlot * SLOT_MINUTES, }); } for (var slotIndex = 0; slotIndex < normalizedSlots; slotIndex += 1) { ticks.push({ id: 'tick-' + slotIndex, minute: slotIndex * SLOT_MINUTES, label: 'Slot ' + String(slotIndex + 1), }); } return { startMinute: 0, endMinute: normalizedSlots * SLOT_MINUTES, days: days, ticks: ticks, initialViewport: { startMinute: 0, endMinute: Math.min(normalizedSlots, DEFAULT_VIEWPORT_SLOTS) * SLOT_MINUTES, }, }; } function buildTimelineItem(id, slotIndex, label, meta, toneKey) { return { id: id, startMinute: slotIndex * SLOT_MINUTES, endMinute: (slotIndex + 1) * SLOT_MINUTES, label: String(label), meta: meta || '', tone: toneForKey(toneKey || label), }; } function slotRangeLabel(startSlot, endSlot) { if (endSlot - startSlot <= 1) { return 'Slot ' + String(startSlot + 1); } return 'Slots ' + String(startSlot + 1) + '-' + String(endSlot); } function listLaneBadges(length, longestSequence) { if (length === 0) return ['Empty']; var badges = []; if (length === longestSequence) badges.push('Longest'); if (length === 1) badges.push('Single'); return badges; } function toneForKey(key) { var text = String(key || ''); var hash = 0; for (var index = 0; index < text.length; index += 1) { hash = ((hash * 31) + text.charCodeAt(index)) >>> 0; } return TIMELINE_TONES[hash % TIMELINE_TONES.length]; } function renderTables(data) { tablesContainer.innerHTML = ''; (uiModel.entities || []).concat(uiModel.facts || []).forEach(function (entry) { var rows = data[entry.plural] || []; if (!rows.length) return; var cols = Object.keys(rows[0]).filter(function (key) { return key !== 'score' && key !== 'solverStatus'; }); var values = rows.map(function (row) { return cols.map(function (key) { var value = row[key]; if (value == null) return '—'; if (Array.isArray(value)) return value.join(', '); if (typeof value === 'object') return JSON.stringify(value); return String(value); }); }); var section = SF.el('div', { className: 'sf-section' }); section.appendChild(SF.el('h3', null, entry.label)); section.appendChild(SF.createTable({ columns: cols, rows: values })); tablesContainer.appendChild(section); }); } function buildAnalysisHtml(analysis) { if (!analysis || !analysis.constraints) return '
No analysis available.
'; var html = 'Score: ' + SF.escHtml(analysis.score) + '
'; html += '| Constraint | Type | Score | Matches |
|---|---|---|---|
| ' + SF.escHtml(constraint.name) + ' | ' + SF.escHtml(constraint.constraintType || constraint.type || '') + ' | ' + SF.escHtml(constraint.score) + ' | ' + matchCount + ' |