WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m18s
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,952 @@
|
||||
// SVG drawings for Panel Analysis's five geometry blocks, plus the pure
|
||||
// `sectionAt()` cut-profile function, reimplemented byte-for-byte from
|
||||
// `vibe.pa_sketch_extents.section_at()` per docs/pa-web-spec.md section 6 --
|
||||
// the two must always agree, not just on the panels covered by whatever
|
||||
// tests happen to exist.
|
||||
//
|
||||
// Everything here works off real-unit numbers (ft, in) straight out of the
|
||||
// spec's `geometry` dict -- no pixels arrive from the server, all layout and
|
||||
// scaling happens in here. Plain ES2017, no modules, no dependencies other
|
||||
// than window.Units (units.js, loaded first).
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var NS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
// --------------------------------------------------------- small helpers
|
||||
|
||||
function safeNum(value) {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === 'boolean') return null;
|
||||
if (typeof value === 'number') return isNaN(value) ? null : value;
|
||||
var s = String(value).trim();
|
||||
if (s === '') return null;
|
||||
var n = parseFloat(s);
|
||||
return isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
function clamp(v, lo, hi) {
|
||||
if (v < lo) return lo;
|
||||
if (v > hi) return hi;
|
||||
return v;
|
||||
}
|
||||
|
||||
function el(name, attrs, parent) {
|
||||
var e = document.createElementNS(NS, name);
|
||||
if (attrs) {
|
||||
Object.keys(attrs).forEach(function (k) {
|
||||
if (attrs[k] !== null && attrs[k] !== undefined) {
|
||||
e.setAttribute(k, attrs[k]);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (parent) parent.appendChild(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
function clear(node) {
|
||||
while (node.firstChild) node.removeChild(node.firstChild);
|
||||
}
|
||||
|
||||
// A nominal-coordinate SVG root sized by the block's own aspect ratio --
|
||||
// width always "fills the container" via width:100%, height follows from
|
||||
// aspect the way every drawing block in the spec declares it.
|
||||
function svgRoot(container, aspect, nominalW) {
|
||||
nominalW = nominalW || 1000;
|
||||
var h = Math.max(60, Math.round(nominalW * (aspect || 0.3)));
|
||||
clear(container);
|
||||
// `aspect-ratio` (not a bare height:auto SVG attribute -- Chrome does
|
||||
// NOT reliably derive height from viewBox alone on a replaced <svg>
|
||||
// element, and falls back to a tall default box, letterboxing the
|
||||
// drawing) is what actually makes the rendered box track the block's
|
||||
// own aspect as the container's width changes.
|
||||
var svg = el('svg', {
|
||||
viewBox: '0 0 ' + nominalW + ' ' + h,
|
||||
style: 'display:block;width:100%;aspect-ratio:' + nominalW + '/' + h + ';background:#fff'
|
||||
}, container);
|
||||
return { svg: svg, W: nominalW, H: h };
|
||||
}
|
||||
|
||||
// Text with an opaque background patch behind it (drawn AFTER the text is
|
||||
// measured, so it never overlaps a crossing line or another label).
|
||||
function label(svg, x, y, text, opts) {
|
||||
opts = opts || {};
|
||||
var t = el('text', {
|
||||
x: x, y: y,
|
||||
'text-anchor': opts.anchor || 'middle',
|
||||
'font-size': opts.size || 15,
|
||||
fill: opts.fill || '#1b1e23',
|
||||
'font-family': 'inherit'
|
||||
}, svg);
|
||||
t.textContent = text;
|
||||
if (opts.rotate) {
|
||||
t.setAttribute('transform', 'rotate(' + opts.rotate + ' ' + x + ' ' + y + ')');
|
||||
}
|
||||
if (opts.bg !== false) {
|
||||
var box;
|
||||
try { box = t.getBBox(); } catch (e) { box = null; }
|
||||
if (box) {
|
||||
var pad = 2;
|
||||
var rect = el('rect', {
|
||||
x: box.x - pad, y: box.y - pad,
|
||||
width: box.width + pad * 2, height: box.height + pad * 2,
|
||||
fill: opts.bgFill || '#fff',
|
||||
opacity: opts.bgOpacity === undefined ? 0.85 : opts.bgOpacity
|
||||
});
|
||||
svg.insertBefore(rect, t);
|
||||
if (opts.rotate) {
|
||||
rect.setAttribute('transform', 'rotate(' + opts.rotate + ' ' + x + ' ' + y + ')');
|
||||
}
|
||||
}
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
// A horizontal dimension string: line + end ticks + centred text.
|
||||
// `below` puts the text under the line instead of above it.
|
||||
function dimH(svg, x1, x2, y, text, opts) {
|
||||
opts = opts || {};
|
||||
var tick = opts.tick || 6;
|
||||
var g = el('g', { class: 'dim' }, svg);
|
||||
el('line', { x1: x1, y1: y - tick, x2: x1, y2: y + tick, stroke: '#8a8f98', 'stroke-width': 1 }, g);
|
||||
el('line', { x1: x2, y1: y - tick, x2: x2, y2: y + tick, stroke: '#8a8f98', 'stroke-width': 1 }, g);
|
||||
el('line', { x1: x1, y1: y, x2: x2, y2: y, stroke: '#8a8f98', 'stroke-width': 1 }, g);
|
||||
label(g, (x1 + x2) / 2, opts.below ? y + 16 : y - 6, text, { size: opts.size || 13 });
|
||||
return g;
|
||||
}
|
||||
|
||||
// A vertical dimension string: line + end ticks + rotated centred text.
|
||||
function dimV(svg, y1, y2, x, text, opts) {
|
||||
opts = opts || {};
|
||||
var tick = opts.tick || 6;
|
||||
var g = el('g', { class: 'dim' }, svg);
|
||||
el('line', { x1: x - tick, y1: y1, x2: x + tick, y2: y1, stroke: '#8a8f98', 'stroke-width': 1 }, g);
|
||||
el('line', { x1: x - tick, y1: y2, x2: x + tick, y2: y2, stroke: '#8a8f98', 'stroke-width': 1 }, g);
|
||||
el('line', { x1: x, y1: y1, x2: x, y2: y2, stroke: '#8a8f98', 'stroke-width': 1 }, g);
|
||||
var mx = opts.left ? x - 10 : x + 10;
|
||||
label(g, mx, (y1 + y2) / 2, text, { size: opts.size || 13, rotate: -90 });
|
||||
return g;
|
||||
}
|
||||
|
||||
function ftIn(v) { return window.Units.fmtFtIn(v); }
|
||||
function inch(v) { return window.Units.fmtIn(v); }
|
||||
|
||||
// A cheap text-width estimate (no DOM measurement needed) for staggering
|
||||
// labels BEFORE they are drawn -- good enough to keep a long label like
|
||||
// 'Floor tie @ 13\'-2 1/2"' from being scored as if it were as narrow as
|
||||
// a short one, which is what let two such labels print on top of each
|
||||
// other despite `stagger()` "succeeding".
|
||||
function estWidth(text, size) { return String(text).length * size * 0.58; }
|
||||
|
||||
// Greedy label-collision avoidance: given item extents [{x0,x1}], returns
|
||||
// a stacking "level" per item (0, 1, 2, ...) such that no two items on the
|
||||
// same level overlap within `gap`. Used to stagger restraint / lifter
|
||||
// labels that would otherwise sit on top of one another.
|
||||
function stagger(items, gap) {
|
||||
gap = gap || 4;
|
||||
var levels = []; // each: last x1 placed on that level
|
||||
var out = new Array(items.length);
|
||||
var order = items.map(function (it, i) { return i; })
|
||||
.sort(function (a, b) { return items[a].x0 - items[b].x0; });
|
||||
order.forEach(function (i) {
|
||||
var it = items[i];
|
||||
var lvl = 0;
|
||||
while (lvl < levels.length && levels[lvl] > it.x0 - gap) lvl++;
|
||||
levels[lvl] = it.x1;
|
||||
out[i] = lvl;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// One continuous dimension chain's cells: consecutive `[lo, hi]` spans (in
|
||||
// inches-from-left) starting at `railPosIn`, each labelled with the chain
|
||||
// length as typed (`cs[i]`). Shared by the reinforcement plan and section
|
||||
// so the two can never disagree about the chain itself (issue #23 item 9i).
|
||||
function chainCellsFromRail(xs, cs, railPosIn) {
|
||||
var cells = [];
|
||||
var prev = railPosIn;
|
||||
for (var i = 0; i < xs.length; i++) {
|
||||
var x = safeNum(xs[i]);
|
||||
if (x === null) x = prev;
|
||||
cells.push({ lo: Math.min(prev, x), hi: Math.max(prev, x), label: cs[i] });
|
||||
prev = x;
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- section-at-cut
|
||||
|
||||
function mergeRanges(ranges) {
|
||||
var clean = ranges.filter(function (r) { return r[1] > r[0]; })
|
||||
.sort(function (a, b) { return a[0] - b[0]; });
|
||||
var merged = [];
|
||||
clean.forEach(function (r) {
|
||||
if (merged.length && r[0] <= merged[merged.length - 1][1]) {
|
||||
merged[merged.length - 1][1] = Math.max(merged[merged.length - 1][1], r[1]);
|
||||
} else {
|
||||
merged.push([r[0], r[1]]);
|
||||
}
|
||||
});
|
||||
return merged;
|
||||
}
|
||||
|
||||
var FALLBACK_LENGTH_FT = 1.0;
|
||||
var FALLBACK_WIDTH_FT = 0.3;
|
||||
|
||||
// Reimplements vibe.pa_sketch_extents.section_at() -- see
|
||||
// docs/pa-web-spec.md section 6. Never throws.
|
||||
function sectionAt(geometry, xFt) {
|
||||
geometry = (geometry && typeof geometry === 'object') ? geometry : {};
|
||||
var problems = [];
|
||||
var lengthFt = safeNum(geometry.length_ft);
|
||||
if (lengthFt === null || lengthFt <= 0) lengthFt = FALLBACK_LENGTH_FT;
|
||||
var widthFt = safeNum(geometry.width_ft);
|
||||
if (widthFt === null || widthFt <= 0) widthFt = FALLBACK_WIDTH_FT;
|
||||
var thicknessIn = safeNum(geometry.thickness_in);
|
||||
|
||||
var x = safeNum(xFt);
|
||||
if (x === null) {
|
||||
problems.push('x_ft is not a number; using the panel midpoint.');
|
||||
x = lengthFt / 2.0;
|
||||
}
|
||||
x = clamp(x, 0, lengthFt);
|
||||
|
||||
var rawRanges = [];
|
||||
(geometry.openings || []).forEach(function (row) {
|
||||
if (!row || typeof row !== 'object') return;
|
||||
var type = String(row.type || 'opening').trim().toLowerCase();
|
||||
if (type !== 'opening' && type !== '') return;
|
||||
var along = row.along;
|
||||
if (!along || along.length !== 2 || along[0] === null || along[1] === null) return;
|
||||
var lo = along[0], hi = along[1];
|
||||
if (!(lo <= x && x <= hi)) return;
|
||||
var vert = row.vert;
|
||||
if (!vert || vert.length !== 2 || vert[0] === null || vert[1] === null) {
|
||||
problems.push('opening ' + JSON.stringify(row.label || row.index) +
|
||||
' crosses this station but has no across-width position entered; ' +
|
||||
'not punched into the section.');
|
||||
return;
|
||||
}
|
||||
rawRanges.push([Number(vert[0]), Number(vert[1])]);
|
||||
});
|
||||
|
||||
var voids = mergeRanges(rawRanges);
|
||||
return { x_ft: x, width_ft: widthFt, thickness_in: thicknessIn, voids: voids, problems: problems };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- outline
|
||||
|
||||
// Renders the panel outline (Concrete Extents drawing 1) with openings
|
||||
// and a draggable cut line. `cutFrac` is the initial 0..1 fraction;
|
||||
// `cb.onDrag(frac)` fires continuously while dragging, `cb.onDragEnd(frac)`
|
||||
// once on release -- both mouse and touch, via Pointer Events.
|
||||
function renderOutline(container, geometry, cutFrac, cb, aspect) {
|
||||
geometry = geometry || {};
|
||||
cb = cb || {};
|
||||
aspect = aspect || 0.32;
|
||||
var root = svgRoot(container, aspect);
|
||||
var svg = root.svg, W = root.W, H = root.H;
|
||||
|
||||
var lengthFt = geometry.length_ft || FALLBACK_LENGTH_FT;
|
||||
var widthFt = geometry.width_ft || FALLBACK_WIDTH_FT;
|
||||
|
||||
var marginL = 60, marginR = 20, marginTop = 34, marginBottom = 24;
|
||||
// The degraded-placeholder note sits above the overall-length dimension
|
||||
// string -- give it its own row instead of sharing the dimension's, or
|
||||
// the two labels print on top of each other (both centred on the same
|
||||
// x, both near y=14).
|
||||
if (geometry.degraded) marginTop += 16;
|
||||
var drawW = W - marginL - marginR;
|
||||
var drawH = H - marginTop - marginBottom;
|
||||
var scale = drawW / lengthFt;
|
||||
var rectH = Math.min(drawH, widthFt * scale);
|
||||
var rectY = marginTop + (drawH - rectH) / 2;
|
||||
|
||||
function xAt(ft) { return marginL + ft * scale; }
|
||||
function yAt(vertFt) { return rectY + rectH - vertFt * scale; } // vert measured from bottom
|
||||
|
||||
if (geometry.degraded) {
|
||||
label(svg, W / 2, 14, 'No length/width entered yet -- placeholder shown', { size: 12, fill: '#b45309' });
|
||||
}
|
||||
|
||||
// panel outline
|
||||
el('rect', {
|
||||
x: marginL, y: rectY, width: drawW, height: rectH,
|
||||
fill: '#f4f6fb', stroke: '#1b1e23', 'stroke-width': 1.5
|
||||
}, svg);
|
||||
|
||||
// openings
|
||||
(geometry.openings || []).forEach(function (op) {
|
||||
if (!op.along) return;
|
||||
var x0 = xAt(op.along[0]), x1 = xAt(op.along[1]);
|
||||
var type = (op.type || '').toLowerCase();
|
||||
if (op.vert) {
|
||||
var y0 = yAt(op.vert[1]), y1 = yAt(op.vert[0]);
|
||||
el('rect', {
|
||||
x: x0, y: y0, width: Math.max(1, x1 - x0), height: Math.max(1, y1 - y0),
|
||||
fill: type === 'opening' ? '#ffffff' : (type === 'reveal' ? '#eef2ff' : '#f1f5f9'),
|
||||
stroke: '#334155',
|
||||
'stroke-dasharray': type === 'reveal' ? '4,3' : null,
|
||||
'stroke-width': 1.2
|
||||
}, svg);
|
||||
} else {
|
||||
// no across-width position: show a dashed full-height band + note
|
||||
el('rect', {
|
||||
x: x0, y: rectY, width: Math.max(1, x1 - x0), height: rectH,
|
||||
fill: 'none', stroke: '#b45309', 'stroke-dasharray': '3,3', 'stroke-width': 1
|
||||
}, svg);
|
||||
label(svg, (x0 + x1) / 2, rectY + rectH / 2, '?', { size: 11, fill: '#b45309' });
|
||||
}
|
||||
if (op.label) {
|
||||
label(svg, (x0 + x1) / 2, (op.vert ? yAt(op.vert[1]) : rectY) - 4, op.label, { size: 10 });
|
||||
}
|
||||
});
|
||||
|
||||
// overall dimensions
|
||||
dimH(svg, marginL, marginL + drawW, marginTop - 14, ftIn(lengthFt));
|
||||
dimV(svg, rectY, rectY + rectH, marginL - 14, ftIn(widthFt), { left: true });
|
||||
|
||||
// cut line
|
||||
var cutX = marginL + clamp(cutFrac, 0, 1) * drawW;
|
||||
var lineGroup = el('g', { style: 'cursor:ew-resize' }, svg);
|
||||
var hit = el('rect', {
|
||||
x: cutX - 8, y: rectY - 6, width: 16, height: rectH + 12,
|
||||
fill: 'transparent'
|
||||
}, lineGroup);
|
||||
var cutLine = el('line', {
|
||||
x1: cutX, y1: rectY - 6, x2: cutX, y2: rectY + rectH + 6,
|
||||
stroke: '#d33', 'stroke-width': 2
|
||||
}, lineGroup);
|
||||
el('polygon', {
|
||||
points: [cutX - 5, rectY - 6, cutX + 5, rectY - 6, cutX, rectY - 6 + 8].join(' '),
|
||||
fill: '#d33'
|
||||
}, lineGroup);
|
||||
|
||||
function moveTo(clientX) {
|
||||
var box = svg.getBoundingClientRect();
|
||||
var svgX = (clientX - box.left) / box.width * W;
|
||||
var frac = clamp((svgX - marginL) / drawW, 0, 1);
|
||||
var px = marginL + frac * drawW;
|
||||
cutLine.setAttribute('x1', px); cutLine.setAttribute('x2', px);
|
||||
hit.setAttribute('x', px - 8);
|
||||
return frac;
|
||||
}
|
||||
|
||||
var dragging = false;
|
||||
hit.addEventListener('pointerdown', function (ev) {
|
||||
dragging = true;
|
||||
try { hit.setPointerCapture(ev.pointerId); } catch (e) { /* ignore */ }
|
||||
ev.preventDefault();
|
||||
});
|
||||
hit.addEventListener('pointermove', function (ev) {
|
||||
if (!dragging) return;
|
||||
var frac = moveTo(ev.clientX);
|
||||
if (cb.onDrag) cb.onDrag(frac);
|
||||
});
|
||||
function endDrag(ev) {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
var frac = moveTo(ev.clientX);
|
||||
if (cb.onDragEnd) cb.onDragEnd(frac);
|
||||
}
|
||||
hit.addEventListener('pointerup', endDrag);
|
||||
hit.addEventListener('pointercancel', endDrag);
|
||||
|
||||
return {
|
||||
setCutFrac: function (frac) { moveTo(marginL + clamp(frac, 0, 1) * drawW +
|
||||
svg.getBoundingClientRect().left); }
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- section
|
||||
|
||||
function renderSection(container, sec, values, aspect) {
|
||||
sec = sec || { width_ft: FALLBACK_WIDTH_FT, thickness_in: 8, voids: [] };
|
||||
aspect = aspect || 0.16;
|
||||
var root = svgRoot(container, aspect);
|
||||
var svg = root.svg, W = root.W, H = root.H;
|
||||
|
||||
var widthFt = sec.width_ft || FALLBACK_WIDTH_FT;
|
||||
var thicknessIn = sec.thickness_in || 8;
|
||||
|
||||
var marginL = 60, marginR = 20, marginTop = 24, marginBottom = 46;
|
||||
var drawW = W - marginL - marginR;
|
||||
var drawH = H - marginTop - marginBottom;
|
||||
var scale = drawW / widthFt;
|
||||
var rectH = Math.min(drawH, (thicknessIn / 12) * scale * 4); // exaggerate thin strips
|
||||
if (rectH < 10) rectH = 10;
|
||||
var rectY = marginTop + (drawH - rectH) / 2;
|
||||
|
||||
function xAt(ft) { return marginL + ft * scale; }
|
||||
|
||||
// draw solid segments, punching voids
|
||||
var voids = (sec.voids || []).slice().sort(function (a, b) { return a[0] - b[0]; });
|
||||
var cursor = 0;
|
||||
var segs = [];
|
||||
voids.forEach(function (v) {
|
||||
if (v[0] > cursor) segs.push([cursor, v[0]]);
|
||||
cursor = Math.max(cursor, v[1]);
|
||||
});
|
||||
if (cursor < widthFt) segs.push([cursor, widthFt]);
|
||||
|
||||
segs.forEach(function (s) {
|
||||
el('rect', {
|
||||
x: xAt(s[0]), y: rectY, width: Math.max(0, xAt(s[1]) - xAt(s[0])), height: rectH,
|
||||
fill: '#e2e8f0', stroke: '#1b1e23', 'stroke-width': 1.2
|
||||
}, svg);
|
||||
});
|
||||
voids.forEach(function (v) {
|
||||
label(svg, (xAt(v[0]) + xAt(v[1])) / 2, rectY + rectH / 2, 'open', { size: 10, fill: '#94a3b8' });
|
||||
});
|
||||
|
||||
dimH(svg, marginL, marginL + drawW, rectY + rectH + 18, ftIn(widthFt), { below: true });
|
||||
dimV(svg, rectY, rectY + rectH, marginL - 14, inch(thicknessIn), { left: true });
|
||||
label(svg, W - marginR, marginTop - 8, 'cut at ' + ftIn(sec.x_ft), { anchor: 'end', size: 11, fill: '#6b7280' });
|
||||
|
||||
if (sec.thickness_in && (thicknessIn / 12) * scale * 4 > drawH + 2) {
|
||||
label(svg, marginL, marginTop - 8, '(vertically exaggerated)', { anchor: 'start', size: 10, fill: '#6b7280' });
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ freebody
|
||||
|
||||
// A classic beam-style free-body diagram: the member drawn as ONE
|
||||
// horizontal line regardless of its true (vertical) orientation, with
|
||||
// restraints, point loads and wind-zone bands positioned along it by
|
||||
// elevation. Matches the wide/short aspect every drawing block declares.
|
||||
function renderFreebody(container, geom, aspect) {
|
||||
geom = geom || {};
|
||||
aspect = aspect || 0.24;
|
||||
var root = svgRoot(container, aspect);
|
||||
var svg = root.svg, W = root.W, H = root.H;
|
||||
|
||||
var bot = geom.bottom_elevation_ft, top = geom.top_elevation_ft;
|
||||
if (bot === undefined || bot === null) bot = 0;
|
||||
if (top === undefined || top === null || top <= bot) top = bot + 1;
|
||||
|
||||
var marginL = 40, marginR = 40, marginTop = 60, marginBottom = 70;
|
||||
var drawW = W - marginL - marginR;
|
||||
var lineY = marginTop + (H - marginTop - marginBottom) / 2;
|
||||
var scale = drawW / (top - bot);
|
||||
function xAt(elev) { return marginL + (elev - bot) * scale; }
|
||||
|
||||
el('line', { x1: marginL, y1: lineY, x2: marginL + drawW, y2: lineY, stroke: '#1b1e23', 'stroke-width': 3 }, svg);
|
||||
dimH(svg, marginL, marginL + drawW, H - 14, ftIn(top - bot) + ' overall', { below: false, size: 11 });
|
||||
|
||||
// restraints, ABOVE the line (owner ruling, 2026-09-05: supports below,
|
||||
// restraints above), staggered by ESTIMATED LABEL WIDTH (not a fixed
|
||||
// guess) so a long "Floor tie @ 13'-2 1/2"" label never overlaps its
|
||||
// neighbour the way a fixed half-width would on a tightly spaced run.
|
||||
// Reserves a fixed ~34px band directly above the line; point loads
|
||||
// (below) start their own arrows further up still, clear of this band.
|
||||
var restraints = geom.restraints || [];
|
||||
var rTexts = restraints.map(function (r) { return (r.type || '?') + ' @ ' + ftIn(r.elevation_ft); });
|
||||
var rItems = restraints.map(function (r, i) {
|
||||
var x = xAt(r.elevation_ft);
|
||||
var w = estWidth(rTexts[i], 10);
|
||||
return { x0: x - w / 2, x1: x + w / 2 };
|
||||
});
|
||||
var rLevels = stagger(rItems, 6);
|
||||
restraints.forEach(function (r, i) {
|
||||
var x = xAt(r.elevation_ft);
|
||||
var y0 = lineY;
|
||||
var lvl = rLevels[i];
|
||||
var sym = el('g', {}, svg);
|
||||
var type = (r.type || '').toLowerCase();
|
||||
if (type === 'pin') {
|
||||
el('polygon', { points: [x - 7, y0 - 14, x + 7, y0 - 14, x, y0].join(' '), fill: '#fff', stroke: '#1b1e23' }, sym);
|
||||
} else if (type === 'fixed') {
|
||||
el('line', { x1: x - 9, y1: y0 - 10, x2: x + 9, y2: y0 - 10, stroke: '#1b1e23', 'stroke-width': 3 }, sym);
|
||||
for (var h = -8; h <= 8; h += 4) {
|
||||
el('line', { x1: x + h, y1: y0 - 10, x2: x + h - 4, y2: y0 - 18, stroke: '#1b1e23', 'stroke-width': 1 }, sym);
|
||||
}
|
||||
} else if (type === 'floor tie') {
|
||||
el('circle', { cx: x, cy: y0 - 10, r: 5, fill: '#fff', stroke: '#1b1e23' }, sym);
|
||||
el('line', { x1: x, y1: y0, x2: x, y2: y0 - 5, stroke: '#1b1e23' }, sym);
|
||||
} else { // Free / unknown
|
||||
el('circle', { cx: x, cy: y0 - 8, r: 3, fill: '#94a3b8' }, sym);
|
||||
}
|
||||
label(svg, x, lineY - 34 - lvl * 14, (r.type || '?') + ' @ ' + ftIn(r.elevation_ft), { size: 10 });
|
||||
});
|
||||
|
||||
// supports (bearing positions for THIS analysis), BELOW the line --
|
||||
// filled triangles, apex on the line, base below, one label each.
|
||||
// Owner ruling, 2026-09-05: `support` table x_ft positions, already
|
||||
// converted to this drawing's elevation axis by
|
||||
// `pa_sketch_freebody._support_rows` (bottom_elevation_ft + x_ft).
|
||||
var supports = geom.supports || [];
|
||||
var sTexts = supports.map(function (s) { return 'Support @ ' + ftIn(s.elevation_ft); });
|
||||
var sItems = supports.map(function (s, i) {
|
||||
var x = xAt(s.elevation_ft);
|
||||
var w = estWidth(sTexts[i], 10);
|
||||
return { x0: x - w / 2, x1: x + w / 2 };
|
||||
});
|
||||
var sLevels = stagger(sItems, 6);
|
||||
supports.forEach(function (s, i) {
|
||||
var x = xAt(s.elevation_ft);
|
||||
var lvl = sLevels[i];
|
||||
el('polygon', { points: [x - 7, lineY + 14, x + 7, lineY + 14, x, lineY].join(' '), fill: '#1b1e23', stroke: '#1b1e23' }, svg);
|
||||
label(svg, x, lineY + 34 + lvl * 14, sTexts[i], { size: 10, fill: '#1b1e23' });
|
||||
});
|
||||
|
||||
// point loads, above the line -- pushed clear of the restraint band.
|
||||
var loads = geom.conc_loads || [];
|
||||
var lTexts = loads.map(function (ld) {
|
||||
return (ld.label || 'P') + ': D=' + (ld.dead_k == null ? '-' : ld.dead_k) +
|
||||
'k L=' + (ld.live_k == null ? '-' : ld.live_k) + 'k';
|
||||
});
|
||||
var lItems = loads.map(function (ld, i) {
|
||||
var x = xAt(ld.elevation_ft);
|
||||
var w = estWidth(lTexts[i], 10);
|
||||
return { x0: x - w / 2, x1: x + w / 2 };
|
||||
});
|
||||
var lLevels = stagger(lItems, 6);
|
||||
var loadTop = restraints.length ? 60 : 40;
|
||||
loads.forEach(function (ld, i) {
|
||||
var x = xAt(ld.elevation_ft);
|
||||
var lvl = lLevels[i];
|
||||
var topY = lineY - loadTop - lvl * 26;
|
||||
el('line', { x1: x, y1: topY, x2: x, y2: lineY - 4, stroke: '#2f6fed', 'stroke-width': 2, 'marker-end': null }, svg);
|
||||
el('polygon', { points: [x - 4, lineY - 10, x + 4, lineY - 10, x, lineY - 2].join(' '), fill: '#2f6fed' }, svg);
|
||||
label(svg, x, topY - 6, lTexts[i], { size: 10, fill: '#2f6fed' });
|
||||
});
|
||||
|
||||
// wind zones, distributed arrows over their extent, below loads / above line
|
||||
var zones = geom.wind_zones || [];
|
||||
zones.forEach(function (z, zi) {
|
||||
var x0 = xAt(Math.min(z.from_elev_ft, z.to_elev_ft));
|
||||
var x1 = xAt(Math.max(z.from_elev_ft, z.to_elev_ft));
|
||||
var y = marginTop + 10 + zi * 16;
|
||||
el('line', { x1: x0, y1: y, x2: x1, y2: y, stroke: '#16a34a', 'stroke-width': 1.5 }, svg);
|
||||
var steps = Math.max(2, Math.round((x1 - x0) / 40));
|
||||
for (var s = 0; s <= steps; s++) {
|
||||
var ax = x0 + (x1 - x0) * (s / steps);
|
||||
el('line', { x1: ax, y1: y - 8, x2: ax, y2: y, stroke: '#16a34a', 'stroke-width': 1 }, svg);
|
||||
}
|
||||
var pieces = [];
|
||||
if (z.pressure_psf !== null && z.pressure_psf !== undefined) pieces.push('p=' + z.pressure_psf + ' psf');
|
||||
if (z.suction_psf !== null && z.suction_psf !== undefined) pieces.push('s=' + z.suction_psf + ' psf');
|
||||
label(svg, (x0 + x1) / 2, y - 12, (z.label || 'Wind') + (pieces.length ? ' ' + pieces.join(', ') : '') +
|
||||
(z.flagged ? ' (reversed)' : ''), { size: 10, fill: '#16a34a' });
|
||||
});
|
||||
|
||||
if ((!restraints.length) && (!supports.length) && (!loads.length) && (!zones.length)) {
|
||||
label(svg, W / 2, lineY + 20, 'Nothing to draw yet -- add restraints, supports, loads or wind zones.', { size: 11, fill: '#6b7280' });
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ lifter/dunnage
|
||||
|
||||
function renderLifterDunnage(container, geom, aspect) {
|
||||
geom = geom || {};
|
||||
aspect = aspect || 0.26;
|
||||
var root = svgRoot(container, aspect);
|
||||
var svg = root.svg, W = root.W, H = root.H;
|
||||
|
||||
if (geom.length_ft === null || geom.length_ft === undefined ||
|
||||
geom.width_ft === null || geom.width_ft === undefined) {
|
||||
label(svg, W / 2, H / 2, 'Enter length and width to see lifter positions.', { size: 12, fill: '#6b7280' });
|
||||
return;
|
||||
}
|
||||
|
||||
var lengthFt = geom.length_ft, widthFt = geom.width_ft;
|
||||
var marginL = 40, marginR = 40, marginTop = 40, marginBottom = 30;
|
||||
var drawW = W - marginL - marginR;
|
||||
var drawH = H - marginTop - marginBottom;
|
||||
var scale = drawW / lengthFt;
|
||||
var rectH = Math.min(drawH, widthFt * scale);
|
||||
var rectY = marginTop + (drawH - rectH) / 2;
|
||||
|
||||
function xAt(ft) { return marginL + ft * scale; }
|
||||
function yAt(vertFt) { return rectY + rectH - vertFt * scale; }
|
||||
|
||||
el('rect', { x: marginL, y: rectY, width: drawW, height: rectH, fill: '#f8fafc', stroke: '#1b1e23', 'stroke-width': 1.5 }, svg);
|
||||
|
||||
(geom.openings || []).forEach(function (op) {
|
||||
if (!op.along || !op.vert) return;
|
||||
el('rect', {
|
||||
x: xAt(op.along[0]), y: yAt(op.vert[1]),
|
||||
width: Math.max(1, xAt(op.along[1]) - xAt(op.along[0])),
|
||||
height: Math.max(1, yAt(op.vert[0]) - yAt(op.vert[1])),
|
||||
fill: '#ffffff', stroke: '#94a3b8', 'stroke-dasharray': '3,2'
|
||||
}, svg);
|
||||
});
|
||||
|
||||
(geom.dunnage_positions_ft || []).forEach(function (d) {
|
||||
var x = xAt(d);
|
||||
el('line', { x1: x, y1: rectY - 6, x2: x, y2: rectY + rectH + 6, stroke: '#a855f7', 'stroke-dasharray': '5,3', 'stroke-width': 1.5 }, svg);
|
||||
});
|
||||
|
||||
(geom.face_lifters || []).forEach(function (fl) {
|
||||
var x = xAt(fl.x_ft), y = rectY + rectH * fl.frac;
|
||||
el('circle', { cx: x, cy: y, r: 6, fill: '#1b1e23' }, svg);
|
||||
});
|
||||
|
||||
// An 'end' lifter sits at a fixed x (a panel end) and is spread across
|
||||
// the width by `frac`; a 'side' lifter sits at a fixed y (one of the
|
||||
// long edges, named by `side`) and is spread along the length by
|
||||
// `x_ft`. Conflating the two (deciding y from `side` alone) collapsed
|
||||
// every 'end' lifter at the same end onto one point, since their
|
||||
// distinguishing coordinate -- `frac` -- was never read for them.
|
||||
(geom.edge_lifters || []).forEach(function (el2) {
|
||||
var x, y, horiz;
|
||||
if (el2.kind === 'end') {
|
||||
x = xAt(el2.x_ft);
|
||||
y = rectY + rectH * (el2.frac != null ? el2.frac : 0.5);
|
||||
horiz = true; // marker points along the length, toward the panel interior
|
||||
} else {
|
||||
x = xAt(el2.x_ft);
|
||||
if (el2.side === 'top') { y = rectY; }
|
||||
else if (el2.side === 'bottom') { y = rectY + rectH; }
|
||||
else { y = rectY + rectH * (el2.frac != null ? el2.frac : 0.5); }
|
||||
horiz = false;
|
||||
}
|
||||
var inward = el2.x_ft > (geom.length_ft || 0) / 2 ? -1 : 1;
|
||||
var downward = (el2.side === 'top') ? 1 : (el2.side === 'bottom' ? -1 : (y < rectY + rectH / 2 ? 1 : -1));
|
||||
var pts = horiz
|
||||
? [x, y - 6, x, y + 6, x + inward * 10, y].join(' ')
|
||||
: [x - 6, y, x + 6, y, x, y + downward * 10].join(' ');
|
||||
el('polygon', { points: pts, fill: el2.ambiguous ? '#f59e0b' : '#2f6fed' }, svg);
|
||||
});
|
||||
|
||||
if (geom.centroid && geom.centroid.x_ft !== null && geom.centroid.x_ft !== undefined &&
|
||||
geom.centroid.z_ft !== null && geom.centroid.z_ft !== undefined) {
|
||||
var cx = xAt(geom.centroid.x_ft), cy = yAt(geom.centroid.z_ft);
|
||||
el('line', { x1: cx - 8, y1: cy, x2: cx + 8, y2: cy, stroke: '#d33', 'stroke-width': 1.5 }, svg);
|
||||
el('line', { x1: cx, y1: cy - 8, x2: cx, y2: cy + 8, stroke: '#d33', 'stroke-width': 1.5 }, svg);
|
||||
el('circle', { cx: cx, cy: cy, r: 9, fill: 'none', stroke: '#d33', 'stroke-width': 1 }, svg);
|
||||
label(svg, cx, cy - 12, 'CG', { size: 10, fill: '#d33' });
|
||||
}
|
||||
|
||||
dimH(svg, marginL, marginL + drawW, marginTop - 14, ftIn(lengthFt));
|
||||
dimV(svg, rectY, rectY + rectH, marginL - 14, ftIn(widthFt), { left: true });
|
||||
}
|
||||
|
||||
// --------------------------------------------------- reinforcement.outline
|
||||
|
||||
// The Reinforcement page's PLAN view (issue #23 item 9i): the panel
|
||||
// rectangle (length x width), strand lines along the length at their
|
||||
// across-width x_in, the rail datum, and a per-group continuous dimension
|
||||
// chain for strands (from the rail) and bars (from their own basis) plus a
|
||||
// light hatched mesh sheet. No cut line here -- this is the plan half of
|
||||
// the outline+section pair, not Concrete Extents' draggable outline.
|
||||
function renderReinforcementOutline(container, geom, aspect) {
|
||||
geom = geom || {};
|
||||
aspect = aspect || 0.44;
|
||||
var root = svgRoot(container, aspect);
|
||||
var svg = root.svg, W = root.W, H = root.H;
|
||||
|
||||
var lengthFt = safeNum(geom.length_ft);
|
||||
var widthFt = safeNum(geom.width_ft);
|
||||
if (lengthFt === null || lengthFt <= 0 || widthFt === null || widthFt <= 0) {
|
||||
label(svg, W / 2, H / 2, 'Enter length and width to see the reinforcement plan.', { size: 12, fill: '#6b7280' });
|
||||
return;
|
||||
}
|
||||
var widthIn = widthFt * 12;
|
||||
|
||||
var marginL = 110, marginR = 28, marginTop = 40, marginBottom = 48;
|
||||
var drawW = W - marginL - marginR;
|
||||
var drawH = H - marginTop - marginBottom;
|
||||
var scale = drawW / lengthFt;
|
||||
var rectH = Math.min(drawH, widthFt * scale);
|
||||
var rectY = marginTop + (drawH - rectH) / 2;
|
||||
|
||||
function xAt(ft) { return marginL + ft * scale; }
|
||||
function yAtIn(inches) { return rectY + rectH - (inches / 12) * scale; }
|
||||
|
||||
el('rect', { x: marginL, y: rectY, width: drawW, height: rectH, fill: '#f4f6fb', stroke: '#1b1e23', 'stroke-width': 1.5 }, svg);
|
||||
|
||||
dimH(svg, marginL, marginL + drawW, marginTop - 14, ftIn(lengthFt));
|
||||
dimV(svg, rectY, rectY + rectH, marginL - 14, ftIn(widthFt), { left: true });
|
||||
|
||||
var rail = geom.rail;
|
||||
var strands = geom.strands || [];
|
||||
var bars = geom.bars || [];
|
||||
var mesh = geom.mesh;
|
||||
|
||||
// Strands -- thin lines along the length at their across-width position
|
||||
// x_in (inches from the panel's left edge). A row with no resolved x_in
|
||||
// spreads evenly across the width, the same fallback the section uses.
|
||||
strands.forEach(function (s) {
|
||||
var count = (s.count && s.count > 0) ? s.count : 1;
|
||||
var xs = s.x_in;
|
||||
if (xs && xs.length) {
|
||||
xs.forEach(function (x) {
|
||||
el('line', { x1: marginL, y1: yAtIn(x), x2: marginL + drawW, y2: yAtIn(x), stroke: '#1b1e23', 'stroke-width': 1.4 }, svg);
|
||||
});
|
||||
} else {
|
||||
for (var i = 0; i < count; i++) {
|
||||
var x = widthIn * (i + 1) / (count + 1);
|
||||
el('line', { x1: marginL, y1: yAtIn(x), x2: marginL + drawW, y2: yAtIn(x), stroke: '#94a3b8', 'stroke-width': 1, 'stroke-dasharray': '3,3' }, svg);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Rail -- a dashed line at the rail side, offset by rail.offset_in.
|
||||
if (rail && rail.location) {
|
||||
var railPos = rail.location === 'right' ? (widthIn - (rail.offset_in || 0)) : (rail.offset_in || 0);
|
||||
railPos = Math.max(0, Math.min(widthIn, railPos));
|
||||
var ry = yAtIn(railPos);
|
||||
el('line', { x1: marginL, y1: ry, x2: marginL + drawW, y2: ry, stroke: '#b45309', 'stroke-width': 1.6, 'stroke-dasharray': '6,4' }, svg);
|
||||
label(svg, marginL + drawW + 4, ry - 4, 'rail (' + rail.location + ')', { anchor: 'start', size: 9, fill: '#b45309' });
|
||||
}
|
||||
|
||||
// Strand group chain -- vertical, from the rail outward, one cell per
|
||||
// chain_in length, on its own column left of the rectangle.
|
||||
var chainRow = null;
|
||||
for (var i = 0; i < strands.length; i++) {
|
||||
if (strands[i] && strands[i].chain_in && strands[i].chain_in.length &&
|
||||
strands[i].x_in && strands[i].x_in.length) { chainRow = strands[i]; break; }
|
||||
}
|
||||
var strandChainX = marginL - 42;
|
||||
if (chainRow && rail && rail.location) {
|
||||
label(svg, strandChainX, rectY - 6, 'strands', { anchor: 'middle', size: 10, fill: '#1b1e23' });
|
||||
var cells = chainCellsFromRail(chainRow.x_in, chainRow.chain_in,
|
||||
rail.location === 'right' ? (widthIn - (rail.offset_in || 0)) : (rail.offset_in || 0));
|
||||
cells.forEach(function (c) {
|
||||
dimV(svg, yAtIn(c.hi), yAtIn(c.lo), strandChainX, inch(c.label), { size: 9 });
|
||||
});
|
||||
}
|
||||
|
||||
// Bars -- longitudinal bars run along the length (horizontal lines at
|
||||
// their depth_in); transverse bars run across the width (vertical lines
|
||||
// at their from_ft station).
|
||||
var longitBars = [], transBars = [];
|
||||
bars.forEach(function (b) {
|
||||
if (b.depth_in === null || b.depth_in === undefined) return;
|
||||
if ((b.direction || '').toLowerCase().indexOf('trans') === 0) transBars.push(b);
|
||||
else longitBars.push(b);
|
||||
});
|
||||
longitBars.forEach(function (b) {
|
||||
var y = yAtIn(b.depth_in);
|
||||
var x0 = (b.from_ft !== null && b.from_ft !== undefined) ? xAt(b.from_ft) : marginL;
|
||||
var x1 = (b.to_ft !== null && b.to_ft !== undefined) ? xAt(b.to_ft) : marginL + drawW;
|
||||
el('line', { x1: x0, y1: y, x2: x1, y2: y, stroke: '#2f6fed', 'stroke-width': 1.4 }, svg);
|
||||
});
|
||||
transBars.forEach(function (b) {
|
||||
var xv = (b.from_ft !== null && b.from_ft !== undefined) ? b.from_ft
|
||||
: ((b.to_ft !== null && b.to_ft !== undefined) ? b.to_ft : lengthFt / 2);
|
||||
el('line', { x1: xAt(xv), y1: rectY, x2: xAt(xv), y2: rectY + rectH, stroke: '#2f6fed', 'stroke-width': 1.2, 'stroke-dasharray': '5,3' }, svg);
|
||||
});
|
||||
|
||||
// Bar group chain -- from each bar's own reference. Longitudinal bars
|
||||
// measure their depth up from the panel's bottom edge (a vertical chain
|
||||
// on its own column); transverse bars measure their station along the
|
||||
// length (a horizontal chain stacked below the rectangle).
|
||||
var barChainX = marginL - 74;
|
||||
if (longitBars.length) {
|
||||
label(svg, barChainX, rectY - 6, 'bars', { anchor: 'middle', size: 10, fill: '#2f6fed' });
|
||||
}
|
||||
longitBars.forEach(function (b) {
|
||||
dimV(svg, yAtIn(b.depth_in), rectY + rectH, barChainX, (b.size || 'bar') + ' longit.', { size: 9 });
|
||||
});
|
||||
transBars.forEach(function (b, ti) {
|
||||
var xv = (b.from_ft !== null && b.from_ft !== undefined) ? b.from_ft
|
||||
: ((b.to_ft !== null && b.to_ft !== undefined) ? b.to_ft : lengthFt / 2);
|
||||
var by = rectY + rectH + 20 + ti * 16;
|
||||
if (ti === 0 && !longitBars.length) {
|
||||
label(svg, marginL, by - 4, 'bars', { anchor: 'start', size: 10, fill: '#2f6fed' });
|
||||
}
|
||||
dimH(svg, marginL, xAt(xv), by, (b.size || 'bar') + ' transv.', { size: 9, below: true });
|
||||
});
|
||||
|
||||
// Mesh -- a light hatched sheet over the panel, labelled once.
|
||||
if (mesh && mesh.designation) {
|
||||
var pid = 'reinout-hatch';
|
||||
var defs = el('defs', {}, svg);
|
||||
var pat = el('pattern', { id: pid, patternUnits: 'userSpaceOnUse', width: 7, height: 7 }, defs);
|
||||
el('path', { d: 'M0 7 L7 0', stroke: '#94a3b8', 'stroke-width': 0.8 }, pat);
|
||||
el('rect', { x: marginL, y: rectY, width: drawW, height: rectH, fill: 'url(#' + pid + ')', opacity: 0.22 }, svg);
|
||||
var meshText = 'mesh: ' + mesh.designation;
|
||||
if (mesh.layers !== null && mesh.layers !== undefined) meshText += ' \u00d7 ' + mesh.layers;
|
||||
if (mesh.depth_in !== null && mesh.depth_in !== undefined) meshText += ' @ ' + inch(mesh.depth_in);
|
||||
label(svg, marginL + 6, rectY + 14, meshText, { anchor: 'start', size: 9, fill: '#334155' });
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------- reinforcement.section
|
||||
|
||||
function renderReinforcementSection(container, geom, aspect) {
|
||||
geom = geom || {};
|
||||
aspect = aspect || 0.28;
|
||||
var root = svgRoot(container, aspect);
|
||||
var svg = root.svg, W = root.W, H = root.H;
|
||||
|
||||
var widthFt = geom.width_ft || FALLBACK_WIDTH_FT;
|
||||
var thicknessIn = geom.thickness_in || 8;
|
||||
|
||||
var marginL = 60, marginR = 20, marginTop = 24, marginBottom = 128;
|
||||
var drawW = W - marginL - marginR;
|
||||
var drawH = H - marginTop - marginBottom;
|
||||
var scaleX = drawW / widthFt;
|
||||
var exaggerate = Math.max(1, Math.min(6, drawH / ((thicknessIn / 12) * scaleX || 1)));
|
||||
var rectH = (thicknessIn / 12) * scaleX * exaggerate;
|
||||
if (rectH > drawH) { exaggerate *= drawH / rectH; rectH = drawH; }
|
||||
var rectY = marginTop + (drawH - rectH) / 2;
|
||||
|
||||
function xAt(ft) { return marginL + ft * scaleX; }
|
||||
function xAtIn(inches) { return marginL + (inches / 12) * scaleX; }
|
||||
function yAtDepth(depthIn) { return rectY + (depthIn / 12) * scaleX * exaggerate; }
|
||||
|
||||
el('rect', { x: marginL, y: rectY, width: drawW, height: rectH, fill: '#f4f6fb', stroke: '#1b1e23', 'stroke-width': 1.2 }, svg);
|
||||
|
||||
// strands: filled circles, x from strand_x_in flattened in strand order
|
||||
var xs = geom.strand_x_in;
|
||||
var flatIdx = 0;
|
||||
var strandItems = [];
|
||||
(geom.strands || []).forEach(function (s) {
|
||||
for (var i = 0; i < (s.count || 0); i++) {
|
||||
var xIn;
|
||||
if (xs && !geom.used_fallback_strand_x && xs.length > flatIdx) {
|
||||
xIn = xs[flatIdx];
|
||||
} else {
|
||||
xIn = null; // filled in after we know the total count
|
||||
}
|
||||
strandItems.push({ xIn: xIn, depth: s.depth_in, dia: s.dia_in, id: s.id });
|
||||
flatIdx++;
|
||||
}
|
||||
});
|
||||
if (geom.used_fallback_strand_x || !xs) {
|
||||
var n = strandItems.length;
|
||||
strandItems.forEach(function (it, i) {
|
||||
it.xIn = n > 1 ? (widthFt * 12) * (i + 0.5) / n : (widthFt * 12) / 2;
|
||||
});
|
||||
}
|
||||
strandItems.forEach(function (it) {
|
||||
var x = xAt(it.xIn / 12), y = yAtDepth(it.depth);
|
||||
el('circle', { cx: x, cy: y, r: 5, fill: '#1b1e23' }, svg);
|
||||
});
|
||||
if (geom.used_fallback_strand_x) {
|
||||
label(svg, marginL, rectY + rectH + 112, '(strand positions spread evenly -- no explicit across-width layout entered)', { anchor: 'start', size: 10, fill: '#6b7280' });
|
||||
}
|
||||
|
||||
// mild bars -- owner ruling 2026-09-05, matching `pa_sketch_reinf.py`'s
|
||||
// own (already-correct) WPF picture: a LONGITUDINAL bar runs along the
|
||||
// panel's LENGTH, so this cross-section (cut across the WIDTH) sees it
|
||||
// END-ON -- one open circle per bar, at its own across-width position
|
||||
// (the row's own `spacing_in` pitch when it is a real number, else
|
||||
// spread evenly). A TRANSVERSE bar runs ACROSS the width, so the same
|
||||
// cut sees it ALONG its own length -- one dashed line spanning the
|
||||
// full section width at its depth, labelled once (e.g. '#4 @ 19"
|
||||
// transv.'), never a row of circles the way this used to draw it
|
||||
// (backwards from the physical picture -- see the fix's own note).
|
||||
// Two bars whose depths sit within a fraction of an inch of each other
|
||||
// (common: a top mat and a bottom mat of two different sizes) can land
|
||||
// within a few px once exaggerated -- cascade the label sideways in
|
||||
// that case, computed on DEPTH-SORTED groups (never original array
|
||||
// order, which can interleave the two directions and miss an adjacent
|
||||
// pair, or falsely flag two non-adjacent ones) and kept separate per
|
||||
// direction since they print on opposite sides of the strip.
|
||||
var allBars = geom.bars || [];
|
||||
function isTransverse(b) { return (b.direction || '').toLowerCase().indexOf('trans') === 0; }
|
||||
function cascadeOffsets(list) {
|
||||
var withY = list.map(function (b) { return { id: b.id, y: yAtDepth(b.depth_in) }; })
|
||||
.sort(function (a, b) { return a.y - b.y; });
|
||||
var offsets = {}; var lastY = null, c = 0;
|
||||
withY.forEach(function (it) {
|
||||
c = (lastY !== null && Math.abs(it.y - lastY) < 11) ? c + 1 : 0;
|
||||
lastY = it.y;
|
||||
offsets[it.id] = c * 34;
|
||||
});
|
||||
return offsets;
|
||||
}
|
||||
var transverseOffsets = cascadeOffsets(allBars.filter(isTransverse));
|
||||
var longitOffsets = cascadeOffsets(allBars.filter(function (b) { return !isTransverse(b); }));
|
||||
|
||||
allBars.forEach(function (b) {
|
||||
var y = yAtDepth(b.depth_in);
|
||||
if (isTransverse(b)) {
|
||||
el('line', { x1: marginL + 4, y1: y, x2: marginL + drawW - 4, y2: y, stroke: '#2f6fed', 'stroke-width': 1, 'stroke-dasharray': '6,3' }, svg);
|
||||
var spacingText = b.spacing_in ? ' @ ' + Math.round(b.spacing_in * 100) / 100 + '"' : '';
|
||||
label(svg, marginL + drawW - 6 - (transverseOffsets[b.id] || 0), y - 4, (b.size || '') + spacingText + ' transv.', { anchor: 'end', size: 9, fill: '#2f6fed' });
|
||||
} else {
|
||||
var count = b.count && b.count > 0 ? b.count : 1;
|
||||
var dotCount = Math.min(count, 20);
|
||||
var xs = [];
|
||||
var innerW = drawW - 8;
|
||||
if (b.spacing_in && b.spacing_in > 0) {
|
||||
var pitchPx = (b.spacing_in / 12) * scaleX;
|
||||
for (var x = marginL + 4 + pitchPx; x < marginL + 4 + innerW && xs.length < dotCount; x += pitchPx) xs.push(x);
|
||||
}
|
||||
if (!xs.length) {
|
||||
for (var i = 0; i < dotCount; i++) xs.push(marginL + 4 + innerW * (i + 1) / (dotCount + 1));
|
||||
}
|
||||
xs.forEach(function (cx) {
|
||||
el('circle', { cx: cx, cy: y, r: 4, fill: '#fff', stroke: '#2f6fed', 'stroke-width': 1.4 }, svg);
|
||||
});
|
||||
label(svg, marginL + 4 + (longitOffsets[b.id] || 0), y - 6, (b.size || '') + ' longit.', { anchor: 'start', size: 9, fill: '#2f6fed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Per-group dimension chains along the section's width (issue #23 item
|
||||
// 9i): strands from the rail, bars from their own basis, stacked on
|
||||
// their own rows below the overall width dimension so they never overlap.
|
||||
var widthInSec = widthFt * 12;
|
||||
var chainRow = null;
|
||||
for (var ci = 0; ci < (geom.strands || []).length; ci++) {
|
||||
var sr = geom.strands[ci];
|
||||
if (sr && sr.chain_in && sr.chain_in.length && sr.x_in && sr.x_in.length) {
|
||||
chainRow = sr;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (chainRow) {
|
||||
var cxs = chainRow.x_in, ccs = chainRow.chain_in;
|
||||
var railPos = (cxs[0] <= cxs[cxs.length - 1])
|
||||
? (cxs[0] - ccs[0]) : (cxs[0] + ccs[0]);
|
||||
railPos = Math.max(0, Math.min(widthInSec, railPos));
|
||||
label(svg, marginL, rectY + rectH + 44, 'strands', { anchor: 'start', size: 10, fill: '#1b1e23' });
|
||||
chainCellsFromRail(cxs, ccs, railPos).forEach(function (c) {
|
||||
dimH(svg, xAtIn(c.lo), xAtIn(c.hi), rectY + rectH + 48, inch(c.label), { size: 9, below: true });
|
||||
});
|
||||
}
|
||||
|
||||
var longitBarRows = allBars.filter(function (b) { return !isTransverse(b); });
|
||||
var transBarRows = allBars.filter(isTransverse);
|
||||
var barChainY = rectY + rectH + 74;
|
||||
var barRow = 0;
|
||||
if (longitBarRows.length || transBarRows.length) {
|
||||
label(svg, marginL, barChainY, 'bars', { anchor: 'start', size: 10, fill: '#2f6fed' });
|
||||
}
|
||||
longitBarRows.forEach(function (b) {
|
||||
if (!b.spacing_in || b.spacing_in <= 0) return;
|
||||
var x1 = Math.min(marginL + drawW, xAtIn(b.spacing_in));
|
||||
dimH(svg, marginL, x1, barChainY + 4 + barRow * 16, (b.size || 'bar') + ' longit.', { size: 9, below: true });
|
||||
barRow += 1;
|
||||
});
|
||||
transBarRows.forEach(function (b) {
|
||||
dimH(svg, marginL, marginL + drawW, barChainY + 4 + barRow * 16, (b.size || 'bar') + ' transv.', { size: 9, below: true });
|
||||
barRow += 1;
|
||||
});
|
||||
|
||||
dimH(svg, marginL, marginL + drawW, rectY + rectH + 16, ftIn(widthFt), { below: true });
|
||||
dimV(svg, rectY, rectY + rectH, marginL - 14, inch(thicknessIn), { left: true });
|
||||
if (exaggerate > 1.05) {
|
||||
label(svg, marginL, marginTop - 8, '(depth exaggerated ' + exaggerate.toFixed(1) + '×)', { anchor: 'start', size: 10, fill: '#6b7280' });
|
||||
}
|
||||
label(svg, marginL + drawW, marginTop - 8, '● strand ○ bar', { anchor: 'end', size: 10, fill: '#6b7280' });
|
||||
}
|
||||
|
||||
window.Draw = {
|
||||
sectionAt: sectionAt,
|
||||
renderOutline: renderOutline,
|
||||
renderSection: renderSection,
|
||||
renderFreebody: renderFreebody,
|
||||
renderLifterDunnage: renderLifterDunnage,
|
||||
renderReinforcementOutline: renderReinforcementOutline,
|
||||
renderReinforcementSection: renderReinforcementSection,
|
||||
_stagger: stagger,
|
||||
svgRoot: svgRoot,
|
||||
label: label,
|
||||
dimH: dimH,
|
||||
dimV: dimV,
|
||||
ftIn: ftIn,
|
||||
inch: inch,
|
||||
el: el,
|
||||
clear: clear,
|
||||
safeNum: safeNum,
|
||||
clamp: clamp,
|
||||
mergeRanges: mergeRanges,
|
||||
estWidth: estWidth
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,19 @@
|
||||
<meta charset="utf-8">
|
||||
<title>Panel Analysis</title>
|
||||
<link rel="stylesheet" href="willtools.css">
|
||||
<div class="app-shell">
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-title">Panel Analysis<span id="pa-mark" class="pa-mark"></span></div>
|
||||
<div class="toolbar-spacer"></div>
|
||||
<button id="btn-run" class="btn btn-primary">Run</button>
|
||||
<button id="btn-close" class="btn">Close</button>
|
||||
</div>
|
||||
<div id="banner" class="banner" hidden></div>
|
||||
<div id="spec-problems" class="spec-problems" hidden></div>
|
||||
<div class="tabs" id="tabs"></div>
|
||||
<div class="pages" id="pages"></div>
|
||||
</div>
|
||||
<pre id="errors" hidden></pre>
|
||||
<script src="units.js"></script>
|
||||
<script src="draw.js"></script>
|
||||
<script src="app.js"></script>
|
||||
@@ -0,0 +1,246 @@
|
||||
// Feet-inches formatting and parsing -- the browser twin of
|
||||
// `panellift/src/panellift/units.py`. Same rules, same rounding, same
|
||||
// accepted input forms; kept as a byte-for-byte port on purpose so a value
|
||||
// typed on the page and the same value printed by the CPython report never
|
||||
// disagree. See units.py's own module docstring for the owner's ruling this
|
||||
// exists to satisfy (2026-09-05: every length on any front-end or results
|
||||
// page is feet-inches, rounded to the nearest 1/8", never a decimal).
|
||||
//
|
||||
// Plain ES2017, no modules -- loaded with a bare <script src="units.js">
|
||||
// the way the rest of this page's vanilla JS is (see EDIT_JS in
|
||||
// tools/panelanalysis.py). Exposes window.Units = {fmtFtIn, fmtIn,
|
||||
// parseLength, parseLengthIn}.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function gcd(a, b) {
|
||||
a = Math.abs(a);
|
||||
b = Math.abs(b);
|
||||
while (b) {
|
||||
var t = b;
|
||||
b = a % b;
|
||||
a = t;
|
||||
}
|
||||
return a || 1;
|
||||
}
|
||||
|
||||
// A reduced "n/d" fraction string, mirroring Python's `fractions.Fraction`
|
||||
// reduction (e.g. 4/8 -> "1/2").
|
||||
function fracString(num, den) {
|
||||
var g = gcd(num, den);
|
||||
return (num / g) + '/' + (den / g);
|
||||
}
|
||||
|
||||
// Decimal feet -> "8'-4 1/2\"", rounded to the nearest 1/precision inch.
|
||||
// null/undefined/NaN -> '-'. Negative values keep the sign in front of the
|
||||
// whole string ("-0'-6\""), never inside it. A remainder that rounds up to
|
||||
// a full inch carries into the inch count, and 12 inches carries into the
|
||||
// foot count.
|
||||
function fmtFtIn(feet, precision) {
|
||||
precision = precision || 8;
|
||||
if (feet === null || feet === undefined || isNaN(feet)) {
|
||||
return '-';
|
||||
}
|
||||
var sign = feet < 0 ? '-' : '';
|
||||
var value = Math.abs(feet);
|
||||
var wholeFt = Math.floor(value);
|
||||
// work in units of 1/precision inch so the carry logic is exact integer
|
||||
// arithmetic, never a float comparison against 12.0 or 1.0.
|
||||
var unitsTotal = Math.round((value - wholeFt) * 12.0 * precision);
|
||||
var inch = Math.floor(unitsTotal / precision);
|
||||
var rem = unitsTotal - inch * precision;
|
||||
if (inch >= 12) {
|
||||
wholeFt += Math.floor(inch / 12);
|
||||
inch = inch % 12;
|
||||
}
|
||||
var out = wholeFt + "'-" + inch;
|
||||
if (rem) {
|
||||
out += ' ' + fracString(rem, precision);
|
||||
}
|
||||
return sign + out + '"';
|
||||
}
|
||||
|
||||
// Decimal inches -> "6 3/8\"" -- for thicknesses, depths, offsets,
|
||||
// diameters and anything else already spoken of in inches, never feet.
|
||||
// null/undefined/NaN -> '-'.
|
||||
function fmtIn(inches, precision) {
|
||||
precision = precision || 8;
|
||||
if (inches === null || inches === undefined || isNaN(inches)) {
|
||||
return '-';
|
||||
}
|
||||
var sign = inches < 0 ? '-' : '';
|
||||
var value = Math.abs(inches);
|
||||
var wholeIn = Math.floor(value);
|
||||
var units = Math.round((value - wholeIn) * precision);
|
||||
if (units >= precision) {
|
||||
wholeIn += 1;
|
||||
units = 0;
|
||||
}
|
||||
var out = '' + wholeIn;
|
||||
if (units) {
|
||||
out += ' ' + fracString(units, precision);
|
||||
}
|
||||
return sign + out + '"';
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ parsing
|
||||
|
||||
// A leading sign, held separately from the feet/inch markers below because
|
||||
// a hyphen INSIDE the string (the "8'-4" separator, not a minus) must
|
||||
// never be read as negation -- only a hyphen before the first digit is a
|
||||
// sign.
|
||||
var SIGN_RE = /^-\s*/;
|
||||
|
||||
// "8", optionally decimal, followed by a foot mark, followed by whatever
|
||||
// is left (which may be empty, or an inch remainder with its own quote).
|
||||
var FEET_RE = /^(\d+(?:\.\d+)?)\s*'\s*([\s\S]*)$/;
|
||||
|
||||
// A hyphen used as the visual separator between a whole inch and a
|
||||
// fraction ("4-1/2") rather than as a sign -- only recognised with a digit
|
||||
// already on its left, so a leading "-" (the "8'-6\"" separator right
|
||||
// after the foot mark) is stripped as its own case, not caught here.
|
||||
var HYPHEN_FRAC_RE = /(\d)\s*-\s*(\d+\s*\/\s*\d+)/g;
|
||||
|
||||
var MIXED_RE = /^(\d+)\s+(\d+)\s*\/\s*(\d+)$/;
|
||||
var FRAC_RE = /^(\d+)\s*\/\s*(\d+)$/;
|
||||
var DEC_RE = /^(\d+(?:\.\d+)?)$/;
|
||||
|
||||
function badLength(orig) {
|
||||
return new Error('not a length: ' + orig);
|
||||
}
|
||||
|
||||
// num/den, or badLength(orig) for a 0 denominator -- a typed "1/0" is bad
|
||||
// input, not a licence to silently hand back Infinity/NaN the way plain
|
||||
// JS division would.
|
||||
function safeDiv(num, den, orig) {
|
||||
if (den === 0) {
|
||||
throw badLength(orig);
|
||||
}
|
||||
return num / den;
|
||||
}
|
||||
|
||||
function normalizeQuotes(s) {
|
||||
return s.replace(/’/g, "'").replace(/‘/g, "'")
|
||||
.replace(/”/g, '"').replace(/“/g, '"');
|
||||
}
|
||||
|
||||
// Parse what follows a foot mark (or the whole string, for a bare inch
|
||||
// value): an optional leading '-' separator, a whole number and/or a
|
||||
// fraction, an optional trailing '"'. Returns inches as a number.
|
||||
function parseInchRemainder(rest, orig, allowEmpty) {
|
||||
rest = rest.trim();
|
||||
if (rest.slice(-1) === '"') {
|
||||
rest = rest.slice(0, -1).trim();
|
||||
}
|
||||
if (rest.slice(0, 1) === '-') {
|
||||
// the "8'-6" separator, not a sign -- the sign (if any) was already
|
||||
// consumed before the foot mark was even found.
|
||||
rest = rest.slice(1).trim();
|
||||
}
|
||||
if (rest === '') {
|
||||
if (allowEmpty) {
|
||||
return 0.0;
|
||||
}
|
||||
throw badLength(orig);
|
||||
}
|
||||
rest = rest.replace(HYPHEN_FRAC_RE, '$1 $2');
|
||||
var m = MIXED_RE.exec(rest);
|
||||
if (m) {
|
||||
return parseFloat(m[1]) +
|
||||
safeDiv(parseFloat(m[2]), parseFloat(m[3]), orig);
|
||||
}
|
||||
m = FRAC_RE.exec(rest);
|
||||
if (m) {
|
||||
return safeDiv(parseFloat(m[1]), parseFloat(m[2]), orig);
|
||||
}
|
||||
m = DEC_RE.exec(rest);
|
||||
if (m) {
|
||||
return parseFloat(m[1]);
|
||||
}
|
||||
throw badLength(orig);
|
||||
}
|
||||
|
||||
// No `'` and no trailing `"` anywhere -- a plain number, or a bare
|
||||
// fraction / mixed number, read in `defaultUnit`.
|
||||
function parseBareNumber(s, orig, defaultUnit) {
|
||||
var value;
|
||||
var m = DEC_RE.exec(s);
|
||||
if (m) {
|
||||
value = parseFloat(m[1]);
|
||||
} else {
|
||||
var s2 = s.replace(HYPHEN_FRAC_RE, '$1 $2');
|
||||
m = MIXED_RE.exec(s2);
|
||||
if (m) {
|
||||
value = parseFloat(m[1]) +
|
||||
safeDiv(parseFloat(m[2]), parseFloat(m[3]), orig);
|
||||
} else {
|
||||
m = FRAC_RE.exec(s2);
|
||||
if (m) {
|
||||
value = safeDiv(parseFloat(m[1]), parseFloat(m[2]), orig);
|
||||
} else {
|
||||
throw badLength(orig);
|
||||
}
|
||||
}
|
||||
}
|
||||
return defaultUnit === 'ft' ? value * 12.0 : value;
|
||||
}
|
||||
|
||||
// The shared parser. Returns inches, given how a bare unmarked number
|
||||
// should be read ('ft' or 'in').
|
||||
function parseCoreIn(text, defaultUnit) {
|
||||
if (text === null || text === undefined) {
|
||||
throw badLength(text);
|
||||
}
|
||||
if (typeof text === 'number') {
|
||||
return defaultUnit === 'ft' ? text * 12.0 : text;
|
||||
}
|
||||
var orig = String(text);
|
||||
var s = orig.trim();
|
||||
if (s === '') {
|
||||
throw badLength(orig);
|
||||
}
|
||||
s = normalizeQuotes(s);
|
||||
|
||||
var negative = false;
|
||||
var signMatch = SIGN_RE.exec(s);
|
||||
if (signMatch) {
|
||||
negative = true;
|
||||
s = s.slice(signMatch[0].length).trim();
|
||||
}
|
||||
if (s === '') {
|
||||
throw badLength(orig);
|
||||
}
|
||||
|
||||
var totalIn;
|
||||
var feetMatch = FEET_RE.exec(s);
|
||||
if (feetMatch) {
|
||||
var feet = parseFloat(feetMatch[1]);
|
||||
totalIn = feet * 12.0 + parseInchRemainder(feetMatch[2], orig, true);
|
||||
} else if (s.slice(-1) === '"') {
|
||||
totalIn = parseInchRemainder(s, orig, false);
|
||||
} else {
|
||||
totalIn = parseBareNumber(s, orig, defaultUnit);
|
||||
}
|
||||
return negative ? -totalIn : totalIn;
|
||||
}
|
||||
|
||||
// Parse any of the accepted forms and return inches. A bare number with
|
||||
// no `'` or `"` anywhere is read in `defaultUnit` ('in' by default here).
|
||||
function parseLengthIn(text, defaultUnit) {
|
||||
return parseCoreIn(text, defaultUnit || 'in');
|
||||
}
|
||||
|
||||
// Parse any of the accepted forms and return decimal feet. A bare number
|
||||
// with no `'` or `"` anywhere is read in `defaultUnit` ('ft' by default,
|
||||
// per the owner's ruling).
|
||||
function parseLength(text, defaultUnit) {
|
||||
return parseCoreIn(text, defaultUnit || 'ft') / 12.0;
|
||||
}
|
||||
|
||||
window.Units = {
|
||||
fmtFtIn: fmtFtIn,
|
||||
fmtIn: fmtIn,
|
||||
parseLength: parseLength,
|
||||
parseLengthIn: parseLengthIn
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,475 @@
|
||||
/* WillTools shared front-end look -- Panel Analysis's new 前端 today, the
|
||||
shell every future tool (Beam Analysis, Connection Analysis) and every
|
||||
results page adopts next. Reference: Eriksson Wall's own input dialogs --
|
||||
plain engineering software, dense but tidy. Light theme, one accent
|
||||
colour, system font stack, no build step, no framework. */
|
||||
|
||||
:root {
|
||||
--bg: #f3f4f6;
|
||||
--panel-bg: #ffffff;
|
||||
--border: #d7dae0;
|
||||
--border-strong: #b7bcc6;
|
||||
--text: #1b1e23;
|
||||
--text-muted: #6b7280;
|
||||
--text-faint: #9aa1ac;
|
||||
--accent: #2f6fed;
|
||||
--accent-dark: #1f56c9;
|
||||
--accent-contrast: #ffffff;
|
||||
--error: #d33333;
|
||||
--error-bg: #fdecec;
|
||||
--warn: #b45309;
|
||||
--warn-bg: #fff7ed;
|
||||
--row-stripe: #fafbfc;
|
||||
--row-hover: #eef2ff;
|
||||
--radius: 4px;
|
||||
--focus-ring: 0 0 0 3px rgba(47, 111, 237, 0.28);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: "Segoe UI", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
button, input, select, textarea {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- layout */
|
||||
|
||||
.app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
background: var(--panel-bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.toolbar-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pa-mark {
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.toolbar-spacer { flex: 1 1 auto; }
|
||||
|
||||
.btn {
|
||||
appearance: none;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
border-radius: var(--radius);
|
||||
padding: 6px 14px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn:hover { background: #f4f5f7; }
|
||||
.btn:focus-visible { outline: none; box-shadow: var(--focus-ring); }
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent-dark);
|
||||
color: var(--accent-contrast);
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent-dark); }
|
||||
|
||||
.banner {
|
||||
flex: 0 0 auto;
|
||||
background: var(--error-bg);
|
||||
color: var(--error);
|
||||
border-bottom: 1px solid #f3c2c2;
|
||||
padding: 8px 14px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.banner[hidden] { display: none; }
|
||||
/* Two faces, never alike: amber = input still needed (the user's to fill),
|
||||
red = something broke (a bug). The bare `.banner` is red so an
|
||||
unlabelled failure is loud, never calm. */
|
||||
.banner.banner--input {
|
||||
background: var(--warn-bg);
|
||||
color: var(--warn);
|
||||
border-bottom-color: #f2d9b6;
|
||||
}
|
||||
.banner.banner--crash {
|
||||
background: var(--error-bg);
|
||||
color: var(--error);
|
||||
border-bottom-color: #f3c2c2;
|
||||
}
|
||||
|
||||
.spec-problems {
|
||||
flex: 0 0 auto;
|
||||
background: var(--warn-bg);
|
||||
color: var(--warn);
|
||||
border-bottom: 1px solid #f2d9b6;
|
||||
padding: 6px 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: var(--panel-bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 10px;
|
||||
flex: 0 0 auto;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tab {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-bottom: 3px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tab:hover { color: var(--text); }
|
||||
.tab.active {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.pages {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.page-panel {
|
||||
display: none;
|
||||
padding: 18px 20px 60px;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.page-panel.active { display: block; }
|
||||
|
||||
/* -------------------------------------------------------------- headings */
|
||||
|
||||
h1.pa-h, h2.pa-h, h3.pa-h {
|
||||
margin: 18px 0 8px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
h1.pa-h { font-size: 17px; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
|
||||
h2.pa-h { font-size: 14.5px; margin-top: 20px; }
|
||||
h3.pa-h { font-size: 13.5px; color: var(--text-muted); }
|
||||
|
||||
hr.pa-rule {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
.pa-space { display: block; }
|
||||
|
||||
/* -------------------------------------------------------------- columns */
|
||||
|
||||
.pa-columns {
|
||||
display: flex;
|
||||
gap: 22px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.pa-col {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- field */
|
||||
|
||||
.field {
|
||||
margin-bottom: 10px;
|
||||
max-width: 420px;
|
||||
}
|
||||
.field.full-width { max-width: none; }
|
||||
.field-label-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.field-label {
|
||||
font-size: 12.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.field.required .field-label::after {
|
||||
content: " *";
|
||||
color: var(--error);
|
||||
}
|
||||
.field-symbol {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.field-input {
|
||||
width: 100%;
|
||||
padding: 5px 7px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
}
|
||||
.field-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
textarea.field-input { resize: vertical; min-height: 44px; }
|
||||
.field-input.has-error {
|
||||
border-color: var(--error);
|
||||
background: var(--error-bg);
|
||||
}
|
||||
.field-error-msg {
|
||||
color: var(--error);
|
||||
font-size: 11px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.field-check-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.field-check-row .field-label { margin: 0; }
|
||||
|
||||
/* "not used by the solve" (docs/pa-web-spec.md, unused_by_solve) --
|
||||
collected only; muted so it can never pass for a live input */
|
||||
.unused-by-solve .field-input,
|
||||
tr.unused-by-solve .field-input { opacity: 0.55; }
|
||||
.unused-by-solve .field-label,
|
||||
tr.unused-by-solve td:first-child { color: var(--text-muted); }
|
||||
.unused-tag {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 0 5px;
|
||||
border: 1px dashed var(--text-faint);
|
||||
border-radius: 3px;
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
line-height: 15px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* row-table empty state + table-level error (issue #22) */
|
||||
.table-empty-state {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.editable-table-wrap.has-error table.pa-editable {
|
||||
outline: 2px solid var(--error);
|
||||
background: var(--error-bg);
|
||||
}
|
||||
.table-error-msg {
|
||||
color: var(--error);
|
||||
font-size: 11px;
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
/* provenance */
|
||||
.prov-icon {
|
||||
display: inline-block;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
line-height: 13px;
|
||||
text-align: center;
|
||||
border-radius: 50%;
|
||||
font-size: 10px;
|
||||
color: var(--accent);
|
||||
cursor: help;
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ sentence */
|
||||
|
||||
.sentence { margin: 6px 0; color: var(--text); }
|
||||
.sentence.emph { font-weight: 600; }
|
||||
|
||||
/* ------------------------------------------------------------------ kv */
|
||||
|
||||
table.kv-table { border-collapse: collapse; margin: 6px 0 12px; }
|
||||
table.kv-table td { padding: 3px 10px 3px 0; vertical-align: top; font-size: 12.5px; }
|
||||
table.kv-table td:first-child { color: var(--text-muted); white-space: nowrap; }
|
||||
|
||||
/* --------------------------------------------------------------- tables */
|
||||
|
||||
table.pa-table, table.pa-editable, table.pa-matrix, table.pa-lc {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 6px 0 14px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
table.pa-table th, table.pa-table td,
|
||||
table.pa-editable th, table.pa-editable td,
|
||||
table.pa-matrix th, table.pa-matrix td,
|
||||
table.pa-lc th, table.pa-lc td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 4px 6px;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
table.pa-table th, table.pa-editable th, table.pa-matrix th, table.pa-lc th {
|
||||
background: #eef1f5;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
table.pa-table tbody tr:nth-child(even),
|
||||
table.pa-editable tbody tr:nth-child(even) {
|
||||
background: var(--row-stripe);
|
||||
}
|
||||
table.pa-table td input.field-input,
|
||||
table.pa-editable td input.field-input,
|
||||
table.pa-editable td select.field-input,
|
||||
table.pa-matrix td input.field-input,
|
||||
table.pa-matrix td select.field-input,
|
||||
table.pa-lc td input.field-input {
|
||||
padding: 3px 5px;
|
||||
min-width: 44px;
|
||||
}
|
||||
table.pa-matrix td.na-cell {
|
||||
text-align: center;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
table.pa-matrix th, table.pa-matrix td:first-child {
|
||||
white-space: normal;
|
||||
}
|
||||
td.row-remove { text-align: center; width: 28px; }
|
||||
.row-remove-btn, .row-check {
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
.row-remove-btn:hover { color: var(--error); }
|
||||
.add-row-btn {
|
||||
margin: 2px 0 14px;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- legend */
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin: 4px 0 10px;
|
||||
}
|
||||
.legend-group-title { font-weight: 600; margin-right: 6px; }
|
||||
.legend-item { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.legend-glyph { font-size: 13px; }
|
||||
|
||||
/* ------------------------------------------------------------- drawing */
|
||||
|
||||
.drawing-block {
|
||||
margin: 10px 0 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel-bg);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.drawing-caption {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.drawing-host { width: 100%; }
|
||||
.drawing-problems {
|
||||
font-size: 11px;
|
||||
color: var(--warn);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.drawing-problems:empty { display: none; }
|
||||
|
||||
/* -------------------------------------------------------------- details */
|
||||
|
||||
details.pa-section {
|
||||
margin: 10px 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel-bg);
|
||||
}
|
||||
details.pa-section > summary {
|
||||
cursor: pointer;
|
||||
padding: 8px 10px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
list-style: none;
|
||||
}
|
||||
details.pa-section > summary::-webkit-details-marker { display: none; }
|
||||
details.pa-section > summary::before {
|
||||
content: "▸ ";
|
||||
color: var(--text-muted);
|
||||
}
|
||||
details.pa-section[open] > summary::before { content: "▾ "; }
|
||||
details.pa-section > .pa-section-body { padding: 4px 12px 12px; }
|
||||
|
||||
details.pa-notes {
|
||||
margin: 20px 0 4px;
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
background: #fbfbfc;
|
||||
}
|
||||
details.pa-notes > summary {
|
||||
cursor: pointer;
|
||||
padding: 7px 10px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12.5px;
|
||||
list-style: none;
|
||||
}
|
||||
details.pa-notes > summary::-webkit-details-marker { display: none; }
|
||||
.pa-notes-body { padding: 2px 14px 12px; font-size: 12px; color: var(--text-muted); }
|
||||
.pa-notes-body h4 { margin: 8px 0 4px; font-size: 11.5px; text-transform: uppercase; letter-spacing: .03em; color: var(--text-faint); }
|
||||
.pa-notes-body ul { margin: 0 0 6px; padding-left: 18px; }
|
||||
.pa-notes-body li { margin-bottom: 3px; }
|
||||
|
||||
.unknown-block {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffe08a;
|
||||
border-radius: var(--radius);
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
#errors {
|
||||
position: fixed;
|
||||
left: 0; right: 0; bottom: 0;
|
||||
max-height: 30vh;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
background: #2b0000;
|
||||
color: #ffd9d9;
|
||||
font-size: 11px;
|
||||
padding: 8px 10px;
|
||||
z-index: 999;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
#errors[hidden] { display: none; }
|
||||
Reference in New Issue
Block a user