Page 3 · 2D Diagram — Pylon & Cable Fan (v0.4)
v0.4: U/S and D/S faces drawn as separate bands, and pylon shape now redraws the towers. Working SVG using the same DOM API the real renderer uses.
← Brief 1. Form 2. Impact 4. Pylon Types 5. Counting

Drawing a cable-stayed bridge in the 2D diagram

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.

CONSTRAINT 1
The diagram has no scale — at all

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.

CONSTRAINT 2
It's four copy-pasted files

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.

CONSTRAINT 3
Grow and scroll — never scale to fit

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.

Live · Mindhola River Bridge elevation
CABLE ARRANGEMENT
SPAN WIDTH
FACES
PYLON TYPE
TRANSVERSE
shape visible here
PLAN
2 anchor lines
H-Frame

Deck anchor lines: 2 Cables drawn in elevation: 82 Physical cables: 164 Cross beams: 2 per pylon

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.

LEGEND Not started Pylon in progress Complete | Cable — not started Anchorage Installed Initial tension Final stress

Span width must become proportional

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.

pxPerM = TARGET_WIDTH / totalLength
spanPx[i] = max(MIN_SPAN_PX, len[i] × pxPerM)

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.

Why the two faces must be drawn separately

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.

The problem with one elevation

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.

Two bands solves it

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.

The data model already supports it. Cable item numbers were specified as 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.

Cream doesn't work for cables

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.

✗ not-started cable = #fff9d9 → invisible
✓ not-started cable = #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.

The cable fan geometry

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)));
}

Why we draw the transverse silhouette in the elevation band

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.

So we make a deliberate schematic choice: draw each pylon's transverse silhouette at its position in the elevation band. It is not strictly correct projection — but the BMS diagram was never a strict projection. It already stacks an elevation, plan-view bands and component tables in one canvas. Showing the real shape means a site engineer recognises the structure instantly, and the client sees their bridge rather than a generic post. Flag this to the client as a decision, not an accident.
STRICT ELEVATION (not what we draw)
H / A / Y all identical here
TRANSVERSE (what we draw instead)
H-frame: two legs + cross beams
PLAN (already exists in BMS)
Two legs = two cable planes
Practical consequence: 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.
⚠ Expect 82 cables in the elevation, not 164. Mindhola has 164 physical stay cables — but the U/S and D/S planes sit directly behind one another in side view, so they render as one line each. The elevation draws 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.

Cables need an invisible hit area

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.

Build order

  1. 1
    Add the @else fallback to both dispatch sites first. Five minutes, and it stops an unknown pier type silently rendering a blank box.
  2. 2
    Create 2d-image/cable_stayed.blade.php by copying single_pier.blade.php — it's the closest base (common carriageway, one pier per support).
  3. 3
    Shift every y-constant by PYLON_HEADROOM and switch the horizontal loop to proportional span widths.
  4. 4
    Add drawPylon() — literally drawPier() with a taller box, called twice (lower, upper).
  5. 5
    Add drawCableFan() and the stage colour map.
  6. 6
    Branch the plan-view band on pylon_shape for 1 vs 2 legs.
  7. 7
    Add a legend. The current diagram has none — with four cable stages plus pylon states, it stops being self-explanatory.

One thing to decide before starting: extract the renderer, or copy a fifth time?

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.

Copy a fifth time

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.

Extract the shared renderer first

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.

Page 3 of 3 · ← Back to the brief