Files
willtools-demo/tools/pa_web/static/app.js
T
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at 77a82eb.
Code, docs and the public catalogue only: no plant or job knowledge
layers, no client, job or producer names (tools/scrub.py clean against
the widened term list; a second word-level grep clean). The intro deck is
left out because its slide images cannot be checked for job drawings.
2026-09-09 08:35:15 -05:00

1309 lines
50 KiB
JavaScript

// Panel Analysis's 前端 -- renders the pagespec JSON (docs/pa-web-spec.md)
// as tabbed pages and talks to the local server over the fixed HTTP
// contract (GET /api/spec, POST /api/save, POST /api/run, POST /api/close).
// Plain ES2017, no modules, no build step, no framework -- this renderer is
// meant to be generic enough that a future tool's own pagespec (Beam
// Analysis, Connection Analysis) renders through the same code.
(function () {
'use strict';
var U = window.Units;
var D = window.Draw;
var errBox = document.getElementById('errors');
function logError(msg) {
errBox.hidden = false;
errBox.textContent += String(msg) + '\n';
try { fetch('/api/log', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: String(msg) }) }); } catch (e) { /* best effort */ }
}
window.onerror = function (message, source, lineno, colno, error) {
logError((message || 'error') + ' @ ' + source + ':' + lineno + ':' + colno +
(error && error.stack ? '\n' + error.stack : ''));
};
window.addEventListener('unhandledrejection', function (ev) {
logError('unhandled rejection: ' + (ev.reason && ev.reason.stack || ev.reason));
});
// ------------------------------------------------------------- state
var state = {
spec: null,
values: {},
cutline: null, // {key, default}
drawings: [], // [{block, host, probEl, page}]
saveTimer: null,
qs: parseQuery(),
pageContainers: [], // DOM node per page index
tabButtons: [],
activeIndex: 0,
fieldEls: {}, // key -> [input elements] for error targeting
tableEls: {}, // table name -> {wrap, errEl, emptyEl, refresh}
visibility: [], // [{el, when}] blocks with a `visible_when`
overridden: {}, // derived key -> true once the user typed into it
liveKv: {} // table name -> [{td, spec}] for every kv row
// carrying a `live` spec (docs/pa-web-spec.md §2)
};
function parseQuery() {
var out = {};
var s = window.location.search.replace(/^\?/, '');
s.split('&').forEach(function (pair) {
if (!pair) return;
var kv = pair.split('=');
out[decodeURIComponent(kv[0])] = decodeURIComponent(kv[1] || '');
});
return out;
}
function truthy(v) {
if (v === null || v === undefined) return false;
return /^(yes|y|true|1)$/i.test(String(v).trim());
}
function escapeReg(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
// --------------------------------------------------------- unit rules
// Owner's ruling (2026-09-05): every length whose unit is feet displays
// as feet-inches, typed input parses through Units.parseLength, and the
// value stored in `values` stays decimal feet as a string. Inch fields
// use Units.fmtIn / parseLengthIn and store decimal inches.
function detectUnit(key, symbol, colUnit) {
if (colUnit === 'ft') return 'ft';
if (colUnit === 'in') return 'in';
if (symbol === 'ft') return 'ft';
if (symbol === 'in') return 'in';
if (/_ft$/.test(key)) return 'ft';
if (/_in$/.test(key)) return 'in';
return null;
}
function numToStr(n) {
if (n === null || n === undefined || isNaN(n)) return '';
var s = n.toFixed(6);
s = s.replace(/0+$/, '').replace(/\.$/, '');
return s;
}
function displayForUnit(rawValue, unitKind) {
if (rawValue === undefined || rawValue === null || String(rawValue).trim() === '') return '';
var n = parseFloat(rawValue);
if (isNaN(n)) return String(rawValue);
return unitKind === 'ft' ? U.fmtFtIn(n) : U.fmtIn(n);
}
// -------------------------------------------------------- values I/O
function getValue(key) {
var v = state.values[key];
return v === undefined || v === null ? '' : v;
}
function setValue(key, v) {
state.values[key] = v;
}
function updateValue(key, value, opts) {
// A block/cell with no `key` at all is in-spec (docs/pa-web-spec.md
// section 2: "some resistance-factor table cells") -- it carries no
// value of its own, so there is nothing to store or react to.
if (!key) return;
setValue(key, value);
scheduleSave();
redrawDependents(key);
if (!(opts && opts.silent)) clearFieldError(key);
if (!(opts && opts.derived)) noteUserEdit(key);
recomputeDerived(key);
recomputeLiveKv(key);
applyVisibility(key);
}
// ------------------------------------------------------ derived fields
// `spec.derived` (docs/pa-web-spec.md, "derived"): key -> {sources,
// formula}. Editing a source recomputes the derived field here, live,
// mirroring panelanalysisform's own formula -- until the user types
// into the derived field itself, after which their value stands and
// the auto-update stops for that field (the owner's rule: editing f'c
// updates Ec; editing Ec never rewrites f'c and freezes Ec). Issue #22,
// item 1. The freeze is page state (`state.overridden`) AND persisted as
// `values['_override.<key>'] = 'yes'` so it survives a reload.
var FORMULAS = {
// ACI 318 19.2.2: Ec = 33 * wc^1.5 * sqrt(f'c), whole psi -- the
// twin of panelanalysisform.concrete_modulus_psi + prefill's round().
concrete_modulus_psi: function (args) {
var fc = parseFloat(args[0]), wc = parseFloat(args[1]);
if (isNaN(fc) || isNaN(wc) || fc <= 0 || wc <= 0) return null;
return String(Math.round(33 * Math.pow(wc, 1.5) * Math.sqrt(fc)));
}
};
function overrideKey(key) { return '_override.' + key; }
function noteUserEdit(key) {
var derived = state.spec && state.spec.derived;
if (!derived || !derived[key]) return;
state.overridden[key] = true;
setValue(overrideKey(key), 'yes');
}
function loadOverrides() {
var derived = state.spec && state.spec.derived;
if (!derived) return;
Object.keys(derived).forEach(function (key) {
if (truthy(state.values[overrideKey(key)])) state.overridden[key] = true;
});
}
function recomputeDerived(changedKey) {
var derived = state.spec && state.spec.derived;
if (!derived) return;
Object.keys(derived).forEach(function (target) {
var d = derived[target];
if (!d || !d.sources || d.sources.indexOf(changedKey) === -1) return;
if (state.overridden[target]) return;
var fn = FORMULAS[d.formula];
if (!fn) return;
var out = fn(d.sources.map(getValue));
if (out === null || out === undefined) return;
if (String(getValue(target)) === out) return;
setValue(target, out);
(state.fieldEls[target] || []).forEach(function (el) {
if ('value' in el && el.type !== 'checkbox') el.value = out;
});
scheduleSave();
clearFieldError(target);
redrawDependents(target);
});
}
// -------------------------------------------------------- live kv rows
//
// A kv row may carry a `live` spec (docs/pa-web-spec.md §2, "kv"; issue
// #23): value = the sum, over the table's PRESENT rows, of the product
// of the row's terms -- so the strand count, Total IP and Aps keep in
// step with the strand_row table as the user edits rows, instead of only
// updating on the next page build / Run. The server-rendered value is
// the initial display (a page with JS off reads the same numbers); the
// spec only takes over once a key of that table changes.
function fmtLive(sum, format) {
// The Python side prints `('%.4f' % v).rstrip('0').rstrip('.')` -- the
// same trimmed look here. `%d` (an integer-only term list -- the strand
// count) prints the integer.
var s;
if (format === '%d') {
s = String(Math.trunc(sum));
} else {
var m = /^%(\d+)?\.(\d+)f$/.exec(format || '');
s = m ? sum.toFixed(parseInt(m[2], 10)) : String(sum);
}
return s.replace(/0+$/, '').replace(/\.$/, '');
}
function termProduct(tableName, n, term) {
// One term of one present row: a column key whose cell parses as a
// number, or {col, map} with the cell text looked up in the
// page-shipped map. Returns null for "does not resolve" -- the whole
// row is then `unknown`, never a guessed zero.
var key = typeof term === 'string' ? term : term.col;
var text = String(getValue(tableName + '.' + n + '.' + key));
if (term && term.map) {
return Object.prototype.hasOwnProperty.call(term.map, text) ?
term.map[text] : null;
}
if (text.trim() === '') return null;
var v = parseFloat(text);
return isNaN(v) ? null : v;
}
function recomputeLiveKv(changedKey) {
// `state.liveKv[table]` holds every kv row that sums THAT table, so an
// edit to `<table>.<n>.<col>` or `<table>.<n>.present` (a row edit, or
// an add/remove) only rewrites the rows that could have changed.
Object.keys(state.liveKv).forEach(function (tableName) {
if (changedKey.indexOf(tableName + '.') !== 0) return;
(state.liveKv[tableName] || []).forEach(function (item) {
var live = item.spec;
var sum = 0;
var unknown = false;
rowIds(live.table).forEach(function (n) {
if (unknown) return;
var prod = 1;
for (var i = 0; i < live.terms.length; i++) {
var v = termProduct(live.table, n, live.terms[i]);
if (v === null) { unknown = true; return; }
prod *= v;
}
sum += prod;
});
item.td.textContent = unknown
? (live.unknown === undefined ? '' : live.unknown)
: fmtLive(sum, live.format);
});
});
}
// ------------------------------------------------ dependent visibility
// A block carrying `visible_when: {key, in: [...]}` (docs/pa-web-spec.md)
// is shown only while `values[key]` is one of those strings (compared
// trimmed, case-insensitively). Hidden means `hidden` on the rendered
// node -- the value is kept, nothing is unbound. Issue #22, item 3.
function visibleNow(when) {
if (!when || !when.key) return true;
var cur = String(getValue(when.key) || '').trim().toLowerCase();
var wanted = (when['in'] || []).map(function (v) { return String(v).trim().toLowerCase(); });
return wanted.indexOf(cur) !== -1;
}
function registerVisibility(el, when) {
if (!el || !when || !when.key) return;
state.visibility.push({ el: el, when: when });
el.hidden = !visibleNow(when);
}
function applyVisibility(changedKey) {
state.visibility.forEach(function (v) {
if (v.when.key !== changedKey) return;
v.el.hidden = !visibleNow(v.when);
});
}
// -------------------------------------------------------- autosave
function scheduleSave() {
if (state.saveTimer) clearTimeout(state.saveTimer);
state.saveTimer = setTimeout(doSave, 1000);
}
function doSave() {
state.saveTimer = null;
return fetch('/api/save', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ values: state.values })
}).catch(function (e) { logError('autosave failed: ' + e); });
}
function flushSave() {
if (state.saveTimer) { clearTimeout(state.saveTimer); state.saveTimer = null; }
return doSave();
}
// ----------------------------------------------------- provenance
function provenanceTitle(key) {
var parts = [];
var spec = state.spec;
if (spec.basis && spec.basis[key]) parts.push('Basis: ' + spec.basis[key]);
if (spec.defaults_why && spec.defaults_why[key]) parts.push('Default rule: ' + spec.defaults_why[key]);
return parts.join('\n');
}
function provIcon(key) {
if (!key) return null;
var t = provenanceTitle(key);
if (!t) return null;
var span = document.createElement('span');
span.className = 'prov-icon';
span.textContent = 'i';
span.title = t;
return span;
}
// ------------------------------------------------------- field errors
function registerFieldEl(key, el) {
if (!key) return;
if (!state.fieldEls[key]) state.fieldEls[key] = [];
state.fieldEls[key].push(el);
}
function clearFieldError(key) {
(state.fieldEls[key] || []).forEach(function (el) {
el.classList.remove('has-error');
var msg = el.parentNode && el.parentNode.querySelector('.field-error-msg');
if (msg) msg.remove();
});
}
function clearAllFieldErrors() {
Object.keys(state.fieldEls).forEach(clearFieldError);
Object.keys(state.tableEls).forEach(clearTableError);
}
function clearTableError(name) {
var t = state.tableEls[name];
if (!t) return;
t.wrap.classList.remove('has-error');
t.errEl.textContent = '';
t.errEl.hidden = true;
}
function setTableError(name, text) {
var t = state.tableEls[name];
if (!t) return null;
t.wrap.classList.add('has-error');
t.errEl.textContent = text;
t.errEl.hidden = false;
return t.wrap;
}
function setFieldError(el, text) {
el.classList.add('has-error');
var wrap = el.parentNode;
var msg = wrap.querySelector('.field-error-msg');
if (!msg) {
msg = document.createElement('div');
msg.className = 'field-error-msg';
wrap.appendChild(msg);
}
msg.textContent = text;
}
// ---------------------------------------------------------- rendering
function h(tag, cls, text) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (text !== undefined && text !== null) e.textContent = text;
return e;
}
function renderBlockList(container, blocks, ctx) {
(blocks || []).forEach(function (b) {
var node = renderBlock(b, ctx);
if (node) container.appendChild(node);
});
}
function renderBlock(block, ctx) {
var node = renderBlockInner(block, ctx);
if (node && block.visible_when) registerVisibility(node, block.visible_when);
return node;
}
function renderBlockInner(block, ctx) {
switch (block.kind) {
case 'heading': return renderHeading(block);
case 'columns': return renderColumns(block, ctx);
case 'field': return renderField(block, ctx);
case 'kv': return renderKv(block);
case 'sentence': return renderSentence(block, ctx);
case 'table': return renderTable(block, ctx);
case 'lc_table': return renderLcTable(block, ctx);
case 'editable_table': return renderEditableTable(block, ctx);
case 'matrix': return renderMatrix(block, ctx);
case 'legend': return renderLegend(block);
case 'drawing': return renderDrawingBlock(block, ctx);
case 'section': return renderSection(block, ctx);
case 'rule': return h('hr', 'pa-rule');
case 'space': {
var sp = h('div', 'pa-space');
sp.style.height = (block.h || 8) + 'px';
return sp;
}
default: {
var u = h('div', 'unknown-block');
u.textContent = 'Unrecognized block kind: ' + block.kind;
return u;
}
}
}
function renderHeading(block) {
var level = block.level === 1 ? 'h1' : (block.level === 3 ? 'h3' : 'h2');
return h(level, 'pa-h', block.text || '');
}
function renderColumns(block, ctx) {
var row = h('div', 'pa-columns');
(block.columns || []).forEach(function (sub) {
var col = h('div', 'pa-col');
renderBlockList(col, sub, ctx);
row.appendChild(col);
});
return row;
}
function renderSentence(block, ctx) {
if (block.quiet) {
ctx.notes.hints.push({ label: null, text: block.text });
return null;
}
return h('p', 'sentence', block.text || '');
}
function renderKv(block) {
var table = h('table', 'kv-table');
var tbody = h('tbody');
(block.rows || []).forEach(function (row) {
var tr = h('tr');
tr.appendChild(h('td', null, row[0]));
var valueTd = h('td', null, row[1]);
tr.appendChild(valueTd);
tbody.appendChild(tr);
// A row may carry a third element -- a `live` spec (docs/pa-web-spec
// .md §2, "kv", issue #23): its value cell is a sum over a row table
// that the browser must keep in step with the rows as they are
// edited. Remember the <td> + spec per table, so `recomputeLiveKv`
// rewrites exactly these cells. Rows without the element are
// unchanged two-column readouts.
var live = row[2];
if (live && live.table && live.terms) {
if (!state.liveKv[live.table]) state.liveKv[live.table] = [];
state.liveKv[live.table].push({ td: valueTd, spec: live });
}
});
table.appendChild(tbody);
return table;
}
function renderLegend(block) {
var wrap = h('div', 'legend');
(block.groups || []).forEach(function (grp) {
var name = grp[0], items = grp[1];
var gwrap = h('span');
gwrap.appendChild(h('span', 'legend-group-title', name + ':'));
items.forEach(function (it) {
var span = h('span', 'legend-item');
span.appendChild(h('span', 'legend-glyph', it[0] + ' '));
span.appendChild(document.createTextNode(it[1]));
gwrap.appendChild(span);
gwrap.appendChild(document.createTextNode(' '));
});
wrap.appendChild(gwrap);
});
return wrap;
}
function renderSection(block, ctx) {
var det = document.createElement('details');
det.className = 'pa-section';
det.open = !block.collapsed;
var summary = h('summary', null, block.title || '');
det.appendChild(summary);
var body = h('div', 'pa-section-body');
renderBlockList(body, block.blocks, ctx);
det.appendChild(body);
return det;
}
// ------------------------------------------------------------- field
function fieldChoices(block) {
if (block.choices) return block.choices;
if (block.key && state.spec.choices && state.spec.choices[block.key]) return state.spec.choices[block.key];
return null;
}
function renderField(block, ctx) {
var key = block.key;
var wrap = h('div', 'field' + (block.required ? ' required' : ''));
if (block.multi_value || (block.editor === 'text' && /\n/.test(String(getValue(key) || '')))) {
wrap.classList.add('full-width');
}
var labelRow = h('div', 'field-label-row');
labelRow.appendChild(h('span', 'field-label', block.label || key || ''));
if (block.symbol) labelRow.appendChild(h('span', 'field-symbol', block.symbol));
var icon = provIcon(key);
if (icon) labelRow.appendChild(icon);
wrap.appendChild(labelRow);
if (block.hint) ctx.notes.hints.push({ label: block.label, text: block.hint });
var input = buildEditor(block.editor, key, {
choices: fieldChoices(block),
multiValue: block.multi_value,
symbol: block.symbol
});
wrap.appendChild(input.el);
registerFieldEl(key, input.input);
if (block.unused_by_solve) markUnused(wrap, input.input, labelRow);
return wrap;
}
// `unused_by_solve: true` (docs/pa-web-spec.md): the control is
// collected only -- nothing in the solve reads it yet. It stays
// editable (a saved value survives) but is muted and says so, so it can
// never pass for a live input (issue #22, item 2).
var UNUSED_TEXT = 'Not used by the solve yet \u2014 collected only.';
function markUnused(wrap, input, labelRow) {
wrap.classList.add('unused-by-solve');
if (input) input.title = UNUSED_TEXT;
var tag = h('span', 'unused-tag', 'not used by the solve');
tag.title = UNUSED_TEXT;
(labelRow || wrap).appendChild(tag);
}
// Builds one editor (input/select/textarea/checkbox) bound to
// state.values[key]. Returns {el, input} where `el` is what to insert
// (may wrap a checkbox in a row) and `input` is the focusable control
// itself (what error state / provenance attach to).
function buildEditor(editor, key, opts) {
opts = opts || {};
var unitKind = detectUnit(key || '', opts.symbol || null, opts.colUnit || null);
if (editor === 'choice') {
var sel = document.createElement('select');
sel.className = 'field-input';
var blank = document.createElement('option');
blank.value = ''; blank.textContent = '—';
sel.appendChild(blank);
(opts.choices || []).forEach(function (c) {
var o = document.createElement('option');
o.value = c; o.textContent = c;
sel.appendChild(o);
});
sel.value = getValue(key);
sel.addEventListener('change', function () { updateValue(key, sel.value); });
return { el: sel, input: sel };
}
if (editor === 'check') {
var row = h('div', 'field-check-row');
var cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'field-input';
cb.style.width = 'auto';
cb.checked = truthy(getValue(key));
cb.addEventListener('change', function () { updateValue(key, cb.checked ? 'yes' : ''); });
row.appendChild(cb);
return { el: row, input: cb };
}
// A plain 'text' editor whose CURRENT value already carries a newline
// (face_lifters / edge_lifters: "one lifter per line" per their own
// hint, even though the spec marks them multi_value:false) is rendered
// as a textarea regardless of that flag -- a single-line <input> mashes
// every line into one unreadable run, which is worse than a generic
// renderer bending its own rule for a value shape it can plainly see.
var forceMulti = opts.multiValue || (editor === 'text' && /\n/.test(String(getValue(key) || '')));
if (forceMulti) {
var ta = document.createElement('textarea');
ta.className = 'field-input';
if (unitKind) {
bindMultiLength(ta, unitKind, key);
} else {
ta.value = getValue(key);
ta.addEventListener('input', function () { updateValue(key, ta.value); });
}
return { el: ta, input: ta };
}
var inp = document.createElement('input');
inp.type = 'text';
inp.className = 'field-input';
if (unitKind) {
bindLength(inp, unitKind, key);
} else {
inp.value = getValue(key);
inp.addEventListener('input', function () { updateValue(key, inp.value); });
}
return { el: inp, input: inp };
}
function bindLength(input, unitKind, key) {
input.value = displayForUnit(getValue(key), unitKind);
input.addEventListener('input', function () {
var raw = input.value.trim();
if (raw === '') { updateValue(key, ''); return; }
try {
var n = unitKind === 'ft' ? U.parseLength(raw) : U.parseLengthIn(raw);
updateValue(key, numToStr(n));
} catch (e) { /* keep typing -- don't clobber the last valid value */ }
});
input.addEventListener('blur', function () {
var raw = input.value.trim();
if (raw === '') { input.value = ''; return; }
try {
var n = unitKind === 'ft' ? U.parseLength(raw) : U.parseLengthIn(raw);
updateValue(key, numToStr(n));
input.value = unitKind === 'ft' ? U.fmtFtIn(n) : U.fmtIn(n);
} catch (e) {
setFieldError(input, 'Not a valid length -- try 8\'-4 1/2", 100", or a plain number.');
}
});
}
function bindMultiLength(textarea, unitKind, key) {
function fmtStored(raw) {
var parts = String(raw || '').split(/[,\n]/).map(function (s) { return s.trim(); }).filter(Boolean);
return parts.map(function (p) {
var n = parseFloat(p);
return isNaN(n) ? p : (unitKind === 'ft' ? U.fmtFtIn(n) : U.fmtIn(n));
}).join(', ');
}
textarea.value = fmtStored(getValue(key));
textarea.addEventListener('blur', function () {
var raw = textarea.value.trim();
if (raw === '') { updateValue(key, ''); return; }
var parts = raw.split(/[,\n]/).map(function (s) { return s.trim(); }).filter(Boolean);
try {
var nums = parts.map(function (p) {
return unitKind === 'ft' ? U.parseLength(p) : U.parseLengthIn(p);
});
updateValue(key, nums.map(numToStr).join(','));
textarea.value = nums.map(function (n) { return unitKind === 'ft' ? U.fmtFtIn(n) : U.fmtIn(n); }).join(', ');
} catch (e) {
setFieldError(textarea, 'One or more entries are not valid lengths.');
}
});
}
// ------------------------------------------------------------- table
function tableCellNode(cell, ctx) {
if (cell === null || cell === undefined) return h('td', null, '');
if (typeof cell === 'string') return h('td', null, cell);
if (typeof cell === 'object' && cell.na) {
return h('td', 'na-cell', '—');
}
// A plain read-only cell with a hover title (a rating's basis sentence
// -- e.g. the Lifting Hardware table's Tension/Shear columns) -- named
// by having `text` but no `editor`, so it never collides with an
// editable cell's own shape (which always carries `editor`).
if (typeof cell === 'object' && cell.editor === undefined &&
cell.text !== undefined) {
var textTd = h('td', null, cell.text);
if (cell.title) textTd.title = cell.title;
return textTd;
}
var td = document.createElement('td');
var editor = buildEditor(cell.editor, cell.key, { choices: cell.choices, colUnit: cell.unit });
if (cell.width) editor.input.style.width = cell.width + 'px';
// `default` fills the cell when values[key] is blank (lc_table code rows).
if (cell.default !== undefined && cell.default !== null && cell.key &&
(getValue(cell.key) === '' || getValue(cell.key) === undefined)) {
if (editor.input.tagName === 'SELECT') editor.input.value = cell.default;
else if (editor.input.type === 'checkbox') editor.input.checked = truthy(cell.default);
else editor.input.value = cell.default;
}
td.appendChild(editor.el);
var icon = provIcon(cell.key);
if (icon) td.appendChild(icon);
registerFieldEl(cell.key, editor.input);
return td;
}
function renderTable(block, ctx) {
var table = h('table', 'pa-table');
if (block.header_groups) {
var groupRow = h('tr');
block.header_groups.forEach(function (g) {
var th = document.createElement('th');
th.textContent = g[0];
th.colSpan = g[1];
groupRow.appendChild(th);
});
var thead0 = h('thead');
thead0.appendChild(groupRow);
table.appendChild(thead0);
}
var thead = table.querySelector('thead') || h('thead');
var headRow = h('tr');
(block.headers || []).forEach(function (hd) { headRow.appendChild(h('th', null, hd)); });
thead.appendChild(headRow);
if (!table.contains(thead)) table.appendChild(thead);
var tbody = h('tbody');
(block.rows || []).forEach(function (row) {
var tr = h('tr');
row.forEach(function (cell) { tr.appendChild(tableCellNode(cell, ctx)); });
tbody.appendChild(tr);
});
table.appendChild(tbody);
return table;
}
function renderMatrix(block, ctx) {
var table = h('table', 'pa-matrix');
var thead = h('thead');
var headRow = h('tr');
headRow.appendChild(h('th', null, ''));
(block.columns || []).forEach(function (c) { headRow.appendChild(h('th', null, c)); });
thead.appendChild(headRow);
table.appendChild(thead);
var tbody = h('tbody');
(block.rows || []).forEach(function (row) {
var tr = h('tr');
var labelTd = h('td', null, row.label || '');
tr.appendChild(labelTd);
(row.cells || []).forEach(function (cell) { tr.appendChild(tableCellNode(cell, ctx)); });
if (row.unused_by_solve) {
tr.classList.add('unused-by-solve');
tr.title = UNUSED_TEXT;
labelTd.appendChild(h('span', 'unused-tag', 'not used'));
}
tbody.appendChild(tr);
if (row.hint) ctx.notes.hints.push({ label: row.label, text: row.hint });
});
table.appendChild(tbody);
return table;
}
// ------------------------------------------------------------ lc_table
function lcExistingUserIds(name) {
var re = new RegExp('^load_combo\\.' + escapeReg(name) + '\\.u(\\d+)\\.');
var ids = {};
Object.keys(state.values).forEach(function (k) {
var m = re.exec(k);
if (m) ids[parseInt(m[1], 10)] = true;
});
return Object.keys(ids).map(Number).sort(function (a, b) { return a - b; });
}
function renderLcTable(block, ctx) {
var wrap = document.createElement('div');
var table = h('table', 'pa-lc');
var thead = h('thead');
var headRow = h('tr');
(block.headers || []).forEach(function (hd) { headRow.appendChild(h('th', null, hd)); });
headRow.appendChild(h('th', null, ''));
thead.appendChild(headRow);
table.appendChild(thead);
var tbody = h('tbody');
function buildRow(idText, cells, isUserRow, userId) {
var tr = h('tr');
tr.appendChild(h('td', null, idText));
cells.forEach(function (cell) { tr.appendChild(tableCellNode(cell, ctx)); });
var rmTd = h('td', 'row-remove');
if (isUserRow) {
var btn = h('button', 'row-remove-btn', '✕');
btn.title = 'Remove this row';
btn.addEventListener('click', function () {
setValue('load_combo.' + block.name + '.u' + userId + '.include', '');
tr.remove();
});
rmTd.appendChild(btn);
}
tr.appendChild(rmTd);
return tr;
}
(block.rows || []).forEach(function (row) {
tbody.appendChild(buildRow(row[0], row.slice(1), false, null));
});
lcExistingUserIds(block.name).forEach(function (uid) {
var cells = (block.factors || []).map(function (f) {
return { editor: 'num', key: 'load_combo.' + block.name + '.u' + uid + '.' + f };
});
cells.push({ editor: 'check', key: 'load_combo.' + block.name + '.u' + uid + '.include' });
tbody.appendChild(buildRow('u' + uid, cells, true, uid));
});
table.appendChild(tbody);
wrap.appendChild(table);
var addBtn = h('button', 'btn add-row-btn', 'Add LC');
addBtn.addEventListener('click', function () {
var uid = (lcExistingUserIds(block.name).pop() || 0) + 1;
(block.factors || []).forEach(function (f) {
setValue('load_combo.' + block.name + '.u' + uid + '.' + f, '0');
});
setValue('load_combo.' + block.name + '.u' + uid + '.include', 'yes');
scheduleSave();
var cells = (block.factors || []).map(function (f) {
return { editor: 'num', key: 'load_combo.' + block.name + '.u' + uid + '.' + f };
});
cells.push({ editor: 'check', key: 'load_combo.' + block.name + '.u' + uid + '.include' });
tbody.appendChild(buildRow('u' + uid, cells, true, uid));
});
wrap.appendChild(addBtn);
return wrap;
}
// ------------------------------------------------------- editable_table
function tableColumns(tableName, blockColumns) {
var def = state.spec.tables && state.spec.tables[tableName];
if (def && def.columns) return def.columns.map(function (c) {
return { key: c.key, label: c.label, editor: c.editor, choices: c.choices, unit: c.unit };
});
return (blockColumns || []).map(function (c) {
return { key: c.col, label: c.label, editor: c.editor, choices: c.choices, unit: null };
});
}
function rowIds(tableName) {
var re = new RegExp('^' + escapeReg(tableName) + '\\.(\\d+)\\.present$');
var ids = [];
Object.keys(state.values).forEach(function (k) {
var m = re.exec(k);
if (m && truthy(state.values[k])) ids.push(parseInt(m[1], 10));
});
return ids.sort(function (a, b) { return a - b; });
}
function maxEverId(tableName) {
var re = new RegExp('^' + escapeReg(tableName) + '\\.(\\d+)\\.');
var max = 0;
Object.keys(state.values).forEach(function (k) {
var m = re.exec(k);
if (m) max = Math.max(max, parseInt(m[1], 10));
});
return max;
}
function renderEditableTable(block, ctx) {
var tableName = block.table;
var cols = tableColumns(tableName, block.columns);
var wrap = document.createElement('div');
var table = h('table', 'pa-editable');
var thead = h('thead');
var headRow = h('tr');
cols.forEach(function (c) { headRow.appendChild(h('th', null, c.label)); });
headRow.appendChild(h('th', null, ''));
thead.appendChild(headRow);
table.appendChild(thead);
var tbody = h('tbody');
table.appendChild(tbody);
wrap.appendChild(table);
function buildRow(n) {
var tr = h('tr');
cols.forEach(function (c) {
var key = tableName + '.' + n + '.' + c.key;
var td = document.createElement('td');
var editor = buildEditor(c.editor, key, { choices: c.choices, colUnit: c.unit });
td.appendChild(editor.el);
var icon = provIcon(key);
if (icon) td.appendChild(icon);
registerFieldEl(key, editor.input);
tr.appendChild(td);
});
var rmTd = h('td', 'row-remove');
var btn = h('button', 'row-remove-btn', '✕');
btn.title = 'Remove this row';
btn.addEventListener('click', function () {
setValue(tableName + '.' + n + '.present', '');
scheduleSave();
redrawDependents(tableName + '.' + n + '.present');
tr.remove();
var t = state.tableEls[tableName];
if (t) t.refresh();
recomputeLiveKv(tableName + '.' + n + '.present');
});
rmTd.appendChild(btn);
tr.appendChild(rmTd);
return tr;
}
rowIds(tableName).forEach(function (n) { tbody.appendChild(buildRow(n)); });
// Empty state (issue #22, items 5/11): a table with no rows says WHY
// -- the model read's own basis sentence when it left one (the
// block's `empty_state.basis_keys`, looked up in `spec.basis`), else
// the block's `empty_state.text`, else a generic "nothing entered
// yet". Could-not-judge and judged-empty must never look alike.
var emptyEl = h('div', 'table-empty-state');
wrap.appendChild(emptyEl);
function refreshEmpty() {
var live = rowIds(tableName).length;
if (live) { emptyEl.hidden = true; emptyEl.textContent = ''; return; }
emptyEl.hidden = false;
emptyEl.textContent = emptyStateText(tableName, block);
}
refreshEmpty();
var errEl = h('div', 'table-error-msg');
errEl.hidden = true;
wrap.appendChild(errEl);
state.tableEls[tableName] = { wrap: wrap, errEl: errEl, emptyEl: emptyEl, refresh: refreshEmpty };
wrap.classList.add('editable-table-wrap');
var addBtn = h('button', 'btn add-row-btn', block.add_label || 'Add row');
addBtn.addEventListener('click', function () {
var n = maxEverId(tableName) + 1;
setValue(tableName + '.' + n + '.present', 'yes');
cols.forEach(function (c) { setValue(tableName + '.' + n + '.' + c.key, ''); });
scheduleSave();
tbody.appendChild(buildRow(n));
refreshEmpty();
clearTableError(tableName);
redrawDependents(tableName + '.' + n + '.present');
recomputeLiveKv(tableName + '.' + n + '.present');
});
wrap.appendChild(addBtn);
return wrap;
}
function emptyStateText(tableName, block) {
var def = (state.spec.tables && state.spec.tables[tableName]) || {};
var es = block.empty_state || def.empty_state || null;
var basis = state.spec.basis || {};
if (es && es.basis_keys) {
for (var i = 0; i < es.basis_keys.length; i++) {
var sentence = basis[es.basis_keys[i]];
if (sentence) return 'Model read: ' + sentence;
}
}
if (es && es.text) return es.text;
return 'No rows yet \u2014 nothing was read from the model for this table and nothing has been entered.';
}
// ------------------------------------------------------------ drawing
function cutFracValue() {
if (!state.cutline) return 0.5;
var v = state.values[state.cutline.key];
var n = parseFloat(v);
if (v === undefined || v === '' || isNaN(n)) n = state.cutline.default;
return Math.max(0, Math.min(1, n));
}
function setDrawingProblems(el, problems) {
el.textContent = (problems && problems.length) ? problems.join(' — ') : '';
}
function redrawOne(inst) {
var block = inst.block;
var geom = state.spec.geometry[block.drawing_key];
try {
var tool = window.ToolDrawings;
if (tool && typeof tool[block.source] === 'function') {
tool[block.source](inst, state, inst.host, geom);
setDrawingProblems(inst.probEl, geom && geom.problems);
return;
}
if (block.source === 'outline' && inst.page.id === 'concrete_extents') {
D.renderOutline(inst.host, geom, cutFracValue(), {
onDrag: function (frac) {
state.values[state.cutline.key] = String(frac);
redrawDependents(state.cutline.key);
},
onDragEnd: function (frac) {
state.values[state.cutline.key] = String(frac);
scheduleSave();
}
}, block.aspect);
setDrawingProblems(inst.probEl, geom && geom.problems);
} else if (block.source === 'outline' && inst.page.id === 'reinforcement') {
D.renderReinforcementOutline(inst.host, geom, block.aspect);
setDrawingProblems(inst.probEl, geom && geom.problems);
} else if (block.source === 'section' && inst.page.id === 'concrete_extents') {
var outlineGeom = state.spec.geometry['concrete_extents.outline'];
var xft = cutFracValue() * ((outlineGeom && outlineGeom.length_ft) || 0);
var sec = D.sectionAt(outlineGeom, xft);
D.renderSection(inst.host, sec, state.values, block.aspect);
setDrawingProblems(inst.probEl, sec.problems);
} else if (block.source === 'freebody') {
D.renderFreebody(inst.host, geom, block.aspect);
setDrawingProblems(inst.probEl, geom && geom.problems);
} else if (block.source === 'lifter_dunnage') {
D.renderLifterDunnage(inst.host, geom, block.aspect);
setDrawingProblems(inst.probEl, geom && geom.problems);
} else if (block.source === 'section' && inst.page.id === 'reinforcement') {
D.renderReinforcementSection(inst.host, geom, block.aspect);
setDrawingProblems(inst.probEl, geom && geom.problems);
} else {
inst.host.textContent = '';
inst.host.appendChild(h('div', 'unknown-block', 'Unknown drawing source: ' + block.source));
}
} catch (e) {
logError('drawing render failed (' + block.drawing_key + '): ' + (e && e.stack || e));
}
}
// `state.spec.geometry` is a SNAPSHOT fetched once, at page load
// (`init`'s single `/api/spec` call) -- editing a value never updates it
// on its own. Dragging the cut line stays a pure client-side recompute
// (`redrawOne`'s own 'section' branch slices the SAME outline snapshot at
// a new x, via `D.sectionAt`) because the outline's own geometry has not
// changed, only which station is shown -- that is what keeps the drag
// itself instant ("跟做核磁一样"). Anything else a drawing `depends_on`
// (a row table -- `opening`, `restraint`, `wind_zone`, ... -- or a field
// like `length_ft` / `width_ft`) DOES change the real geometry (a new
// opening, a moved support), so that snapshot has to be rebuilt off the
// current `state.values` before any of those drawings can redraw
// correctly; `scheduleGeometryRefresh` debounces that rebuild (one
// `/api/geometry` round trip, not one per keystroke) and then redraws
// every drawing on the page against the refreshed snapshot.
var geomRefreshTimer = null;
function redrawDependents(changedKey) {
var needsRefresh = false;
state.drawings.forEach(function (inst) {
var deps = inst.block.depends_on || [];
var hit = deps.indexOf(changedKey) !== -1 ||
deps.some(function (d) { return changedKey.indexOf(d + '.') === 0; });
if (!hit) return;
if (state.cutline && changedKey === state.cutline.key) {
redrawOne(inst);
} else {
needsRefresh = true;
}
});
if (needsRefresh) scheduleGeometryRefresh();
}
function scheduleGeometryRefresh() {
if (geomRefreshTimer) clearTimeout(geomRefreshTimer);
geomRefreshTimer = setTimeout(refreshGeometryAndRedraw, 250);
}
function refreshGeometryAndRedraw() {
geomRefreshTimer = null;
fetch('/api/geometry', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ values: state.values })
}).then(function (res) { return res.json(); })
.then(function (data) {
if (data && data.geometry) state.spec.geometry = data.geometry;
state.drawings.forEach(redrawOne);
})
.catch(function (e) { logError('geometry refresh failed: ' + e); });
}
function renderDrawingBlock(block, ctx) {
var wrap = h('div', 'drawing-block');
if (block.caption) wrap.appendChild(h('div', 'drawing-caption', block.caption));
var host = h('div', 'drawing-host');
wrap.appendChild(host);
var probEl = h('div', 'drawing-problems');
wrap.appendChild(probEl);
if (block.cutline) state.cutline = { key: block.cutline.key, default: block.cutline.default };
var inst = { block: block, host: host, probEl: probEl, page: ctx.page };
state.drawings.push(inst);
redrawOne(inst);
return wrap;
}
// -------------------------------------------------------------- pages
function buildNotes(notesData, pageProblems) {
if (!notesData.hints.length && !(pageProblems && pageProblems.length)) return null;
var det = document.createElement('details');
det.className = 'pa-notes';
det.appendChild(h('summary', null, 'Notes'));
var body = h('div', 'pa-notes-body');
if (notesData.hints.length) {
body.appendChild(h('h4', null, 'Field notes'));
var ul = h('ul');
notesData.hints.forEach(function (n) {
var li = document.createElement('li');
if (n.label) {
var b = document.createElement('b');
b.textContent = n.label + ': ';
li.appendChild(b);
}
li.appendChild(document.createTextNode(n.text || ''));
ul.appendChild(li);
});
body.appendChild(ul);
}
if (pageProblems && pageProblems.length) {
body.appendChild(h('h4', null, 'Known limitations on this page'));
var ul2 = h('ul');
pageProblems.forEach(function (p) { ul2.appendChild(h('li', null, p)); });
body.appendChild(ul2);
}
det.appendChild(body);
return det;
}
function renderPage(page, index) {
var container = h('div', 'page-panel');
container.id = 'page-' + page.id;
var notesData = { hints: [] };
renderBlockList(container, page.blocks, { page: page, notes: notesData });
var notes = buildNotes(notesData, page.problems);
if (notes) container.appendChild(notes);
document.getElementById('pages').appendChild(container);
state.pageContainers[index] = container;
}
function switchTab(index) {
state.activeIndex = index;
state.pageContainers.forEach(function (c, i) { c.classList.toggle('active', i === index); });
state.tabButtons.forEach(function (b, i) { b.classList.toggle('active', i === index); });
}
function buildTabs() {
var tabsEl = document.getElementById('tabs');
state.spec.pages.forEach(function (page, i) {
var btn = h('button', 'tab', page.title);
btn.addEventListener('click', function () { switchTab(i); });
tabsEl.appendChild(btn);
state.tabButtons.push(btn);
});
}
function expandAll(root) {
root.querySelectorAll('details').forEach(function (d) { d.open = true; });
}
// --------------------------------------------------------------- run
// The banner carries one of two faces, and they must never look alike:
// 'input' (amber) is the piece or the form still missing a value -- the
// user's to fill, nothing broke; 'crash' (red) is the server, the solve
// or the network breaking -- a bug, not a data gap. A validation refusal
// and a crash used to share one red banner, and a fixed dependency bug was
// mistaken for a new one because both read as "Panel Analysis errored"
// (issue #19). `kind` defaults to 'crash': an unlabelled failure renders
// loud, never calm.
function showBanner(text, kind) {
var b = document.getElementById('banner');
b.className = 'banner ' + (kind === 'input' ? 'banner--input' : 'banner--crash');
b.textContent = text;
b.hidden = false;
}
function hideBanner() {
document.getElementById('banner').hidden = true;
}
function pageIndexOf(el) {
var container = el.closest && el.closest('.page-panel');
if (!container) return -1;
return state.pageContainers.indexOf(container);
}
function showProblems(problems, kind) {
var general = [];
var firstEl = null;
var fieldCount = 0;
// A problem keyed by a LEGACY field name (`supports_ft`) that no page
// renders any more is routed onto the row table that replaced it
// (`spec.problem_targets`, from panelanalysisform.LEGACY_TABLE_KEYS),
// or onto a table named directly -- highlighted and switched to, never
// dropped into the banner with no pointer (issue #22, item 11).
var targets = (state.spec && state.spec.problem_targets) || {};
problems.forEach(function (p) {
var key = p.key || '';
var els = key ? state.fieldEls[key] : null;
if (els && els.length) {
els.forEach(function (el) { setFieldError(el, p.text); });
if (!firstEl) firstEl = els[0];
fieldCount += 1;
return;
}
var tableName = key && (state.tableEls[key] ? key : targets[key]);
var wrapEl = tableName ? setTableError(tableName, p.text) : null;
if (wrapEl) {
if (!firstEl) firstEl = wrapEl;
fieldCount += 1;
return;
}
general.push((key ? key + ': ' : '') + p.text);
});
if (kind === 'input') {
// Always a banner, even when every problem sits on a field: the
// user has to be told this is a data gap and not a broken tool.
var head = 'Cannot run yet — input still needed, nothing is broken';
if (fieldCount) head += ' (' + fieldCount + ' field' + (fieldCount === 1 ? '' : 's') + ' marked below)';
showBanner(head + (general.length ? ': ' + general.join(' — ') : '.'), 'input');
} else {
var text = 'The analysis broke — this is a bug, not missing input: ' +
(general.length ? general.join(' — ') : '(no message)') +
'. The traceback is in the server log.';
showBanner(text, 'crash');
logError('run failed (' + (kind || 'unlabelled') + '): ' + general.join(' — '));
}
if (firstEl) {
var pi = pageIndexOf(firstEl);
if (pi >= 0) switchTab(pi);
firstEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
if (typeof firstEl.focus === 'function') firstEl.focus();
}
}
function onRun() {
hideBanner();
clearAllFieldErrors();
flushSave().then(function () {
return fetch('/api/run', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ values: state.values })
});
}).then(function (res) { return res.json(); })
.then(function (data) {
if (data.ok) {
window.location = data.result_url;
} else {
showProblems(data.problems || [], data.kind);
}
})
.catch(function (e) {
var msg = 'Could not reach the server: ' + (e && e.message || e);
showBanner(msg, 'crash');
logError('run failed (network): ' + msg);
});
}
function onClose() {
// Inside Revit the page sits in a WebView2 host window (vibe.webhost)
// that only closes on a `{type: 'close'}` postMessage -- the server
// exiting leaves the window on screen (issue #22, item 10). A plain
// desktop browser has no `window.chrome.webview` and skips this; the
// server call below is the same on both paths.
try {
if (window.chrome && window.chrome.webview && typeof window.chrome.webview.postMessage === 'function') {
window.chrome.webview.postMessage({ type: 'close' });
}
} catch (e) { logError('host close message failed: ' + e); }
fetch('/api/close', { method: 'POST' }).catch(function () { /* server exits; ignore */ });
}
// -------------------------------------------------------------- init
function findCutlineInSpec(spec) {
var found = null;
function walk(blocks) {
(blocks || []).forEach(function (b) {
if (found) return;
if (b.kind === 'drawing' && b.cutline) { found = b.cutline; return; }
if (b.kind === 'columns') b.columns.forEach(walk);
if (b.kind === 'section') walk(b.blocks);
});
}
spec.pages.forEach(function (p) { walk(p.blocks); });
return found;
}
function init() {
fetch('/api/spec').then(function (res) { return res.json(); }).then(function (spec) {
state.spec = spec;
state.values = Object.assign({}, spec.values || {});
state.cutline = findCutlineInSpec(spec);
loadOverrides();
if (state.qs.cut !== undefined && state.cutline) {
var outlineGeom = spec.geometry['concrete_extents.outline'];
var length = (outlineGeom && outlineGeom.length_ft > 0) ? outlineGeom.length_ft : 1;
var cutFt = parseFloat(state.qs.cut);
if (!isNaN(cutFt)) {
var frac = Math.max(0, Math.min(1, cutFt / length));
state.values[state.cutline.key] = String(frac);
}
}
document.getElementById('pa-mark').textContent = spec.mark ? (' — ' + spec.mark) : '';
document.title = spec.title ? (spec.title + (spec.mark ? ' - ' + spec.mark : '')) : 'Panel Analysis';
if (spec.problems && spec.problems.length) {
var sp = document.getElementById('spec-problems');
sp.textContent = spec.problems.join(' — ');
sp.hidden = false;
}
buildTabs();
spec.pages.forEach(function (page, i) { renderPage(page, i); });
var initialTab = 0;
if (state.qs.tab !== undefined) {
var n = parseInt(state.qs.tab, 10);
if (!isNaN(n)) initialTab = Math.max(0, Math.min(spec.pages.length - 1, n - 1));
}
switchTab(initialTab);
if (state.qs.expand === 'all') {
state.pageContainers.forEach(expandAll);
}
document.getElementById('btn-run').addEventListener('click', onRun);
document.getElementById('btn-close').addEventListener('click', onClose);
}).catch(function (e) {
showBanner('Could not load the form: ' + (e && e.message || e));
logError('spec load failed: ' + (e && e.stack || e));
});
}
document.addEventListener('DOMContentLoaded', init);
})();