const SF = (function () {
'use strict';
const sf = { version: '0.4.3' };
var uidCounter = 0;
sf.escHtml = function (str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
};
sf.assert = function (cond, message) {
if (!cond) throw new Error('[SolverForge] ' + message);
};
sf.el = function (tag, attrs) {
var children = Array.prototype.slice.call(arguments, 2);
var el = document.createElement(tag);
if (attrs) {
Object.keys(attrs).forEach(function (key) {
if (key === 'className') el.className = attrs[key];
else if (key === 'style' && typeof attrs[key] === 'object') {
Object.assign(el.style, attrs[key]);
}
else if (key.indexOf('on') === 0) el.addEventListener(key.slice(2).toLowerCase(), attrs[key]);
else if (key === 'dataset') Object.assign(el.dataset, attrs[key]);
else if (key === 'html') el.textContent = attrs[key];
else if (key === 'unsafeHtml') el.innerHTML = attrs[key];
else el.setAttribute(key, attrs[key]);
});
}
children.forEach(function (child) {
if (child == null) return;
if (typeof child === 'string') el.appendChild(document.createTextNode(child));
else if (child instanceof Node) el.appendChild(child);
});
return el;
};
sf.uid = function (prefix) {
uidCounter += 1;
return (prefix || 'sf') + '-' + uidCounter;
};
sf.bindActivation = function (el, onActivate) {
if (!el || typeof onActivate !== 'function') return;
function handleActivate(e) {
if (!e || e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return;
if (e.type === 'keydown') e.preventDefault();
onActivate(e);
}
el.addEventListener('click', handleActivate);
el.addEventListener('keydown', handleActivate);
};
if (typeof window !== 'undefined') window.SF = sf;
return sf;
})();
(function (sf) {
'use strict';
sf.score = {};
sf.score.parseHard = function (scoreStr) {
if (!scoreStr) return 0;
var m = scoreStr.match(/(-?\d+)hard/);
return m ? parseInt(m[1], 10) : 0;
};
sf.score.parseSoft = function (scoreStr) {
if (!scoreStr) return 0;
var m = scoreStr.match(/(-?\d+)soft/);
return m ? parseInt(m[1], 10) : 0;
};
sf.score.parseMedium = function (scoreStr) {
if (!scoreStr) return 0;
var m = scoreStr.match(/(-?\d+)medium/);
return m ? parseInt(m[1], 10) : 0;
};
sf.score.getComponents = function (scoreStr) {
return {
hard: sf.score.parseHard(scoreStr),
medium: sf.score.parseMedium(scoreStr),
soft: sf.score.parseSoft(scoreStr),
};
};
sf.score.colorClass = function (scoreStr) {
var hard = sf.score.parseHard(scoreStr);
var soft = sf.score.parseSoft(scoreStr);
return hard < 0 ? 'score-red' : soft < 0 ? 'score-yellow' : 'score-green';
};
})(SF);
(function (sf) {
'use strict';
var SEQUENCE_1 = [0x8AE234, 0xFCE94F, 0x729FCF, 0xE9B96E, 0xAD7FA8];
var SEQUENCE_2 = [0x73D216, 0xEDD400, 0x3465A4, 0xC17D11, 0x75507B];
var colorMap = {};
var nextColorCount = 0;
function buildPercentageColor(floor, ceil, pct) {
var red = (floor & 0xFF0000) + Math.floor(pct * ((ceil & 0xFF0000) - (floor & 0xFF0000))) & 0xFF0000;
var green = (floor & 0x00FF00) + Math.floor(pct * ((ceil & 0x00FF00) - (floor & 0x00FF00))) & 0x00FF00;
var blue = (floor & 0x0000FF) + Math.floor(pct * ((ceil & 0x0000FF) - (floor & 0x0000FF))) & 0x0000FF;
return red | green | blue;
}
function nextColor() {
var colorIndex = nextColorCount % SEQUENCE_1.length;
var shadeIndex = Math.floor(nextColorCount / SEQUENCE_1.length);
var color;
if (shadeIndex === 0) {
color = SEQUENCE_1[colorIndex];
} else if (shadeIndex === 1) {
color = SEQUENCE_2[colorIndex];
} else {
shadeIndex -= 3;
var base = Math.floor((shadeIndex / 2) + 1);
var divisor = 2;
while (base >= divisor) divisor *= 2;
base = (base * 2) - divisor + 1;
color = buildPercentageColor(SEQUENCE_2[colorIndex], SEQUENCE_1[colorIndex], base / divisor);
}
nextColorCount++;
return '#' + color.toString(16).padStart(6, '0');
}
sf.colors = {};
sf.colors.pick = function (key) {
if (colorMap[key] !== undefined) return colorMap[key];
var c = nextColor();
colorMap[key] = c;
return c;
};
sf.colors.reset = function () {
colorMap = {};
nextColorCount = 0;
};
var PROJECT_COLORS = [
{ main: '#10b981', dark: '#047857', light: 'rgba(16,185,129,0.15)' },
{ main: '#3b82f6', dark: '#1d4ed8', light: 'rgba(59,130,246,0.15)' },
{ main: '#8b5cf6', dark: '#6d28d9', light: 'rgba(139,92,246,0.15)' },
{ main: '#f59e0b', dark: '#b45309', light: 'rgba(245,158,11,0.15)' },
{ main: '#ec4899', dark: '#be185d', light: 'rgba(236,72,153,0.15)' },
{ main: '#06b6d4', dark: '#0e7490', light: 'rgba(6,182,212,0.15)' },
{ main: '#f43f5e', dark: '#be123c', light: 'rgba(244,63,94,0.15)' },
{ main: '#84cc16', dark: '#4d7c0f', light: 'rgba(132,204,22,0.15)' },
];
sf.colors.project = function (index) {
return PROJECT_COLORS[index % PROJECT_COLORS.length];
};
})(SF);
(function (sf) {
'use strict';
sf.createButton = function (config) {
sf.assert(config, 'createButton(config) requires a configuration object');
var classes = ['sf-btn'];
if (config.variant) classes.push('sf-btn--' + config.variant);
if (config.size === 'small') classes.push('sf-btn--sm');
if (config.size === 'large') classes.push('sf-btn--lg');
if (config.pill) classes.push('sf-btn--pill');
if (config.circle) classes.push('sf-btn--circle');
if (config.outline) classes.push('sf-btn--outline');
if (config.iconOnly) classes.push('sf-btn--icon');
var btn = sf.el('button', {
className: classes.join(' '),
type: 'button',
});
if (config.disabled) btn.disabled = true;
sf.assert(!config.onClick || typeof config.onClick === 'function', 'createButton(onClick) must be a function');
if (config.icon) {
var icon = sf.el('i', { className: 'fa-solid ' + config.icon });
btn.appendChild(icon);
}
if (config.text && !config.circle && !config.iconOnly) {
btn.appendChild(document.createTextNode(config.text));
}
if (config.onClick) {
btn.addEventListener('click', config.onClick);
}
if (config.tooltip) {
btn.title = config.tooltip;
}
if (config.ariaLabel) {
btn.setAttribute('aria-label', config.ariaLabel);
} else if (config.iconOnly && config.text) {
btn.setAttribute('aria-label', config.text);
} else if (config.icon && !config.text) {
btn.setAttribute('aria-label', config.icon.replace(/fa-/, '').replace(/-/g, ' '));
}
if (config.id) {
btn.id = config.id;
}
if (config.dataset) {
Object.assign(btn.dataset, config.dataset);
}
return btn;
};
})(SF);
(function (sf) {
'use strict';
sf.createHeader = function (config) {
sf.assert(config, 'createHeader(config) requires a configuration object');
var header = sf.el('header', { className: 'sf-header' });
var controls = {
actions: null,
spinner: null,
solveBtn: null,
pauseBtn: null,
resumeBtn: null,
cancelBtn: null,
analyzeBtn: null,
nav: null,
};
if (config.logo) {
var logo = sf.el('img', {
className: 'sf-header-logo',
src: config.logo,
alt: 'Logo',
});
header.appendChild(logo);
}
var brand = sf.el('div', { className: 'sf-header-brand' });
if (config.title) {
brand.appendChild(sf.el('div', { className: 'sf-header-title' }, config.title));
}
if (config.subtitle) {
brand.appendChild(sf.el('div', { className: 'sf-header-subtitle' }, config.subtitle));
}
header.appendChild(brand);
if (config.tabs && config.tabs.length > 0) {
sf.assert(Array.isArray(config.tabs), 'createHeader(config.tabs) expects an array');
var nav = sf.el('nav', { className: 'sf-header-nav' });
controls.nav = nav;
config.tabs.forEach(function (tab) {
sf.assert(tab && tab.id, 'createHeader tab entries require an id');
sf.assert(typeof tab.label === 'string', 'createHeader tab entries require a label');
var btn = sf.el('button', {
className: 'sf-nav-btn' + (tab.active ? ' active' : ''),
role: 'tab',
'aria-selected': !!tab.active,
tabIndex: 0,
dataset: { tab: tab.id },
onKeyDown: function (e) {
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
var buttons = nav.querySelectorAll('.sf-nav-btn');
var list = Array.prototype.slice.call(buttons);
var nextIndex = e.key === 'ArrowRight'
? (list.indexOf(btn) + 1) % list.length
: (list.length + list.indexOf(btn) - 1) % list.length;
var next = list[nextIndex];
if (next && next.focus) next.focus();
},
onClick: function () {
nav.querySelectorAll('.sf-nav-btn').forEach(function (b) { b.classList.remove('active'); });
btn.classList.add('active');
nav.querySelectorAll('.sf-nav-btn').forEach(function (b) {
b.setAttribute('aria-selected', b === btn ? 'true' : 'false');
});
if (config.onTabChange) config.onTabChange(tab.id);
},
});
if (tab.icon) {
btn.appendChild(sf.el('i', { className: 'fa-solid ' + tab.icon }));
}
btn.appendChild(document.createTextNode(tab.label));
nav.appendChild(btn);
});
header.appendChild(nav);
}
if (config.actions) {
sf.assert(typeof config.actions === 'object', 'createHeader(config.actions) expects an object');
sf.assert(!config.actions.onSolve || typeof config.actions.onSolve === 'function', 'createHeader(config.actions.onSolve) must be a function');
sf.assert(!config.actions.onPause || typeof config.actions.onPause === 'function', 'createHeader(config.actions.onPause) must be a function');
sf.assert(!config.actions.onResume || typeof config.actions.onResume === 'function', 'createHeader(config.actions.onResume) must be a function');
sf.assert(!config.actions.onCancel || typeof config.actions.onCancel === 'function', 'createHeader(config.actions.onCancel) must be a function');
sf.assert(!config.actions.onAnalyze || typeof config.actions.onAnalyze === 'function', 'createHeader(config.actions.onAnalyze) must be a function');
sf.assert(!config.onTabChange || typeof config.onTabChange === 'function', 'createHeader(config.onTabChange) must be a function');
var actions = sf.el('div', { className: 'sf-header-actions' });
controls.actions = actions;
var spinner = sf.el('div', { className: 'sf-solving-spinner' });
controls.spinner = spinner;
actions.appendChild(spinner);
if (config.actions.onSolve) {
var solveBtn = sf.createButton({
text: 'Solve',
variant: 'success',
icon: 'fa-play',
onClick: config.actions.onSolve,
});
controls.solveBtn = solveBtn;
actions.appendChild(solveBtn);
}
if (config.actions.onPause) {
var pauseBtn = sf.createButton({
text: 'Pause',
variant: 'default',
icon: 'fa-pause',
onClick: config.actions.onPause,
});
pauseBtn.style.display = 'none';
controls.pauseBtn = pauseBtn;
actions.appendChild(pauseBtn);
}
if (config.actions.onResume) {
var resumeBtn = sf.createButton({
text: 'Resume',
variant: 'primary',
icon: 'fa-play',
onClick: config.actions.onResume,
});
resumeBtn.style.display = 'none';
controls.resumeBtn = resumeBtn;
actions.appendChild(resumeBtn);
}
if (config.actions.onCancel) {
var cancelBtn = sf.createButton({
text: 'Cancel',
variant: 'danger',
icon: 'fa-ban',
onClick: config.actions.onCancel,
});
cancelBtn.style.display = 'none';
controls.cancelBtn = cancelBtn;
actions.appendChild(cancelBtn);
}
if (config.actions.onAnalyze) {
var analyzeBtn = sf.createButton({
variant: 'ghost',
icon: 'fa-chart-bar',
circle: true,
tooltip: 'Score Analysis',
onClick: config.actions.onAnalyze,
});
controls.analyzeBtn = analyzeBtn;
actions.appendChild(analyzeBtn);
}
header.appendChild(actions);
}
header.sfControls = controls;
return header;
};
})(SF);
(function (sf) {
'use strict';
sf.createStatusBar = function (config) {
var bar = sf.el('div', { className: 'sf-statusbar' });
var lastScore = null;
var controls = null;
var scoreEl = sf.el('span', { className: 'sf-statusbar-score', id: 'sfScoreDisplay', 'aria-live': 'polite' }, '\u2014');
bar.appendChild(scoreEl);
bar.appendChild(sf.el('span', { className: 'sf-statusbar-sep' }, '|'));
var dotsContainer = sf.el('div', { className: 'sf-statusbar-constraints' });
bar.appendChild(dotsContainer);
var movesSep = sf.el('span', { className: 'sf-statusbar-sep' }, '|');
movesSep.style.display = 'none';
bar.appendChild(movesSep);
var movesEl = sf.el('span');
movesEl.style.display = 'none';
bar.appendChild(movesEl);
bar.appendChild(sf.el('span', { className: 'sf-statusbar-sep' }, '|'));
var statusEl = sf.el('span', { id: 'sfStatusText', role: 'status', 'aria-live': 'polite' });
bar.appendChild(statusEl);
if (config && config.constraints) {
buildDots(dotsContainer, config.constraints, config.onConstraintClick);
}
var api = { el: bar };
api.bindHeader = function (header) {
controls = header && header.sfControls ? header.sfControls : null;
return api;
};
api.updateScore = function (scoreStr) {
if (scoreStr && scoreStr !== lastScore) {
scoreEl.textContent = scoreStr;
var colorClass = sf.score.colorClass(scoreStr);
scoreEl.classList.remove('improved', 'score-green', 'score-red', 'score-yellow');
scoreEl.classList.add(colorClass);
void scoreEl.offsetWidth;
scoreEl.classList.add('improved');
lastScore = scoreStr;
} else if (!scoreStr) {
scoreEl.textContent = '\u2014';
scoreEl.classList.remove('score-green', 'score-red', 'score-yellow', 'improved');
}
};
api.setLifecycleState = function (state) {
var normalized = normalizeLifecycleState(state);
var solveBtn = controls && controls.solveBtn;
var pauseBtn = controls && controls.pauseBtn;
var resumeBtn = controls && controls.resumeBtn;
var cancelBtn = controls && controls.cancelBtn;
var spinner = controls && controls.spinner;
if (solveBtn) solveBtn.style.display = shouldShowSolve(normalized) ? '' : 'none';
if (pauseBtn) {
pauseBtn.style.display = shouldShowPause(normalized) ? '' : 'none';
pauseBtn.disabled = normalized === 'PAUSE_REQUESTED';
}
if (resumeBtn) resumeBtn.style.display = normalized === 'PAUSED' ? '' : 'none';
if (cancelBtn) cancelBtn.style.display = shouldShowCancel(normalized) ? '' : 'none';
if (spinner) spinner.classList.toggle('active', shouldSpin(normalized));
statusEl.textContent = lifecycleLabel(normalized);
statusEl.style.color = isActiveLifecycle(normalized)
? 'var(--sf-emerald-600)'
: normalized === 'FAILED'
? 'var(--sf-red-600)'
: normalized === 'CANCELLED'
? 'var(--sf-amber-700)'
: 'var(--sf-gray-500)';
};
api.setSolving = function (solving) {
api.setLifecycleState(solving ? 'SOLVING' : 'IDLE');
};
api.updateMoves = function (mps) {
if (mps != null && mps > 0) {
movesEl.textContent = mps.toLocaleString() + ' moves/s';
movesEl.style.display = '';
movesSep.style.display = '';
} else {
movesEl.style.display = 'none';
movesSep.style.display = 'none';
}
};
api.updateConstraintDots = function (constraints) {
buildDots(dotsContainer, constraints, config && config.onConstraintClick);
};
api.colorDotsByScore = function (scoreStr) {
var hard = sf.score.parseHard(scoreStr);
var soft = sf.score.parseSoft(scoreStr);
dotsContainer.querySelectorAll('.sf-constraint-dot').forEach(function (dot) {
var isHard = dot.dataset.type === 'hard';
dot.classList.toggle('violated', isHard && hard < 0);
dot.classList.toggle('violated-soft', !isHard && soft < 0);
});
};
api.colorDotsFromAnalysis = function (constraints) {
if (!constraints || constraints.length === 0) return;
buildDots(dotsContainer, constraints, config && config.onConstraintClick);
dotsContainer.querySelectorAll('.sf-constraint-dot').forEach(function (dot, i) {
var c = constraints[i];
if (!dot) return;
var isHard = c.type === 'hard';
var scoreVal = isHard ? sf.score.parseHard(c.score) : sf.score.parseSoft(c.score);
var violated = scoreVal < 0;
dot.classList.toggle('violated', isHard && violated);
dot.classList.toggle('violated-soft', !isHard && violated);
});
};
if (config && config.header) {
api.bindHeader(config.header);
}
api.setLifecycleState('IDLE');
return api;
};
function buildDots(container, constraints, onClick) {
container.innerHTML = '';
if (!constraints) return;
constraints.forEach(function (c, i) {
var dot = sf.el('div', {
className: 'sf-constraint-dot',
id: 'sf-cdot-' + i,
title: c.name || ('Constraint ' + i),
role: onClick ? 'button' : null,
tabIndex: onClick ? '0' : null,
'aria-label': onClick ? ('Open constraint ' + (c.name || ('Constraint ' + i))) : null,
dataset: { type: c.type || 'hard', index: String(i) },
});
if (onClick) {
dot.style.cursor = 'pointer';
sf.bindActivation(dot, function () { onClick(i); });
}
container.appendChild(dot);
});
}
function normalizeLifecycleState(value) {
if (typeof value !== 'string' || !value.trim()) return 'IDLE';
return value
.trim()
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.replace(/[\s-]+/g, '_')
.toUpperCase();
}
function shouldShowSolve(state) {
return state === 'IDLE'
|| state === 'COMPLETED'
|| state === 'CANCELLED'
|| state === 'FAILED'
|| state === 'TERMINATED_BY_CONFIG';
}
function shouldShowPause(state) {
return state === 'STARTING'
|| state === 'SOLVING'
|| state === 'PAUSE_REQUESTED'
|| state === 'RESUMING';
}
function shouldShowCancel(state) {
return state === 'STARTING'
|| state === 'SOLVING'
|| state === 'PAUSE_REQUESTED'
|| state === 'PAUSED'
|| state === 'RESUMING'
|| state === 'CANCELLING';
}
function shouldSpin(state) {
return state === 'STARTING'
|| state === 'SOLVING'
|| state === 'PAUSE_REQUESTED'
|| state === 'RESUMING'
|| state === 'CANCELLING';
}
function isActiveLifecycle(state) {
return shouldSpin(state);
}
function lifecycleLabel(state) {
if (state === 'STARTING') return 'Starting...';
if (state === 'SOLVING') return 'Solving...';
if (state === 'PAUSE_REQUESTED') return 'Pause requested...';
if (state === 'PAUSED') return 'Paused';
if (state === 'RESUMING') return 'Resuming...';
if (state === 'CANCELLING') return 'Cancelling...';
if (state === 'COMPLETED') return 'Completed';
if (state === 'CANCELLED') return 'Cancelled';
if (state === 'FAILED') return 'Failed';
if (state === 'TERMINATED_BY_CONFIG') return 'Completed';
return 'Ready';
}
})(SF);
(function (sf) {
'use strict';
sf.createModal = function (config) {
sf.assert(config, 'createModal(config) requires a configuration object');
sf.assert(!config.footer || Array.isArray(config.footer), 'createModal(config.footer) must be an array');
var overlay = sf.el('div', { className: 'sf-modal-overlay' });
var dialogId = sf.uid('sf-modal');
var dialog = sf.el('div', {
className: 'sf-modal',
id: dialogId,
role: 'dialog',
'aria-modal': 'true',
'aria-labelledby': dialogId + '-title',
});
var body = sf.el('div', { className: 'sf-modal-body' });
var header = sf.el('div', { className: 'sf-modal-header' });
var titleEl = sf.el('div', { className: 'sf-modal-title', id: dialogId + '-title' }, config.title || '');
header.appendChild(titleEl);
var closeBtn = sf.el('button', {
className: 'sf-modal-close',
html: '×',
'aria-label': 'Close modal',
onClick: function () { api.close(); },
}, '×');
header.appendChild(closeBtn);
dialog.appendChild(header);
setBodyContent(body, config.body, config.unsafeBody);
dialog.appendChild(body);
if (config.footer) {
var footer = sf.el('div', { className: 'sf-modal-footer' });
config.footer.forEach(function (child) {
footer.appendChild(child);
});
dialog.appendChild(footer);
}
overlay.appendChild(dialog);
var previousFocus = null;
overlay.addEventListener('click', function (e) {
if (e.target === overlay) api.close();
});
function onKeyDown(e) {
if (e.key === 'Escape') api.close();
}
var api = { el: overlay, body: body };
api.open = function () {
previousFocus = document.activeElement;
document.body.appendChild(overlay);
if (closeBtn.focus) closeBtn.focus();
overlay.classList.add('open');
document.addEventListener('keydown', onKeyDown);
};
api.close = function () {
overlay.classList.remove('open');
document.removeEventListener('keydown', onKeyDown);
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
if (previousFocus && previousFocus.focus) previousFocus.focus();
if (config.onClose) config.onClose();
};
api.setBody = function (content) {
setBodyContent(body, content);
};
if (config.width) {
dialog.style.maxWidth = config.width;
}
return api;
};
function setBodyContent(target, content, explicitUnsafeHtml) {
target.textContent = '';
if (explicitUnsafeHtml != null) {
target.innerHTML = explicitUnsafeHtml;
} else if (typeof content === 'string') {
target.textContent = content;
} else if (content && content.unsafeBody) {
target.innerHTML = content.unsafeBody;
} else if (content && content.unsafeHtml) {
target.innerHTML = content.unsafeHtml;
} else if (content instanceof Node) {
target.appendChild(content);
}
}
})(SF);
(function (sf) {
'use strict';
sf.showTab = function (tabId, root) {
if (root) {
activateTabInScope(root, tabId);
return;
}
document.querySelectorAll('.sf-tabs-container').forEach(function (container) {
activateTabInScope(container, tabId);
});
};
sf.createTabs = function (config) {
sf.assert(config, 'createTabs(config) requires a configuration object');
sf.assert(Array.isArray(config.tabs), 'createTabs(config.tabs) must be an array');
var container = sf.el('div', { className: 'sf-tabs-container' });
var tabsId = sf.uid('sf-tabs');
config.tabs.forEach(function (tab) {
var panel = sf.el('div', {
className: 'sf-tab-panel' + (tab.active ? ' active' : ''),
id: tabsId + '-' + tab.id,
dataset: { tabId: tab.id },
});
if (tab.content) {
if (typeof tab.content === 'string') panel.textContent = tab.content;
else if (tab.content && tab.content.unsafeHtml) panel.innerHTML = tab.content.unsafeHtml;
else if (tab.content instanceof Node) panel.appendChild(tab.content);
}
container.appendChild(panel);
});
return {
el: container,
show: function (tabId) {
sf.showTab(tabId, container);
},
};
};
function activateTabInScope(scope, tabId) {
scope.querySelectorAll('.sf-tab-panel').forEach(function (p) {
p.classList.remove('active');
});
var panel = scope.querySelector('[data-tab-id="' + tabId + '"]');
if (panel) panel.classList.add('active');
}
})(SF);
(function (sf) {
'use strict';
sf.createTable = function (config) {
sf.assert(config, 'createTable(config) requires a configuration object');
sf.assert(!config.columns || Array.isArray(config.columns), 'createTable(config.columns) must be an array');
sf.assert(!config.rows || Array.isArray(config.rows), 'createTable(config.rows) must be an array');
var wrapper = sf.el('div', { className: 'sf-table-container' });
var table = sf.el('table', { className: 'sf-table' });
if (config.columns) {
var thead = sf.el('thead');
var tr = sf.el('tr');
config.columns.forEach(function (col) {
var th = sf.el('th', null, typeof col === 'string' ? col : col.label);
if (col.align) th.style.textAlign = col.align;
if (col.width) th.style.width = col.width;
tr.appendChild(th);
});
thead.appendChild(tr);
table.appendChild(thead);
}
var tbody = sf.el('tbody');
if (config.rows) {
config.rows.forEach(function (row, rowIdx) {
var tr = sf.el('tr');
row.forEach(function (cell, colIdx) {
var td = sf.el('td');
if (typeof cell === 'string' || typeof cell === 'number') {
td.textContent = cell;
} else if (cell instanceof Node) {
td.appendChild(cell);
} else if (cell && cell.unsafeHtml) {
td.innerHTML = cell.unsafeHtml;
}
var col = config.columns && config.columns[colIdx];
if (col && col.align) td.style.textAlign = col.align;
if (col && col.className) td.classList.add(col.className);
tr.appendChild(td);
});
if (config.onRowClick) {
tr.style.cursor = 'pointer';
tr.setAttribute('role', 'button');
tr.tabIndex = 0;
sf.bindActivation(tr, function () { config.onRowClick(rowIdx, row); });
}
tbody.appendChild(tr);
});
}
table.appendChild(tbody);
wrapper.appendChild(table);
return wrapper;
};
})(SF);
(function (sf) {
'use strict';
var container = null;
function ensureContainer() {
if (container && document.body.contains(container)) return;
container = sf.el('div', { className: 'sf-toast-container' });
document.body.appendChild(container);
}
sf.showToast = function (config) {
sf.assert(config, 'showToast(config) requires a configuration object');
ensureContainer();
var variant = config.variant || 'danger';
var toast = sf.el('div', {
className: 'sf-toast sf-toast--' + variant + ' sf-toast-enter',
role: 'status',
'aria-live': 'polite',
});
var msg = sf.el('div', { className: 'sf-toast-message' });
if (config.title) {
msg.appendChild(sf.el('div', { className: 'sf-toast-title' }, config.title));
}
if (config.message) {
msg.appendChild(sf.el('div', null, config.message));
}
if (config.detail) {
var pre = sf.el('pre', { style: { margin: '4px 0 0', fontSize: '11px', whiteSpace: 'pre-wrap' } });
pre.appendChild(sf.el('code', null, config.detail));
msg.appendChild(pre);
}
toast.appendChild(msg);
var closeBtn = sf.el('button', {
className: 'sf-toast-close',
html: '×',
'aria-label': 'Dismiss toast',
onClick: function () { dismiss(); },
}, '×');
toast.appendChild(closeBtn);
container.appendChild(toast);
var delay = config.delay || 10000;
var timer = setTimeout(dismiss, delay);
function dismiss() {
clearTimeout(timer);
toast.classList.remove('sf-toast-enter');
toast.classList.add('sf-toast-exit');
setTimeout(function () {
if (toast.parentNode) toast.parentNode.removeChild(toast);
}, 200);
}
};
sf.showError = function (title, detail) {
sf.showToast({ title: 'Error', message: title, detail: detail, variant: 'danger', delay: 30000 });
};
})(SF);
(function (sf) {
'use strict';
sf.createBackend = function (config) {
config = config || {};
var type = config.type || 'axum';
if (type === 'tauri') return createTauriBackend(config);
return createHttpBackend(config);
};
function resolveJobId(raw) {
if (raw == null) return '';
if (typeof raw === 'string' || typeof raw === 'number') return String(raw).trim();
if (typeof raw !== 'object') return '';
if (raw.id != null) return String(raw.id).trim();
if (raw.jobId != null) return String(raw.jobId).trim();
if (raw.job_id != null) return String(raw.job_id).trim();
if (raw.data && typeof raw.data === 'object' && raw.data.id != null) {
return String(raw.data.id).trim();
}
return '';
}
function resolveEventJobId(payload) {
if (!payload || typeof payload !== 'object') return '';
if (payload.jobId != null) return String(payload.jobId).trim();
if (payload.job_id != null) return String(payload.job_id).trim();
if (payload.id != null) return String(payload.id).trim();
if (payload.data && typeof payload.data === 'object' && payload.data.id != null) return String(payload.data.id).trim();
if (payload.data && typeof payload.data === 'object' && payload.data.jobId != null) return String(payload.data.jobId).trim();
return '';
}
function withSnapshotRevision(path, snapshotRevision) {
if (snapshotRevision == null || snapshotRevision === '') return path;
return path + '?snapshot_revision=' + encodeURIComponent(String(snapshotRevision));
}
function createHttpBackend(config) {
var baseUrl = config.baseUrl || '';
var jobsPath = config.jobsPath || '/jobs';
var demoDataPath = config.demoDataPath || '/demo-data';
var extraHeaders = config.headers || {};
function headers(extra) {
var h = Object.assign({ 'Content-Type': 'application/json' }, extraHeaders, extra || {});
return h;
}
function createRequestError(method, path, res) {
var err = new Error(res.status + ' ' + res.statusText);
err.status = res.status;
err.statusText = res.statusText;
err.method = method;
err.path = path;
err.url = baseUrl + path;
return err;
}
function request(method, path, body) {
var opts = { method: method, headers: headers() };
if (body !== undefined) opts.body = JSON.stringify(body);
return fetch(baseUrl + path, opts).then(function (res) {
if (!res.ok) throw createRequestError(method, path, res);
var ct = res.headers.get('content-type') || '';
return ct.indexOf('json') !== -1 ? res.json() : res.text();
});
}
return {
createJob: function (data) {
return request('POST', jobsPath, data).then(resolveJobId);
},
getJob: function (id) {
return request('GET', jobsPath + '/' + id);
},
getJobStatus: function (id) {
return request('GET', jobsPath + '/' + id + '/status');
},
getSnapshot: function (id, snapshotRevision) {
return request('GET', withSnapshotRevision(jobsPath + '/' + id + '/snapshot', snapshotRevision));
},
analyzeSnapshot: function (id, snapshotRevision) {
return request('GET', withSnapshotRevision(jobsPath + '/' + id + '/analysis', snapshotRevision));
},
pauseJob: function (id) {
return request('POST', jobsPath + '/' + id + '/pause');
},
resumeJob: function (id) {
return request('POST', jobsPath + '/' + id + '/resume');
},
cancelJob: function (id) {
return request('POST', jobsPath + '/' + id + '/cancel');
},
deleteJob: function (id) {
return request('DELETE', jobsPath + '/' + id);
},
getDemoData: function (name) {
return request('GET', demoDataPath + '/' + (name || 'STANDARD'));
},
listDemoData: function () {
return request('GET', demoDataPath);
},
streamJobEvents: function (id, onMessage, onError) {
var url = baseUrl + jobsPath + '/' + id + '/events';
var es = new EventSource(url);
var closed = false;
es.onmessage = function (e) {
try { onMessage(JSON.parse(e.data)); } catch (_) {}
};
es.onerror = function () {
if (closed || !onError) return;
onError(new Error('Event stream failed for ' + url));
};
return function close() {
closed = true;
es.onmessage = null;
es.onerror = null;
es.close();
};
},
};
}
function createTauriBackend(config) {
sf.assert(typeof config === 'object', 'createBackend({}) is required for Tauri adapter');
sf.assert(typeof config.invoke === 'function', 'Tauri backend requires config.invoke');
sf.assert(typeof config.listen === 'function', 'Tauri backend requires config.listen');
var invoke = config.invoke;
var listen = config.listen;
var commands = config.commands || {};
var eventName = config.eventName || 'solver-update';
return {
createJob: function (data) {
return invoke(commands.createJob || 'create_job', { request: data }).then(resolveJobId);
},
getJob: function (id) {
return invoke(commands.getJob || 'get_job', { id: id });
},
getJobStatus: function (id) {
return invoke(commands.getJobStatus || 'get_job_status', { id: id });
},
getSnapshot: function (id, snapshotRevision) {
var payload = { id: id };
if (snapshotRevision != null && snapshotRevision !== '') payload.snapshotRevision = snapshotRevision;
return invoke(commands.getSnapshot || 'get_snapshot', payload);
},
analyzeSnapshot: function (id, snapshotRevision) {
var payload = { id: id };
if (snapshotRevision != null && snapshotRevision !== '') payload.snapshotRevision = snapshotRevision;
return invoke(commands.analyzeSnapshot || 'analyze_snapshot', payload);
},
pauseJob: function (id) {
return invoke(commands.pauseJob || 'pause_job', { id: id });
},
resumeJob: function (id) {
return invoke(commands.resumeJob || 'resume_job', { id: id });
},
cancelJob: function (id) {
return invoke(commands.cancelJob || 'cancel_job', { id: id });
},
deleteJob: function (id) {
return invoke(commands.deleteJob || 'delete_job', { id: id });
},
getDemoData: function (name) {
return invoke(commands.demoData || 'demo_seed', { name: name });
},
listDemoData: function () {
return Promise.resolve([]);
},
streamJobEvents: function (id, onMessage) {
var targetId = String(id);
var unlisten = null;
listen(eventName, function (event) {
var payload = event && event.payload ? event.payload : {};
var payloadId = resolveEventJobId(payload);
if (payloadId && payloadId !== targetId) return;
onMessage(payload);
}).then(function (fn) { unlisten = fn; });
return function close() { if (unlisten) unlisten(); };
},
};
}
})(SF);
(function (sf) {
'use strict';
sf.createSolver = function (config) {
sf.assert(config, 'createSolver(config) requires a configuration object');
sf.assert(config.backend, 'createSolver(config.backend) is required');
sf.assert(hasFunction(config.backend, 'createJob'), 'createSolver(config.backend.createJob) must be a function');
sf.assert(hasFunction(config.backend, 'getSnapshot'), 'createSolver(config.backend.getSnapshot) must be a function');
sf.assert(hasFunction(config.backend, 'analyzeSnapshot'), 'createSolver(config.backend.analyzeSnapshot) must be a function');
sf.assert(hasFunction(config.backend, 'pauseJob'), 'createSolver(config.backend.pauseJob) must be a function');
sf.assert(hasFunction(config.backend, 'resumeJob'), 'createSolver(config.backend.resumeJob) must be a function');
sf.assert(hasFunction(config.backend, 'cancelJob'), 'createSolver(config.backend.cancelJob) must be a function');
sf.assert(hasFunction(config.backend, 'streamJobEvents'), 'createSolver(config.backend.streamJobEvents) must be a function');
sf.assert(!config.onProgress || typeof config.onProgress === 'function', 'createSolver(config.onProgress) must be a function');
sf.assert(!config.onSolution || typeof config.onSolution === 'function', 'createSolver(config.onSolution) must be a function');
sf.assert(!config.onPauseRequested || typeof config.onPauseRequested === 'function', 'createSolver(config.onPauseRequested) must be a function');
sf.assert(!config.onPaused || typeof config.onPaused === 'function', 'createSolver(config.onPaused) must be a function');
sf.assert(!config.onResumed || typeof config.onResumed === 'function', 'createSolver(config.onResumed) must be a function');
sf.assert(!config.onCancelled || typeof config.onCancelled === 'function', 'createSolver(config.onCancelled) must be a function');
sf.assert(!config.onComplete || typeof config.onComplete === 'function', 'createSolver(config.onComplete) must be a function');
sf.assert(!config.onFailure || typeof config.onFailure === 'function', 'createSolver(config.onFailure) must be a function');
sf.assert(!config.onAnalysis || typeof config.onAnalysis === 'function', 'createSolver(config.onAnalysis) must be a function');
sf.assert(!config.onError || typeof config.onError === 'function', 'createSolver(config.onError) must be a function');
var backend = config.backend;
var statusBar = config.statusBar;
var closeStream = null;
var activeJobId = null;
var retainedJobId = null;
var lifecycleState = 'IDLE';
var phase = 'idle';
var runToken = 0;
var lastSnapshotRevision = null;
var lastMeta = null;
var queuedAction = null;
var pendingPause = null;
var pendingResume = null;
var pendingCancel = null;
var api = {};
api.start = function (data) {
if (phase !== 'idle') return Promise.resolve();
resetForStart();
phase = 'starting';
runToken += 1;
applyLifecycleState('STARTING');
updateMoves(null);
var token = runToken;
return backend.createJob(data).then(function (id) {
if (token !== runToken) return;
ensureJobId(id);
activeJobId = id;
retainedJobId = id;
phase = 'solving';
applyLifecycleState('SOLVING');
closeStream = backend.streamJobEvents(id, function (payload) {
if (token !== runToken) return;
handleEvent(token, id, payload);
}, function (err) {
if (token !== runToken) return;
failTransport(err);
});
if (queuedAction === 'pause') {
queuedAction = null;
requestPause(token, id);
} else if (queuedAction === 'cancel') {
queuedAction = null;
requestCancel(token, id);
}
}).catch(function (err) {
if (token !== runToken) return;
failTransport(err);
throw err;
});
};
api.pause = function () {
if (pendingPause) return pendingPause.promise;
if (phase === 'starting' && !activeJobId) {
queuedAction = 'pause';
pendingPause = createDeferred();
return pendingPause.promise;
}
if (phase !== 'solving' || !activeJobId) return Promise.resolve();
pendingPause = createDeferred();
requestPause(runToken, activeJobId);
return pendingPause.promise;
};
api.resume = function () {
if (pendingResume) return pendingResume.promise;
if (phase !== 'paused' || !activeJobId) return Promise.resolve();
pendingResume = createDeferred();
requestResume(runToken, activeJobId);
return pendingResume.promise;
};
api.cancel = function () {
if (pendingCancel) return pendingCancel.promise;
if (phase === 'starting' && !activeJobId) {
queuedAction = 'cancel';
pendingCancel = createDeferred();
return pendingCancel.promise;
}
if (!activeJobId || !isCancelablePhase()) return Promise.resolve();
pendingCancel = createDeferred();
requestCancel(runToken, activeJobId);
return pendingCancel.promise;
};
api.delete = function () {
if (!retainedJobId || !hasFunction(backend, 'deleteJob')) return Promise.resolve();
if (api.isRunning() || phase === 'paused') {
return Promise.reject(new Error('Cannot delete a live or paused job'));
}
var jobId = retainedJobId;
return backend.deleteJob(jobId).then(function () {
if (retainedJobId !== jobId) return;
retainedJobId = null;
activeJobId = null;
lastSnapshotRevision = null;
lastMeta = null;
applyLifecycleState('IDLE');
updateMoves(null);
}).catch(function (err) {
notifyError(err);
throw err;
});
};
api.getSnapshot = function (snapshotRevision) {
var jobId = currentJobId();
if (!jobId) return Promise.reject(new Error('No retained job is available'));
var revision = resolveRequestedSnapshotRevision(snapshotRevision);
return backend.getSnapshot(jobId, revision).then(function (payload) {
return normalizeSnapshot(payload, lastMeta);
});
};
api.analyzeSnapshot = function (snapshotRevision) {
var jobId = currentJobId();
if (!jobId) return Promise.reject(new Error('No retained job is available'));
var revision = resolveRequestedSnapshotRevision(snapshotRevision);
return backend.analyzeSnapshot(jobId, revision).then(function (payload) {
return normalizeAnalysis(payload, lastMeta);
});
};
api.isRunning = function () {
return phase !== 'idle' && phase !== 'paused';
};
api.getJobId = function () {
return activeJobId || retainedJobId;
};
api.getLifecycleState = function () {
return lifecycleState;
};
api.getSnapshotRevision = function () {
return lastSnapshotRevision;
};
return api;
function requestPause(token, id) {
phase = 'pause-requested';
backend.pauseJob(id).catch(function (err) {
if (token !== runToken) return;
phase = 'solving';
rejectDeferred('pause', err);
notifyError(err);
});
}
function requestResume(token, id) {
phase = 'resuming';
backend.resumeJob(id).catch(function (err) {
if (token !== runToken) return;
phase = 'paused';
rejectDeferred('resume', err);
notifyError(err);
});
}
function requestCancel(token, id) {
phase = 'cancelling';
backend.cancelJob(id).catch(function (err) {
if (token !== runToken) return;
phase = lifecycleState === 'PAUSED' ? 'paused' : 'solving';
rejectDeferred('cancel', err);
notifyError(err);
});
}
function handleEvent(token, expectedId, payload) {
var event = normalizeJobEvent(payload, expectedId);
if (!event) return;
lastMeta = event.meta;
if (event.meta.snapshotRevision != null) {
lastSnapshotRevision = event.meta.snapshotRevision;
}
retainedJobId = event.meta.jobId;
activeJobId = event.meta.jobId;
if (event.eventType === 'progress') {
if (!event.meta.currentScore) return;
phase = 'solving';
applyEventMeta(event.meta);
if (config.onProgress) config.onProgress(event.meta);
return;
}
if (event.eventType === 'best_solution') {
if (!event.solution || !event.meta.currentScore) return;
phase = 'solving';
applyEventMeta(event.meta);
if (config.onSolution) {
config.onSolution(buildLiveSnapshot(event), event.meta);
}
return;
}
if (event.eventType === 'pause_requested') {
phase = 'pause-requested';
applyEventMeta(event.meta);
if (config.onPauseRequested) config.onPauseRequested(event.meta);
return;
}
if (event.eventType === 'paused') {
phase = 'paused';
applyEventMeta(event.meta);
syncSnapshotBundle(event.meta, true).then(function (bundle) {
if (token !== runToken || hasNewerEvent(event.meta)) return;
applyBundle(bundle);
if (config.onPaused && bundle.snapshot) config.onPaused(bundle.snapshot, bundle.meta);
resolveDeferred('pause', bundle);
}).catch(function (err) {
if (token !== runToken || hasNewerEvent(event.meta)) return;
rejectDeferred('pause', err);
notifyError(err);
});
return;
}
if (event.eventType === 'resumed') {
phase = 'solving';
applyEventMeta(event.meta);
if (config.onResumed) config.onResumed(event.meta);
resolveDeferred('resume', event.meta);
return;
}
if (event.eventType === 'completed') {
phase = 'idle';
applyEventMeta(event.meta);
syncSnapshotBundle(event.meta, true).then(function (bundle) {
if (token !== runToken || hasNewerEvent(event.meta)) return;
finalizeTerminal(bundle.meta);
applyBundle(bundle);
if (config.onComplete && bundle.snapshot) config.onComplete(bundle.snapshot, bundle.meta);
settlePendingFromTerminal('completed', bundle);
}).catch(function (err) {
if (token !== runToken || hasNewerEvent(event.meta)) return;
finalizeTerminal(event.meta);
settlePendingFromTerminal('completed', null, err);
notifyError(err);
});
return;
}
if (event.eventType === 'cancelled') {
phase = 'idle';
applyEventMeta(event.meta);
syncSnapshotBundle(event.meta, false).then(function (bundle) {
if (token !== runToken || hasNewerEvent(event.meta)) return;
finalizeTerminal(bundle.meta);
applyBundle(bundle);
if (config.onCancelled) config.onCancelled(bundle.snapshot, bundle.meta);
settlePendingFromTerminal('cancelled', bundle);
}).catch(function (err) {
if (token !== runToken || hasNewerEvent(event.meta)) return;
finalizeTerminal(event.meta);
settlePendingFromTerminal('cancelled', null, err);
notifyError(err);
});
return;
}
if (event.eventType === 'failed') {
phase = 'idle';
applyEventMeta(event.meta);
syncSnapshotBundle(event.meta, false).then(function (bundle) {
if (token !== runToken || hasNewerEvent(event.meta)) return;
finalizeTerminal(bundle.meta);
applyBundle(bundle);
if (config.onFailure) config.onFailure(event.error || 'Solver job failed', bundle.meta, bundle.snapshot, bundle.analysis);
settlePendingFromTerminal('failed', bundle, new Error(event.error || 'Solver job failed'));
}).catch(function (err) {
if (token !== runToken || hasNewerEvent(event.meta)) return;
finalizeTerminal(event.meta);
if (config.onFailure) config.onFailure(event.error || 'Solver job failed', event.meta, null, null);
settlePendingFromTerminal('failed', null, err);
notifyError(err);
});
}
}
function syncSnapshotBundle(meta, requireSnapshot) {
var analysisRequired = !!config.onAnalysis;
var snapshotRevision = meta && meta.snapshotRevision != null ? meta.snapshotRevision : null;
return backend.getSnapshot(meta.jobId, snapshotRevision).then(function (snapshotPayload) {
var snapshot = normalizeSnapshot(snapshotPayload, meta);
if (!snapshot) throw new Error('Solver backend returned an invalid snapshot payload');
var mergedMeta = mergeMeta(meta, snapshot, meta.eventType);
var result = {
meta: mergedMeta,
snapshot: snapshot,
analysis: null,
};
if (!analysisRequired) return result;
return backend.analyzeSnapshot(meta.jobId, mergedMeta.snapshotRevision).then(function (analysisPayload) {
result.analysis = normalizeAnalysis(analysisPayload, mergedMeta);
return result;
});
}).catch(function (err) {
if (requireSnapshot) throw err;
var fallback = { meta: meta, snapshot: null, analysis: null };
if (!analysisRequired || snapshotRevision == null) return fallback;
return backend.analyzeSnapshot(meta.jobId, snapshotRevision).then(function (analysisPayload) {
fallback.analysis = normalizeAnalysis(analysisPayload, meta);
return fallback;
}).catch(function () {
return fallback;
});
});
}
function applyBundle(bundle) {
if (!bundle) return;
lastMeta = bundle.meta;
if (bundle.meta && bundle.meta.snapshotRevision != null) {
lastSnapshotRevision = bundle.meta.snapshotRevision;
}
applyEventMeta(bundle.meta, bundle.analysis);
if (bundle.analysis && config.onAnalysis) config.onAnalysis(bundle.analysis, bundle.meta);
}
function finalizeTerminal(meta) {
closeCurrentStream();
activeJobId = null;
queuedAction = null;
phase = 'idle';
applyLifecycleState(meta && meta.lifecycleState ? meta.lifecycleState : 'IDLE');
updateMoves(null);
}
function failTransport(err) {
retainedJobId = activeJobId || retainedJobId;
closeCurrentStream();
activeJobId = null;
phase = 'idle';
queuedAction = null;
rejectDeferred('pause', err);
rejectDeferred('resume', err);
rejectDeferred('cancel', err);
applyLifecycleState('IDLE');
updateMoves(null);
notifyError(err);
}
function applyEventMeta(meta, analysis) {
applyLifecycleState(meta && meta.lifecycleState ? meta.lifecycleState : lifecycleState);
updateScore(meta && (meta.currentScore || meta.bestScore) ? (meta.currentScore || meta.bestScore) : null);
updateMoves(meta ? readMovesPerSecond(meta.telemetry) : null);
if (analysis) {
var constraints = readAnalysisConstraints(analysis);
if (constraints && constraints.length && statusBar && statusBar.colorDotsFromAnalysis) {
statusBar.colorDotsFromAnalysis(constraints);
}
}
}
function applyLifecycleState(state) {
lifecycleState = state || 'IDLE';
if (!statusBar) return;
if (typeof statusBar.setLifecycleState === 'function') {
statusBar.setLifecycleState(lifecycleState);
return;
}
if (typeof statusBar.setSolving === 'function') {
statusBar.setSolving(isActiveLifecycle(lifecycleState));
}
}
function updateScore(score) {
if (statusBar && typeof statusBar.updateScore === 'function') {
statusBar.updateScore(score);
}
}
function updateMoves(value) {
if (statusBar && typeof statusBar.updateMoves === 'function') {
statusBar.updateMoves(value);
}
}
function resetForStart() {
closeCurrentStream();
activeJobId = null;
lastSnapshotRevision = null;
lastMeta = null;
queuedAction = null;
pendingPause = null;
pendingResume = null;
pendingCancel = null;
}
function closeCurrentStream() {
if (!closeStream) return;
closeStream();
closeStream = null;
}
function currentJobId() {
return activeJobId || retainedJobId;
}
function hasNewerEvent(meta) {
var currentSequence = lastMeta && typeof lastMeta.eventSequence === 'number' ? lastMeta.eventSequence : null;
var candidateSequence = meta && typeof meta.eventSequence === 'number' ? meta.eventSequence : null;
if (currentSequence == null || candidateSequence == null) return false;
return currentSequence > candidateSequence;
}
function resolveRequestedSnapshotRevision(snapshotRevision) {
if (snapshotRevision != null && snapshotRevision !== '') return snapshotRevision;
return lastSnapshotRevision;
}
function isCancelablePhase() {
return phase === 'solving' || phase === 'pause-requested' || phase === 'paused' || phase === 'cancelling';
}
function settlePendingFromTerminal(eventType, bundle, err) {
if (eventType === 'cancelled') {
resolveDeferred('cancel', bundle);
} else if (pendingCancel) {
if (bundle) pendingCancel.resolve(bundle);
else pendingCancel.reject(err || new Error('Cancel did not settle before the job terminated'));
pendingCancel = null;
}
rejectDeferred('pause', err || new Error('Job terminated before pause settled'));
rejectDeferred('resume', err || new Error('Job terminated before resume settled'));
}
function resolveDeferred(name, value) {
var deferred = getDeferred(name);
if (!deferred) return;
deferred.resolve(value);
setDeferred(name, null);
}
function rejectDeferred(name, err) {
var deferred = getDeferred(name);
if (!deferred) return;
deferred.reject(err);
setDeferred(name, null);
}
function getDeferred(name) {
if (name === 'pause') return pendingPause;
if (name === 'resume') return pendingResume;
if (name === 'cancel') return pendingCancel;
return null;
}
function setDeferred(name, value) {
if (name === 'pause') pendingPause = value;
if (name === 'resume') pendingResume = value;
if (name === 'cancel') pendingCancel = value;
}
function notifyError(err) {
if (config.onError) config.onError(err && err.message ? err.message : String(err));
}
function ensureJobId(id) {
if (typeof id === 'string' && id.trim()) return;
throw new Error('Invalid solver backend createJob response');
}
};
function hasFunction(object, key) {
return !!(object && typeof object[key] === 'function');
}
function createDeferred() {
var resolve;
var reject;
var promise = new Promise(function (res, rej) {
resolve = res;
reject = rej;
});
return { promise: promise, resolve: resolve, reject: reject };
}
function normalizeJobEvent(payload, expectedId) {
if (!payload || typeof payload !== 'object') return null;
var eventType = normalizeEventType(readField(payload, ['eventType', 'event_type', 'type']));
if (!eventType) return null;
var jobId = readField(payload, ['jobId', 'job_id', 'id'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) || expectedId;
if (!jobId) return null;
if (String(jobId) !== String(expectedId)) return null;
var meta = {
id: String(jobId),
jobId: String(jobId),
eventType: eventType,
eventSequence: readField(payload, ['eventSequence', 'event_sequence'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]),
lifecycleState: normalizeLifecycleState(readField(payload, ['lifecycleState', 'lifecycle_state', 'solverStatus', 'solver_status'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]), eventType),
terminalReason: readField(payload, ['terminalReason', 'terminal_reason'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) || null,
telemetry: normalizeTelemetry(readField(payload, ['telemetry'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]), payload),
currentScore: readField(payload, ['currentScore', 'current_score'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) || null,
bestScore: readField(payload, ['bestScore', 'best_score'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]) || null,
snapshotRevision: readField(payload, ['snapshotRevision', 'snapshot_revision'], [payload, payload.metadata, payload.data, payload.data && payload.data.metadata]),
};
return {
eventType: eventType,
meta: meta,
solution: payload.solution || (payload.data && payload.data.solution) || null,
error: readField(payload, ['error'], [payload, payload.data]) || null,
};
}
function normalizeSnapshot(payload, fallbackMeta) {
if (!payload || typeof payload !== 'object') return null;
var jobId = readField(payload, ['jobId', 'job_id', 'id'], [payload, payload.data]) || (fallbackMeta && fallbackMeta.jobId) || null;
return {
id: jobId != null ? String(jobId) : null,
jobId: jobId != null ? String(jobId) : null,
snapshotRevision: readField(payload, ['snapshotRevision', 'snapshot_revision'], [payload, payload.data]),
lifecycleState: normalizeLifecycleState(readField(payload, ['lifecycleState', 'lifecycle_state'], [payload, payload.data]), fallbackMeta && fallbackMeta.eventType),
terminalReason: readField(payload, ['terminalReason', 'terminal_reason'], [payload, payload.data]) || null,
currentScore: readField(payload, ['currentScore', 'current_score'], [payload, payload.data]) || null,
bestScore: readField(payload, ['bestScore', 'best_score'], [payload, payload.data]) || null,
telemetry: normalizeTelemetry(readField(payload, ['telemetry'], [payload, payload.data]), payload),
solution: payload.solution || (payload.data && payload.data.solution) || null,
};
}
function normalizeAnalysis(payload, fallbackMeta) {
if (!payload || typeof payload !== 'object') return null;
var analysisBody = payload.analysis || (payload.data && payload.data.analysis) || payload;
var constraints = readAnalysisConstraints(analysisBody);
return {
jobId: readField(payload, ['jobId', 'job_id', 'id'], [payload, payload.data]) || (fallbackMeta && fallbackMeta.jobId) || null,
snapshotRevision: readField(payload, ['snapshotRevision', 'snapshot_revision'], [payload, payload.data]) || (fallbackMeta && fallbackMeta.snapshotRevision) || null,
lifecycleState: normalizeLifecycleState(readField(payload, ['lifecycleState', 'lifecycle_state'], [payload, payload.data]), fallbackMeta && fallbackMeta.eventType),
terminalReason: readField(payload, ['terminalReason', 'terminal_reason'], [payload, payload.data]) || (fallbackMeta && fallbackMeta.terminalReason) || null,
analysis: analysisBody,
score: analysisBody && analysisBody.score != null ? analysisBody.score : null,
constraints: constraints,
};
}
function buildLiveSnapshot(event) {
return {
id: event.meta.jobId,
jobId: event.meta.jobId,
snapshotRevision: event.meta.snapshotRevision,
lifecycleState: event.meta.lifecycleState,
terminalReason: event.meta.terminalReason,
currentScore: event.meta.currentScore,
bestScore: event.meta.bestScore,
telemetry: event.meta.telemetry,
solution: event.solution,
};
}
function mergeMeta(meta, snapshot, eventType) {
if (!snapshot) return meta;
return {
id: meta && meta.id ? meta.id : snapshot.id,
jobId: meta && meta.jobId ? meta.jobId : snapshot.jobId,
eventType: meta && meta.eventType ? meta.eventType : eventType,
eventSequence: meta ? meta.eventSequence : null,
lifecycleState: (meta && meta.lifecycleState) || snapshot.lifecycleState || normalizeLifecycleState(null, eventType),
terminalReason: (meta && meta.terminalReason) || snapshot.terminalReason || null,
telemetry: snapshot.telemetry || (meta && meta.telemetry) || null,
currentScore: snapshot.currentScore || (meta && meta.currentScore) || null,
bestScore: snapshot.bestScore || (meta && meta.bestScore) || null,
snapshotRevision: snapshot.snapshotRevision != null ? snapshot.snapshotRevision : (meta && meta.snapshotRevision),
};
}
function readField(payload, names, sources) {
var fields = Array.isArray(names) ? names : [names];
var roots = sources || [payload];
for (var i = 0; i < roots.length; i++) {
var source = roots[i];
if (!source || typeof source !== 'object') continue;
for (var j = 0; j < fields.length; j++) {
if (source[fields[j]] != null) return source[fields[j]];
}
}
return null;
}
function normalizeEventType(value) {
if (typeof value !== 'string') return null;
var normalized = value
.trim()
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.replace(/[\s-]+/g, '_')
.toLowerCase();
if (!normalized) return null;
if (normalized === 'finished') return 'completed';
return normalized;
}
function normalizeLifecycleState(value, eventType) {
if (typeof value === 'string' && value.trim()) {
return value
.trim()
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.replace(/[\s-]+/g, '_')
.toUpperCase();
}
if (eventType === 'progress' || eventType === 'best_solution' || eventType === 'resumed') return 'SOLVING';
if (eventType === 'pause_requested') return 'PAUSE_REQUESTED';
if (eventType === 'paused') return 'PAUSED';
if (eventType === 'completed') return 'COMPLETED';
if (eventType === 'cancelled') return 'CANCELLED';
if (eventType === 'failed') return 'FAILED';
return 'IDLE';
}
function normalizeTelemetry(rawTelemetry, payload) {
if (rawTelemetry && typeof rawTelemetry === 'object') return rawTelemetry;
var telemetry = {};
var movesPerSecond = readField(payload, ['movesPerSecond', 'moves_per_second']);
var stepCount = readField(payload, ['stepCount', 'step_count']);
if (movesPerSecond != null) telemetry.movesPerSecond = movesPerSecond;
if (stepCount != null) telemetry.stepCount = stepCount;
return Object.keys(telemetry).length ? telemetry : null;
}
function readMovesPerSecond(telemetry) {
if (!telemetry || typeof telemetry !== 'object') return null;
if (telemetry.movesPerSecond != null) return telemetry.movesPerSecond;
if (telemetry.moves_per_second != null) return telemetry.moves_per_second;
return null;
}
function readAnalysisConstraints(analysis) {
if (!analysis || typeof analysis !== 'object') return null;
if (Array.isArray(analysis.constraints)) return analysis.constraints;
if (analysis.analysis && Array.isArray(analysis.analysis.constraints)) return analysis.analysis.constraints;
return null;
}
function isActiveLifecycle(state) {
return state === 'STARTING'
|| state === 'SOLVING'
|| state === 'PAUSE_REQUESTED'
|| state === 'RESUMING'
|| state === 'CANCELLING';
}
})(SF);
(function (sf) {
'use strict';
sf.createApiGuide = function (config) {
sf.assert(config, 'createApiGuide(config) requires a configuration object');
sf.assert(Array.isArray(config.endpoints), 'createApiGuide(config.endpoints) must be an array');
var guide = sf.el('div', { className: 'sf-api-guide' });
var endpoints = config.endpoints;
endpoints.forEach(function (ep) {
var section = sf.el('div', { className: 'sf-api-section' });
section.appendChild(sf.el('h3', null, (ep.method || 'GET') + ' ' + ep.path));
if (ep.description) {
section.appendChild(sf.el('p', { style: { fontSize: '13px', color: 'var(--sf-gray-600)', marginBottom: '8px' } }, ep.description));
}
if (ep.curl) {
var block = sf.el('div', { className: 'sf-api-code-block' });
block.appendChild(sf.el('code', null, ep.curl));
var copyBtn = sf.el('button', {
className: 'sf-copy-btn',
'aria-label': 'Copy command',
onClick: function () {
navigator.clipboard.writeText(ep.curl).then(function () {
copyBtn.textContent = 'Copied!';
setTimeout(function () { copyBtn.textContent = 'Copy'; }, 1500);
});
},
}, 'Copy');
block.appendChild(copyBtn);
section.appendChild(block);
}
guide.appendChild(section);
});
return guide;
};
})(SF);
(function (sf) {
'use strict';
sf.rail = {};
sf.rail.createHeader = function (config) {
sf.assert(config, 'createHeader(config) requires a configuration object');
sf.assert(!config.columns || Array.isArray(config.columns), 'createHeader(config.columns) expects an array');
var labelWidth = config.labelWidth || 200;
var columns = config.columns || [];
var header = sf.el('div', { className: 'sf-timeline-header' });
header.style.gridTemplateColumns = labelWidth + 'px 1fr';
var spacer = sf.el('div', { className: 'sf-timeline-label-spacer' }, config.label || '');
header.appendChild(spacer);
var days = sf.el('div', { className: 'sf-timeline-days' });
days.style.gridTemplateColumns = 'repeat(' + columns.length + ', 1fr)';
columns.forEach(function (col) {
var colEl = sf.el('div', { className: 'sf-timeline-day-col' });
colEl.appendChild(sf.el('span', null, typeof col === 'string' ? col : col.label));
days.appendChild(colEl);
});
header.appendChild(days);
return header;
};
sf.rail.createCard = function (config) {
sf.assert(config, 'createCard(config) requires a configuration object');
var labelWidth = config.labelWidth || 200;
var card = sf.el('div', { className: 'sf-resource-card' });
var state = {
unassigned: [],
railConfig: config,
};
if (config.id) card.dataset.resourceId = config.id;
var resHeader = sf.el('div', { className: 'sf-resource-header' });
resHeader.style.gridTemplateColumns = labelWidth + 'px 1fr';
var identity = sf.el('div', { className: 'sf-resource-identity' });
if (config.name) {
identity.appendChild(sf.el('div', { className: 'sf-resource-name' }, config.name));
}
if (config.badges || config.type) {
var meta = sf.el('div', { className: 'sf-resource-meta' });
if (config.type) {
var badge = sf.el('span', { className: 'sf-resource-type-badge' }, config.type);
if (config.typeStyle) {
badge.style.background = config.typeStyle.bg || '';
badge.style.color = config.typeStyle.color || '';
badge.style.border = config.typeStyle.border || '';
}
meta.appendChild(badge);
}
var badges = Array.isArray(config.badges)
? config.badges
: config.badges
? [config.badges]
: [];
if (badges.length) {
badges.forEach(function (entry) {
if (!entry) return;
if (typeof entry === 'string') {
meta.appendChild(sf.el('span', { className: 'sf-resource-type-badge' }, entry));
return;
}
var extraBadge = sf.el('span', { className: 'sf-resource-type-badge' }, entry.label || '');
if (entry.style) {
extraBadge.style.background = entry.style.bg || '';
extraBadge.style.color = entry.style.color || '';
extraBadge.style.border = entry.style.border || '';
}
meta.appendChild(extraBadge);
});
}
identity.appendChild(meta);
}
resHeader.appendChild(identity);
if (config.gauges && config.gauges.length > 0) {
var gauges = sf.el('div', { className: 'sf-gauges' });
config.gauges.forEach(function (g) {
var row = sf.el('div', { className: 'sf-gauge-row' });
row.appendChild(sf.el('span', { className: 'sf-gauge-label' }, g.label));
var track = sf.el('div', { className: 'sf-gauge-track' });
var fill = sf.el('div', {
className: 'sf-gauge-fill' + (g.style ? ' sf-gauge-fill--' + g.style : ''),
});
fill.style.width = Math.min(g.pct || 0, 100) + '%';
track.appendChild(fill);
row.appendChild(track);
if (g.text) row.appendChild(sf.el('span', { className: 'sf-gauge-value' }, g.text));
gauges.appendChild(row);
});
resHeader.appendChild(gauges);
}
card.appendChild(resHeader);
var body = sf.el('div', { className: 'sf-resource-body' });
body.style.gridTemplateColumns = labelWidth + 'px 1fr';
var stats = sf.el('div', { className: 'sf-resource-stats' });
if (config.stats) {
config.stats.forEach(function (s) {
var row = sf.el('div', { className: 'sf-stat-row' });
row.appendChild(sf.el('span', { className: 'sf-stat-label' }, s.label));
row.appendChild(sf.el('span', { className: 'sf-stat-value' }, String(s.value)));
stats.appendChild(row);
});
}
body.appendChild(stats);
var railContainer = sf.el('div', { className: 'sf-rail-container' });
var rail = sf.el('div', { className: 'sf-rail' });
if (config.id) rail.id = 'sf-rail-' + config.id;
var numCols = config.columns || 5;
var dayGrid = sf.el('div', { className: 'sf-day-grid' });
dayGrid.style.gridTemplateColumns = 'repeat(' + numCols + ', 1fr)';
for (var i = 0; i < numCols; i++) {
dayGrid.appendChild(sf.el('div', { className: 'sf-day-col' }));
}
rail.appendChild(dayGrid);
railContainer.appendChild(rail);
body.appendChild(railContainer);
card.appendChild(body);
if (config.heatmap) {
var heatmapCfg = {
horizon: config.heatmap.horizon || 1,
label: config.heatmap.label,
segments: config.heatmap.segments,
labelWidth: labelWidth,
};
heatmapCfg.railConfig = config;
var heatmap = sf.rail.createHeatmap(heatmapCfg);
if (heatmap) card.appendChild(heatmap);
}
var unassignedRail = sf.el('div', { className: 'sf-unassigned-rail' });
if (config.unassigned) {
state.unassigned = config.unassigned;
renderUnassigned(unassignedRail, config.unassigned, config.onUnassignedClick);
}
if (unassignedRail.children.length > 0) card.appendChild(unassignedRail);
var cardApi = { el: card, rail: rail };
cardApi.addBlock = function (blockConfig) {
return sf.rail.addBlock(rail, blockConfig);
};
cardApi.setUnassigned = function (items) {
state.unassigned = Array.isArray(items) ? items : [];
if (state.unassigned.length === 0 && unassignedRail.parentNode) {
unassignedRail.innerHTML = '';
unassignedRail.parentNode && unassignedRail.parentNode.removeChild(unassignedRail);
return;
}
if (state.unassigned.length > 0) {
renderUnassigned(unassignedRail, state.unassigned, config.onUnassignedClick);
} else {
unassignedRail.innerHTML = '';
}
if (state.unassigned.length > 0 && !unassignedRail.parentNode) {
card.appendChild(unassignedRail);
}
};
cardApi.clearBlocks = function () {
rail.querySelectorAll('.sf-block, .sf-changeover').forEach(function (el) {
el.remove();
});
};
cardApi.setSolving = function (solving) {
card.classList.toggle('solving', solving);
};
return cardApi;
};
sf.rail.createHeatmap = function (config) {
if (!config || !config.segments || !Array.isArray(config.segments) || config.segments.length === 0) return null;
var heatmap = sf.el('div', { className: 'sf-heatmap' });
heatmap.style.gridTemplateColumns = (config.labelWidth || 200) + 'px 1fr';
var label = sf.el('div', { className: 'sf-heatmap-label' }, config.label || '');
heatmap.appendChild(label);
var track = sf.el('div', { className: 'sf-heatmap-track' });
var columns = config.railConfig && config.railConfig.columns || 1;
track.style.gridTemplateColumns = 'repeat(' + columns + ', 1fr)';
heatmap.appendChild(track);
var horizon = config.horizon || 1;
config.segments.forEach(function (segment) {
if (!segment || segment.end <= segment.start) return;
var band = sf.el('div', { className: 'sf-heatmap-segment' });
var start = Math.max(0, segment.start);
var width = Math.max(0, segment.end - start);
band.style.left = (start / horizon * 100) + '%';
band.style.width = Math.max(width / horizon * 100, 0.25) + '%';
if (segment.color) band.style.background = segment.color;
if (segment.opacity != null) band.style.opacity = segment.opacity;
if (segment.tooltip) band.title = segment.tooltip;
track.appendChild(band);
});
return heatmap;
};
sf.rail.createUnassignedRail = function (tasks, onTaskClick) {
var rail = sf.el('div', { className: 'sf-unassigned-rail' });
renderUnassigned(rail, tasks, onTaskClick);
return rail;
};
sf.rail.addBlock = function (rail, config) {
sf.assert(rail, 'addBlock(rail) requires a rail element');
sf.assert(config && config.horizon != null, 'addBlock(config.horizon) is required');
sf.assert(config.start != null && config.end != null, 'addBlock(config.start/config.end) are required');
var horizon = config.horizon || 1;
var startPct = (config.start / horizon) * 100;
var widthPct = ((config.end - config.start) / horizon) * 100;
var block = sf.el('div', { className: 'sf-block' });
block.style.left = startPct + '%';
block.style.width = Math.max(widthPct, 0.5) + '%';
if (config.color) {
block.style.background = config.color;
block.style.borderLeftColor = config.borderColor || config.color;
}
if (config.className) block.classList.add(config.className);
if (config.late) block.classList.add('late');
if (config.id) block.dataset.blockId = config.id;
if (config.delay) block.style.animationDelay = config.delay;
if (config.label) {
block.appendChild(sf.el('div', { className: 'sf-block-label' }, config.label));
}
if (config.meta) {
block.appendChild(sf.el('div', { className: 'sf-block-meta' }, config.meta));
}
if (config.onHover) {
block.addEventListener('mouseenter', function (e) { config.onHover(e, config); });
}
if (config.onLeave) {
block.addEventListener('mouseleave', function () { config.onLeave(); });
}
if (config.onClick) {
block.setAttribute('role', 'button');
block.tabIndex = 0;
sf.bindActivation(block, function (e) { config.onClick(e, config); });
}
rail.appendChild(block);
return block;
};
sf.rail.addChangeover = function (rail, config) {
sf.assert(rail, 'addChangeover(rail) requires a rail element');
sf.assert(config && config.horizon != null, 'addChangeover(config.horizon) is required');
sf.assert(config.start != null && config.end != null, 'addChangeover(config.start/config.end) are required');
var horizon = config.horizon || 1;
var startPct = (config.start / horizon) * 100;
var widthPct = ((config.end - config.start) / horizon) * 100;
var co = sf.el('div', { className: 'sf-changeover' });
co.style.left = startPct + '%';
co.style.width = widthPct + '%';
rail.appendChild(co);
return co;
};
function renderUnassigned(unassignedRail, items, onTaskClick) {
unassignedRail.innerHTML = '';
(items || []).forEach(function (item) {
var label = typeof item === 'string' ? item : item.label || item.id || '';
if (!label) return;
var pill = sf.el('button', {
className: 'sf-unassigned-pill',
onClick: function () {
if (onTaskClick) onTaskClick(item);
},
}, label);
unassignedRail.appendChild(pill);
});
}
})(SF);
(function (sf) {
'use strict';
sf.gantt = {};
sf.gantt.create = function (config) {
config = config || {};
var instanceId = sf.uid('sf-gantt');
var chartPaneId = config.chartPane || (instanceId + '-chart-pane');
var gridPaneId = config.gridPane || (instanceId + '-grid-pane');
var chartContainerId = config.chartContainer || (instanceId + '-container');
var svgId = config.svgId || (instanceId + '-svg');
var ganttChart = null;
var splitInstance = null;
var mounted = false;
var mountTarget = null;
var resizeObserver = null;
var tasks = [];
var sortState = { key: null, direction: 'asc' };
var wrapper = sf.el('div', { className: 'sf-gantt-split' });
var gridPane = sf.el('div', { className: 'sf-gantt-pane', id: gridPaneId });
var gridHeader = sf.el('div', { className: 'sf-gantt-pane-header' });
gridHeader.appendChild(sf.el('h3', null, config.gridTitle || 'Tasks'));
var gridControls = sf.el('div', { className: 'sf-gantt-pane-controls' });
gridHeader.appendChild(gridControls);
gridPane.appendChild(gridHeader);
var gridContent = sf.el('div', { className: 'sf-gantt-pane-content' });
var grid = sf.el('div', { className: 'sf-gantt-grid' });
gridContent.appendChild(grid);
gridPane.appendChild(gridContent);
var chartPane = sf.el('div', { className: 'sf-gantt-pane', id: chartPaneId });
var chartHeader = sf.el('div', { className: 'sf-gantt-pane-header' });
chartHeader.appendChild(sf.el('h3', null, config.chartTitle || 'Timeline'));
var viewControls = sf.el('div', { className: 'sf-gantt-view-controls' });
var viewSelect = sf.el('select', { className: 'sf-gantt-view-select' });
var modes = [
{ value: 'Quarter Day', label: 'Quarter Day' },
{ value: 'Half Day', label: 'Half Day' },
{ value: 'Day', label: 'Day' },
{ value: 'Week', label: 'Week' },
{ value: 'Month', label: 'Month' },
];
modes.forEach(function (m) {
var opt = sf.el('option', { value: m.value }, m.label);
if (m.value === (config.viewMode || 'Quarter Day')) opt.selected = true;
viewSelect.appendChild(opt);
});
viewSelect.addEventListener('change', function () {
if (ganttChart) ganttChart.change_view_mode(viewSelect.value);
});
viewControls.appendChild(viewSelect);
var chartControls = sf.el('div', { className: 'sf-gantt-pane-controls' });
chartHeader.appendChild(viewControls);
chartHeader.appendChild(chartControls);
chartPane.appendChild(chartHeader);
var chartContent = sf.el('div', { className: 'sf-gantt-pane-content' });
var chartContainer = sf.el('div', { className: 'sf-gantt-container', id: chartContainerId });
chartContent.appendChild(chartContainer);
chartPane.appendChild(chartContent);
wrapper.appendChild(gridPane);
wrapper.appendChild(chartPane);
var ctrl = { el: wrapper };
ctrl.mount = function (parent) {
sf.assert(parent, 'gantt.mount(parent) requires a mount target');
var target = typeof parent === 'string' ? document.getElementById(parent) : parent;
sf.assert(target, 'gantt.mount(parent) target not found: ' + parent);
validateMountTarget(target);
if (mounted && mountTarget === target && wrapper.parentNode === target) {
return;
}
if (mounted) ctrl.destroy();
target.appendChild(wrapper);
mounted = true;
mountTarget = target;
if (tasks.length > 0 || grid.firstChild || chartContainer.firstChild) {
renderGrid(tasks);
renderChart(tasks);
}
initSplit();
bindResizeObserver();
};
ctrl.setTasks = function (newTasks) {
sf.assert(Array.isArray(newTasks), 'gantt.setTasks(tasks) expects an array');
tasks = newTasks;
renderGrid(newTasks);
renderChart(newTasks);
};
ctrl.refresh = function () {
if (ganttChart && tasks.length > 0) {
ganttChart.refresh(tasksToFrappe(tasks));
}
};
ctrl.getChart = function () { return ganttChart; };
ctrl.changeViewMode = function (mode) {
viewSelect.value = mode;
if (ganttChart) ganttChart.change_view_mode(mode);
};
ctrl.highlightTask = function (taskId) {
grid.querySelectorAll('.sf-gantt-row').forEach(function (row) {
row.classList.toggle('selected', row.dataset.taskId === taskId);
});
var svg = chartContainer.querySelector('svg');
if (svg) {
svg.querySelectorAll('.bar-wrapper').forEach(function (bw) {
bw.classList.remove('highlighted');
});
var bar = svg.querySelector('.bar-wrapper[data-id="' + taskId + '"]');
if (bar) bar.classList.add('highlighted');
}
};
ctrl.destroy = function () {
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
if (splitInstance) { splitInstance.destroy(); splitInstance = null; }
ganttChart = null;
mounted = false;
mountTarget = null;
if (wrapper.parentNode) wrapper.parentNode.removeChild(wrapper);
};
return ctrl;
function initSplit() {
if (typeof Split !== 'function') return;
if (splitInstance) {
splitInstance.destroy();
splitInstance = null;
}
var splitSizes = normalizePair(config.splitSizes, [40, 60]);
var splitMinSize = normalizePair(config.splitMinSize, [200, 300]);
splitInstance = Split(['#' + gridPaneId, '#' + chartPaneId], {
direction: 'vertical',
sizes: splitSizes,
minSize: splitMinSize,
snapOffset: 30,
gutterSize: 4,
cursor: 'col-resize',
onDragEnd: function () {
if (ganttChart) {
setTimeout(function () { ganttChart.refresh(tasksToFrappe(tasks)); }, 100);
}
},
});
}
function bindResizeObserver() {
if (typeof ResizeObserver !== 'function') return;
if (resizeObserver) {
resizeObserver.disconnect();
}
resizeObserver = new ResizeObserver(function () {
if (!ganttChart) return;
setTimeout(function () { ganttChart.refresh(tasksToFrappe(tasks)); }, 0);
});
if (wrapper.parentNode) resizeObserver.observe(wrapper.parentNode);
}
function normalizePair(value, fallback) {
if (typeof value === 'number' && isFinite(value)) return [value, value];
if (!Array.isArray(value) || value.length !== 2) return fallback.slice();
var n0 = Number(value[0]);
var n1 = Number(value[1]);
if (!isFinite(n0) || !isFinite(n1)) return fallback.slice();
return [n0, n1];
}
function validateMountTarget(target) {
sf.assert(target && typeof target.appendChild === 'function', 'gantt.mount(parent) requires a valid DOM container');
sf.assert(getElementSize(target, 'Width') > 0 && getElementSize(target, 'Height') > 0, 'gantt.mount(parent) target is not laid out yet');
}
function getElementSize(target, axis) {
var clientKey = 'client' + axis;
var offsetKey = 'offset' + axis;
var rectKey = axis === 'Width' ? 'width' : 'height';
if (typeof target[clientKey] === 'number') return target[clientKey];
if (typeof target[offsetKey] === 'number') return target[offsetKey];
if (typeof target.getBoundingClientRect === 'function') {
var rect = target.getBoundingClientRect();
if (rect && typeof rect[rectKey] === 'number') return rect[rectKey];
}
return 0;
}
function tasksToFrappe(taskList) {
return taskList
.filter(function (t) { return t.start && t.end; })
.map(function (t) {
var customClass = t.custom_class || '';
if (t.pinned) {
customClass = customClass ? customClass + ' pinned' : 'pinned';
}
return {
id: t.id,
name: t.name || t.label || t.id,
start: t.start,
end: t.end,
custom_class: customClass,
dependencies: t.dependencies || '',
};
});
}
function renderChart(taskList) {
var frappeTasks = tasksToFrappe(taskList);
if (frappeTasks.length === 0) {
chartContainer.textContent = '';
chartContainer.appendChild(sf.el('div', {
className: 'sf-gantt-empty-state',
style: {
padding: '24px',
color: 'var(--sf-gray-400)',
fontFamily: 'var(--sf-font-mono)',
fontSize: '13px',
},
}, 'No scheduled tasks to display.'));
ganttChart = null;
return;
}
chartContainer.textContent = '';
chartContainer.appendChild(createSvgRoot(svgId));
ganttChart = new Gantt('#' + svgId, frappeTasks, {
view_mode: viewSelect.value || 'Quarter Day',
date_format: 'YYYY-MM-DD HH:mm',
custom_popup_html: config.unsafePopupHtml || config.popupHtml || defaultPopup,
on_click: function (task) {
ctrl.highlightTask(task.id);
if (config.onTaskClick) config.onTaskClick(task);
},
on_date_change: function (task, start, end) {
if (config.onDateChange) config.onDateChange(task, start, end);
},
});
}
function renderGrid(taskList) {
while (grid.firstChild) grid.removeChild(grid.firstChild);
var table = sf.el('table', { className: 'sf-gantt-table' });
var columns = config.columns || [
{ key: 'name', label: 'Task' },
{ key: 'start', label: 'Start' },
{ key: 'end', label: 'End' },
];
var sortedTasks = sortTasks(taskList);
var thead = sf.el('thead');
var headerRow = sf.el('tr');
columns.forEach(function (col) {
headerRow.appendChild(buildHeaderCell(col));
});
thead.appendChild(headerRow);
table.appendChild(thead);
var tbody = sf.el('tbody');
sortedTasks.forEach(function (task) {
var rowClasses = ['sf-gantt-row'];
if (task.custom_class) rowClasses.push(task.custom_class);
if (task.projectIndex != null) rowClasses.push('sf-project-' + task.projectIndex);
var tr = sf.el('tr', {
className: rowClasses.join(' '),
dataset: { taskId: task.id },
onClick: function () {
ctrl.highlightTask(task.id);
if (config.onTaskClick) config.onTaskClick(task);
},
});
columns.forEach(function (col) {
var td = sf.el('td');
if (col.key === 'name') {
td.className = 'sf-task-name';
td.textContent = task.name || task.label || task.id;
} else if (col.render) {
var content = col.render(task);
if (typeof content === 'string') td.textContent = content;
else if (content && content.unsafeHtml) td.innerHTML = content.unsafeHtml;
else if (content instanceof Node) td.appendChild(content);
} else {
td.textContent = task[col.key] || '';
td.style.fontFamily = 'var(--sf-font-mono)';
td.style.fontSize = '12px';
}
tr.appendChild(td);
});
tbody.appendChild(tr);
});
table.appendChild(tbody);
grid.appendChild(table);
}
function buildHeaderCell(col) {
if (!col.sortable) {
return sf.el('th', null, col.label);
}
var isCurrent = sortState.key === col.key;
var th = sf.el('th', {
className: 'sortable' + (isCurrent ? ' active' : ''),
role: 'button',
tabIndex: 0,
'aria-sort': isCurrent ? (sortState.direction === 'asc' ? 'ascending' : 'descending') : 'none',
});
th.appendChild(document.createTextNode(col.label));
th.appendChild(sf.el('span', { className: 'sort-icon' }, isCurrent ? (sortState.direction === 'asc' ? '▲' : '▼') : ''));
sf.bindActivation(th, function () {
if (sortState.key === col.key) {
sortState.direction = sortState.direction === 'asc' ? 'desc' : 'asc';
} else {
sortState.key = col.key;
sortState.direction = 'asc';
}
renderGrid(tasks);
});
return th;
}
function sortTasks(taskList) {
if (!sortState.key) return taskList.slice();
var sorted = taskList.slice();
sorted.sort(function (a, b) {
var aVal = sortValue(a[sortState.key], sortState.key);
var bVal = sortValue(b[sortState.key], sortState.key);
if (aVal === bVal) return 0;
if (sortState.direction === 'asc') return aVal < bVal ? -1 : 1;
return aVal > bVal ? -1 : 1;
});
return sorted;
}
function sortValue(value, key) {
if (value == null) return '';
if (key === 'start' || key === 'end') {
var parsed = Date.parse(value);
return isNaN(parsed) ? String(value).toLowerCase() : parsed;
}
if (typeof value === 'number') return value;
return String(value).toLowerCase();
}
function defaultPopup(task) {
var t = tasks.find(function (x) { return x.id === task.id; });
if (!t) return '';
return '<div class="sf-gantt-popup">' +
'<h4>' + sf.escHtml(t.name || t.id) + '</h4>' +
'<p><strong>Start:</strong> ' + sf.escHtml(t.start) + '</p>' +
'<p><strong>End:</strong> ' + sf.escHtml(t.end) + '</p>' +
(t.duration_minutes ? '<p><strong>Duration:</strong> ' + t.duration_minutes + ' min</p>' : '') +
(t.pinned ? '<p class="sf-gantt-popup-pinned"><i class="fa-solid fa-thumbtack"></i> Pinned</p>' : '') +
'</div>';
}
function createSvgRoot(id) {
if (document.createElementNS) {
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.id = id;
return svg;
}
return sf.el('svg', { id: id });
}
};
})(SF);
(function (sf) {
'use strict';
sf.createFooter = function (config) {
sf.assert(config, 'createFooter(config) requires a configuration object');
var footer = sf.el('footer', { className: 'sf-footer' });
if (config.links) {
config.links.forEach(function (link, i) {
if (i > 0) footer.appendChild(sf.el('span', { className: 'sf-vr' }));
footer.appendChild(sf.el('a', { href: link.url, target: '_blank' }, link.label));
});
}
if (config.version) {
footer.appendChild(sf.el('span', { style: { marginLeft: 'auto' } }, config.version));
}
return footer;
};
})(SF);