patterns/_diagram-shared/

patterns/_diagram-shared/README.md · 9901 B · source sha256 a60da5f8255b

Canonical source plane for support code that more than one diagram engine consumes: text layout and pointer input, each for the target set its row below declares. Files here are authored once and mirrored out by tools/sync-diagram-shared.mjs.

on this page

The plane confers no authority

_diagram-shared is a location, not a mandate. Each member declares its own explicit target set, and that declaration — not this folder's name — decides which patterns consume it. A future member may serve a different set, or one pattern only.

This matters because the owner's Class A static family is five patterns — diagram-static-H, diagram-static-V, diagram-static-SEQ, diagram-static-FLOW and diagram-static-peer — and FLOW and peer each carry a different grammar. A member that governs only some of them must say so, or a later maintainer reads an absence as partial vendoring and "completes" it.

Current members

Member Target set Excluded
Memberdiagrams-text-layout.js Target setdiagram-static-H · diagram-static-V · diagram-static-SEQ Excludeddiagram-static-FLOW · diagram-static-peer · diagram-interactive-spine · diagram-interactive-radial
Memberdiagrams-pointer.js Target setdiagram-interactive-radial · diagram-interactive-spine · diagram-static-H · diagram-static-V · diagram-static-SEQ · diagram-static-FLOW Excludeddiagram-static-peer

Canonical and mirrors

canonical   patterns/_diagram-shared/diagrams-text-layout.js
mirrors     patterns/diagram-static-{H,V,SEQ}/diagrams-text-layout.js

canonical   patterns/_diagram-shared/diagrams-pointer.js
mirrors     patterns/diagram-interactive-{radial,spine}/diagrams-pointer.js
            patterns/diagram-static-{H,V,SEQ,FLOW}/diagrams-pointer.js

A mirror is byte-identical to its canonical and is a generated artifact.

node tools/sync-diagram-shared.mjs          # emit
node tools/sync-diagram-shared.mjs --check  # exit 1 on any missing or divergent mirror

Never hand-edit a mirror. The mirror exists so a consumer vendors one self-contained bundle directory rather than reaching across the repo; --check exists so a hand edit fails loudly instead of surviving as a silent fork. Edit the canonical and re-emit.

What a member owns, and what it must not

diagrams-pointer.js owns gesture recognition for mouse, pen and touch on Pointer Events: pan past a tap slop, a centroid pinch, a moved gesture that swallows its click, and the wheel. It does not own a camera, a hit test or a selection: the consuming engine supplies its view, applies every change and decides what a tap means. Its gesture logic is a device-approved controller moved as donor code, with two declared corrections (a horizontal scroll no longer zooms; the pointer left after a pinch keeps its own type). Its own header carries the attach contract.

v2 (2026-10-08) serves the static engines too. A static canvas holds the panels laid over the drawing, so the caller names them in exclude: a press there stays native and a wheel there scrolls unless it is a trackpad pinch. The controller marks the stage it owns with data-diagram-pointer, which diagrams.css keys touch-action: none on. With exclude it also lays its own touch-action: none layer under the drawing and the panels, so the panels keep the browser's own touch behavior, page zoom included. A moved gesture's click is swallowed only when a pointer produced it, for at most 500 ms and never past the next press, so a keyboard or programmatic activation — Enter on a linked card after a touch pan — and a tap on the HUD straight after a pan are never eaten. Only a primary-button press starts a gesture, so a middle click, a right click and a pen's barrel stay native.

