This is the largest piece of front-end work in the CR. Read the three constraints first — they explain why the design looks the way it does.
Every span renders at exactly 140 px wide regardless of real length. Every pier is exactly 140 px tall regardless of real height. length_of_span is captured on the form but never read by the diagram. Real pier height is used only for the partial-fill ratio.
single_pier, portal_pier, double_pier, double_portal_pier — each ~100 KB of inline JS with the same helpers redefined. No shared module. The last SVG bugfix was applied identically four times. A fifth variant makes it five.
There's no viewBox. A past commit explicitly removed max-width:100% because it shrank the diagram instead of letting it overflow. That was a deliberate product decision — keep it. The cable-stayed canvas gets wider and taller and scrolls.
Changing this redraws the pylons in the diagram above. Strictly, a true longitudinal elevation would look identical for every shape — you'd be sighting along the legs. We deliberately draw the transverse silhouette at each pylon instead, so the type is recognisable at a glance. That's consistent with the rest of the BMS diagram, which already mixes elevation, plan bands and component tables. Full comparison with reference bridges on page 4.
Toggle "Equal (today)" above. With fixed 140 px spans, Mindhola's three 150 m main spans render the same width as its 35 m approach spans — and the cable fans collide because there's no room between pylons. On a girder bridge nobody noticed. On a cable-stayed bridge it's unreadable.
Scope this to the new partial only. Don't retrofit proportional width onto the four existing partials in this CR — that changes how every live bridge looks and needs its own client sign-off.
Mindhola has two cable planes — upstream and downstream. In side view they sit exactly behind one another, so a single elevation can only draw one line per cable pair. That is fine geometrically. It is not fine for progress tracking.
Cables are stressed one plane at a time. If U/S cable 5 at P3 is finally stressed but D/S cable 5 is only installed, a superimposed view has one line and two different truths. Whatever colour you pick is wrong for one of them.
Switch the Faces control to "Superimposed" above — the diagram falls back to showing the lesser of the two stages, which is safe but silently hides real progress from the client.
Each face gets its own elevation with its own 82 cables and its own colours — 164 in total, matching the component plan quantity exactly. No arithmetic to explain, no hidden state.
This also matches what BMS already does: dual-carriageway bridges stack LHS and RHS elevations in exactly this way. Same pattern, different axis — so it's familiar to the client and the renderer already has the shape of this code.
plane_side_cable_n from the start — us_a1_cable_1 and ds_a1_cable_1 are separate progress rows at the same location p_2. No schema change; the renderer just filters by plane. Single Mast is the exception — one plane, so the toggle collapses to a single band automatically.
Every existing component uses #fff9d9 for "not started" — which also happens to be the canvas background. That's fine for a filled rect with a black stroke around it. A 1–2 px line in that colour on that background is invisible.
#fff9d9 → invisible#C9CBD0 dashed → reads as "planned"This is a genuine new rule, not a style preference — the existing palette simply has no case for line-shaped components.
One pylon, one direction. Every cable is a straight line from a point on the pylon to a point on the deck — the arrangement only changes how those two point-sets are distributed.
// ---- LAYOUT CONSTANTS (cable_stayed variant) ---- const PYLON_HEADROOM = 240; // NEW: room above deck const baseY = 320 + PYLON_HEADROOM; // ground line const deckY = 180 + PYLON_HEADROOM; // deck top const MAX_UPPER_PX = 200; // tallest mast on screen // every existing y-constant shifts down by PYLON_HEADROOM; // svg height grows 950 -> 1190 (common carriageway) // ---- PYLON ---- upperPx = MAX_UPPER_PX * (upperH / maxUpperH); pylonTopY = deckY - upperPx; // lower pylon: deckY..baseY, fills UP from baseY // upper pylon: pylonTopY..deckY, fills UP from deckY // both reuse drawPier()'s partial-fill algorithm verbatim: h = fullPx * Math.min(done, total) / total; color = done >= total ? '#7ED321' : '#D9A526'; // ---- CABLE FAN ---- dir = -1 (A1 side) | +1 (A2 side) const nearX = 26; const farX = adjacentSpanPx * 0.45; const fanTopY = pylonTopY + 10; const fanBotY = pylonTopY + upperPx * 0.50; for (let i = 0; i < n; i++) { const t = (n === 1) ? 0 : i / (n - 1); let ay, ax; if (arrangement === 'semi_fan') { ay = fanBotY - t * (fanBotY - fanTopY); ax = px + dir * (nearX + t * (farX - nearX)); } else if (arrangement === 'fan') { ay = fanTopY; // all from one point ax = px + dir * (nearX + t * (farX - nearX)); } else /* harp — parallel, equal slopes */ { const k = farX / upperPx; ay = pylonTopY + t * upperPx * 0.85; ax = px + dir * k * (deckY - ay); } drawCable(px, ay, ax, deckY, stageColor(cableStage(i))); }
In a strict longitudinal elevation, an H-frame, an A-frame and an inverted-Y all look like the same single tower — you're sighting along the line of the legs, so one hides the other. The shape is only visible looking across or down.
pylon_shape resolves to a small set of line segments in normalised space (see shapeSegments() in this page's source — it's about 10 lines and can be lifted directly). Those segments scale into the pylon's box in the elevation band, and the same definition drives the plan view. One data structure, two renderers, no per-shape branching in the drawing code.
cable_count_a1 + cable_count_a2 per pylon (82 total); the component plan still counts × planes (164). Both numbers are correct — they answer different questions. Worth a code comment, or someone will "fix" it.
A 2 px diagonal line is almost impossible to click. Draw each cable twice: a transparent fat line for hit-testing, then the visible thin one on top.
// hit area first (underneath)
hit = line(x1,y1,x2,y2);
hit.setAttribute('stroke','transparent');
hit.setAttribute('stroke-width','12');
hit.addEventListener('click', () =>
loadComponentData(STAY_CABLE_ID, 'p_2'));
// visible cable on top
vis = line(x1,y1,x2,y2);
vis.setAttribute('stroke', stageColor);
vis.setAttribute('stroke-width','2');
Keep the existing convention: click handlers are attached only to components with progress. A not-started cable stays non-clickable, same as every other component today.
Tooltips use a <title> child, as today — hover a cable in the diagram above to see it.
@else fallback to both dispatch sites first. Five minutes, and it stops an unknown pier type silently rendering a blank box.2d-image/cable_stayed.blade.php by copying single_pier.blade.php — it's the closest base (common carriageway, one pier per support).PYLON_HEADROOM and switch the horizontal loop to proportional span widths.drawPylon() — literally drawPier() with a taller box, called twice (lower, upper).drawCableFan() and the stage colour map.pylon_shape for 1 vs 2 legs.Step 2 above says "copy single_pier.blade.php" because that's what the codebase does today. It's also how we got to four near-identical 100 KB files where every bugfix has to be applied four times.
Fastest to ship, zero risk to existing bridges. Cost: every future SVG fix becomes a 5× edit, and the cable-stayed file will drift from the others.
Pull the common helpers into one JS module, then add cable-stayed as a variant. Costs maybe a week up front and needs regression testing on all four existing pier types — but it's the last cheap moment to do it.
This is a PM/client decision about paying down debt, not a technical unknown. Flagging it here because it's the single biggest hidden cost in the CR and it gets more expensive with every variant added.