// 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.'] = '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 `..` or `
..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
+ 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 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); })();