The radial and spine engines pass no exclude, so they get no layer and keep their panels outside the stage. Three v2 changes do reach them, all deliberate: a non-primary pen or touch press (a pen's barrel) no longer pans; a moved gesture's click token now expires and never swallows a keyboard or programmatic click (v1 swallowed the next click whatever produced it); and their stage carries data-diagram-pointer. Their behavior suites pass against v2 unchanged.

diagrams-text-layout.js owns exact measurement, role metrics, cap application, deterministic line breaking, wrapped height and tspan emission.

It does not own source grammar, topology, placement, connector geometry or the final SVG envelope. Those stay with each engine, because H, V and SEQ have genuinely different geometry contracts — a horizontal top-aligned cascade, a vertical band layout, and a linear sequence — and folding them together would produce a fourth engine wearing three names.

Measurement parity is the first gate. A string that does not wrap must measure exactly as it did before this plane existed, letter-spacing compensation included. That is what lets a no-wrap page prove geometric identity rather than merely look unchanged — and it held on every page of the measured fleet.

It is not a blanket render-neutrality guarantee, and two V tree shapes change with no wrapping at all. Both are corrections to notes V measured and never drew:

V section with a note   the note no longer inflates band WIDTH   (base: ~+434px for
                        text that was never drawn)
V group with a note     the note is now DRAWN, and the group label takes the paired
                        offset — base selected BOX_H_NOTE for it and drew nothing

Nothing in the current fleet authors either shape, so no existing page moves. A downstream V diagram that does author one will render differently after re-vendoring, and should: the published grammar permits note on a group and on a section, and the old behaviour spent geometry on invisible text.

These are the two known structural exceptions to no-wrap neutrality. Every measured current no-wrap page remains byte-identical. Inputs that exceed a role cap still change by design — wrapping them is the entire point of the contract — so "render-neutral" is a statement about the no-wrap path, never about the change as a whole.

What the helper does not own — and the trap that follows

The helper returns added height (addedHeight is 0 when a run does not wrap). The consuming engine adds that to its own box, which means the engine, not the helper, owns where a wrapped run is anchored.

That split has one sharp edge, and every consumer hits it: once a box has grown, a run anchored to the box's bottom or centre must subtract its own growth. Anchoring the first baseline to a grown edge deposits the new height as dead space at one end and pushes the run's remaining lines out the other. A run anchored to the box top needs no correction, because it grows in the same direction the box did.

The failure is quiet: nothing collides, nothing leaves the viewBox, and the text wraps correctly — it simply renders outside its own box. A containment assertion is the only check that sees it.

What the helper owns

line breaking    delimiter matching, longest-first, and force-break
measurement      wrapped width and height for a given role cap
role metrics     per-role cap, line height, and the has-note predicate
tspan emission   the emitted text structure for a wrapped label or note

An engine that keeps a private copy of any of those is the divergence this file exists to remove. That is not a style preference, and the historical case is measured rather than recalled. V's local predicate counted a note on any kind while V drew one on neither a section nor a group:

base V, note on a GROUP     BOX_H_NOTE selected · box 26 -> 44px · +18px height · +511px width
base V, note on a SECTION   no height change at all — a section takes SECTION_H / SECTION_H_TAG,
                            never BOX_H_NOTE — but +434px of band WIDTH
both                        the note is never drawn

Two different symptoms from one drifted predicate. It is now resolved here and is target-aware: H and V draw a section as label + rule + tag and never its note, while SEQ has no section branch at all, so a kind: 'section' record there is an ordinary node that does render its note.

Engines keep what is genuinely theirs: source grammar, topology, base box geometry, placement, anchoring, connectors, the final envelope — and fonts and letter-spacing, which are CSS-derived and belong with the stylesheet.

The break contract

authored \n                 hard break, always
delimiters                  matched LONGEST-FIRST — // before / — breaking AFTER the delimiter
whitespace                  a break opportunity
a token still over its cap  force-broken, and only then

A visual line boundary does not corrupt an identifier: no source character is inserted, removed, reordered or normalized. The helper returns two parallel forms — segments, the exact partition where segments.join('') === source proves exact runtime string equality, and lines, the rendered payload, which drops only trailing whitespace at a boundary so a text-anchor: middle run centres on its glyphs. Keeping them apart is what lets the preservation gate assert exact identity without pushing stray whitespace into the DOM.

That is a claim about the runtime JavaScript string, not about file bytes: two different literal spellings or encodings can produce the same runtime string. Source-file byte preservation is a separate fact, established because every *.source.js path is outside this change.