25592fa6c2
ci / test (push) Successful in 6m14s
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.
1309 lines
50 KiB
JavaScript
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);
|
|
})();
|