@formicoidea/labre-framework-cynefin 0.31.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,83 +1,186 @@
1
1
  import { ElementRendererExtension, } from '@formicoidea/labre-core/blocks/surface';
2
- import { FONT_FAMILY, refScale } from '../utils.js';
2
+ import { FONT_FAMILY } from '../utils.js';
3
3
  import { ARROWHEADS, AXIS_LABELS, AXIS_WIDTH, COLORS, COUNTERFACTUAL_PATH, COUNTERFACTUAL_WIDTH, E_AXIS, LABEL_LETTER_SPACING, LABELS, LIMINAL_PATH, LIMINAL_WIDTH, REF_H, REF_W, T_AXIS, VOLATILE_PATH, VOLATILE_WIDTH, } from './consts.js';
4
+ /**
5
+ * The three reference curves are drawn as a permanent GHOST (PO arbitration,
6
+ * 26/08/2026): dashed, translucent, never solid again.
7
+ *
8
+ * The reasoning is what an Estuarine line IS. Liminal, Volatile and
9
+ * Counter-factual are not measurements — they are boundaries a group argues
10
+ * itself into, and the printed map is a support for that argument, not a
11
+ * verdict about where the boundary lies. A solid stroke made the tool's own
12
+ * curve look like the answer, and the group's negotiated line (drawn on top,
13
+ * with a brush or a connector) look like an annotation on it. Dashing the
14
+ * reference inverts that: the tool suggests, the group states.
15
+ *
16
+ * The toggles keep their meaning exactly — ON shows the ghost, OFF hides it.
17
+ * What changed is only how ON looks, plus the ~600 ms reveal animation the
18
+ * moment a toggle flips (see `./ghost-overlay.ts`), which exists so a line
19
+ * that comes back at 45 % opacity is still SEEN arriving.
20
+ *
21
+ * The legends stay solid. A label is a name, not a boundary.
22
+ */
23
+ /** Dash pattern of the ghost, in reference-space units. */
24
+ export const GHOST_DASH = [12, 10];
25
+ /** Opacity of the permanent ghost. */
26
+ export const GHOST_ALPHA = 0.45;
27
+ /**
28
+ * The three curves, with their `Path2D` built ONCE.
29
+ *
30
+ * A lazy memo (`??=`) rather than a module-level constant, and that is not a
31
+ * micro-optimisation: `Path2D` does not exist under Node, and `./consts.ts` —
32
+ * which this module imports — is pulled in by the unit specs. Building the
33
+ * paths at import time would make merely importing this framework's constants
34
+ * throw in every non-browser environment. Built on the first PAINT instead,
35
+ * which by definition happens on a canvas.
36
+ */
37
+ let _curves;
38
+ export function estuarineCurves() {
39
+ return (_curves ??= [
40
+ {
41
+ key: 'liminal',
42
+ path: new Path2D(LIMINAL_PATH),
43
+ color: COLORS.liminal,
44
+ width: LIMINAL_WIDTH,
45
+ visibleProp: 'showLiminal',
46
+ },
47
+ {
48
+ key: 'volatile',
49
+ path: new Path2D(VOLATILE_PATH),
50
+ color: COLORS.volatile,
51
+ width: VOLATILE_WIDTH,
52
+ visibleProp: 'showVolatile',
53
+ },
54
+ {
55
+ key: 'counterfactual',
56
+ path: new Path2D(COUNTERFACTUAL_PATH),
57
+ color: COLORS.counterfactual,
58
+ width: COUNTERFACTUAL_WIDTH,
59
+ visibleProp: 'showCounterfactual',
60
+ },
61
+ ]);
62
+ }
63
+ export function estuarineFit(w, h) {
64
+ const sx = w / REF_W;
65
+ const sy = h / REF_H;
66
+ const strokeScale = Math.sqrt(sx * sy);
67
+ return { sx, sy, strokeScale, curveLineScale: strokeScale / ((sx + sy) / 2) };
68
+ }
69
+ /**
70
+ * Put `ctx` into the map's STRETCHED reference space, where the authored
71
+ * geometry (a `Path2D` built from `./consts.ts`) lands on the element's real
72
+ * bounds in both directions.
73
+ *
74
+ * The single source of truth for that transform, and it has to stay single:
75
+ * the ghost overlay paints the very same curves one layer above and must land
76
+ * on them to the pixel. Everything that must NOT be stretched — axes,
77
+ * arrowheads, legends — is drawn outside it, in element coordinates, from the
78
+ * same {@link EstuarineFit}.
79
+ */
80
+ export function applyEstuarineTransform(ctx, w, h) {
81
+ const fit = estuarineFit(w, h);
82
+ ctx.scale(fit.sx, fit.sy);
83
+ return fit;
84
+ }
4
85
  /**
5
86
  * Canvas renderer for the Estuarine framework map — reproduces the official SVG:
6
87
  * the e (vertical, double-headed) / t (horizontal, single-headed) axes and the
7
88
  * three reference curves (Liminal / Volatile / Counter-factual), each with its
8
- * legend and individually hideable. Drawn in the fixed reference space and
9
- * scaled uniformly to the element bounds.
89
+ * legend and individually hideable.
90
+ *
91
+ * Two spaces, and which one a mark belongs to is the whole design:
92
+ *
93
+ * - **Element coordinates**, entered by mapping each authored coordinate
94
+ * through {@link EstuarineFit} by hand (`ax` / `ay` below): the axes, their
95
+ * arrowheads and every word. Their POSITION follows the stretch — an axis
96
+ * ends where the map now ends — while their SHAPE does not, because a
97
+ * stretched arrowhead or a squashed letter is a defect, never a feature.
98
+ * - **Stretched reference space**, entered by {@link applyEstuarineTransform}:
99
+ * the three curves, which are boundaries across the plane and must cover
100
+ * whatever plane the user has made.
10
101
  */
11
102
  export const estuarine = (model, ctx, matrix) => {
12
103
  const [, , w, h] = model.deserializedXYWH;
13
104
  const cx = w / 2;
14
105
  const cy = h / 2;
15
- ctx.setTransform(matrix.translateSelf(cx, cy).rotateSelf(model.rotate).translateSelf(-cx, -cy));
16
- const { s, ox, oy } = refScale(w, h, REF_W, REF_H);
17
- ctx.translate(ox, oy);
18
- ctx.scale(s, s);
106
+ ctx.setTransform(matrix
107
+ .translateSelf(cx, cy)
108
+ .rotateSelf(model.rotate)
109
+ .translateSelf(-cx, -cy));
110
+ const fit = estuarineFit(w, h);
111
+ /** Authored x → element x. Proportional: `43.5 / 690` of the real width. */
112
+ const ax = (x) => x * fit.sx;
113
+ /** Authored y → element y. */
114
+ const ay = (y) => y * fit.sy;
19
115
  ctx.lineCap = 'round';
20
116
  ctx.lineJoin = 'round';
21
117
  // ── Axes ────────────────────────────────────────────────────────────
118
+ // Drawn in element coordinates so the e axis spans the real height and the
119
+ // t axis the real width, while `lineWidth` stays one honest thickness
120
+ // instead of being fattened in whichever direction the map was pulled.
22
121
  ctx.strokeStyle = COLORS.axis;
23
122
  ctx.fillStyle = COLORS.axis;
24
- ctx.lineWidth = AXIS_WIDTH;
123
+ ctx.lineWidth = AXIS_WIDTH * fit.strokeScale;
25
124
  ctx.beginPath();
26
- ctx.moveTo(E_AXIS.x, E_AXIS.y1);
27
- ctx.lineTo(E_AXIS.x, E_AXIS.y2);
28
- ctx.moveTo(T_AXIS.x1, T_AXIS.y);
29
- ctx.lineTo(T_AXIS.x2, T_AXIS.y);
125
+ ctx.moveTo(ax(E_AXIS.x), ay(E_AXIS.y1));
126
+ ctx.lineTo(ax(E_AXIS.x), ay(E_AXIS.y2));
127
+ ctx.moveTo(ax(T_AXIS.x1), ay(T_AXIS.y));
128
+ ctx.lineTo(ax(T_AXIS.x2), ay(T_AXIS.y));
30
129
  ctx.stroke();
31
- for (const [[tx, ty], [ax, ay], [bx, by]] of ARROWHEADS) {
130
+ // Each head is pinned by its TIP — which travels to the real end of its axis
131
+ // — and then built from the authored offsets at the isotropic scale, so the
132
+ // triangle keeps its shape at any aspect ratio.
133
+ for (const [[tx, ty], [px, py], [qx, qy]] of ARROWHEADS) {
134
+ const tipX = ax(tx);
135
+ const tipY = ay(ty);
136
+ const k = fit.strokeScale;
32
137
  ctx.beginPath();
33
- ctx.moveTo(tx, ty);
34
- ctx.lineTo(ax, ay);
35
- ctx.lineTo(bx, by);
138
+ ctx.moveTo(tipX, tipY);
139
+ ctx.lineTo(tipX + (px - tx) * k, tipY + (py - ty) * k);
140
+ ctx.lineTo(tipX + (qx - tx) * k, tipY + (qy - ty) * k);
36
141
  ctx.closePath();
37
142
  ctx.fill();
38
143
  }
39
144
  // Uppercase legend (centre-anchored, alphabetic baseline, letter-spaced).
145
+ // Anchored proportionally, typed isotropically — never inside the stretch.
40
146
  const hasSpacing = 'letterSpacing' in ctx;
41
147
  const legend = (l) => {
42
148
  ctx.fillStyle = l.color;
43
- ctx.font = `600 ${l.size}px ${FONT_FAMILY}`;
149
+ ctx.font = `600 ${l.size * fit.strokeScale}px ${FONT_FAMILY}`;
44
150
  ctx.textAlign = 'center';
45
151
  ctx.textBaseline = 'alphabetic';
46
- if (hasSpacing)
47
- ctx.letterSpacing = `${LABEL_LETTER_SPACING}px`;
48
- ctx.fillText(l.text, l.x, l.y);
152
+ if (hasSpacing) {
153
+ ctx.letterSpacing = `${LABEL_LETTER_SPACING * fit.strokeScale}px`;
154
+ }
155
+ ctx.fillText(l.text, ax(l.x), ay(l.y));
49
156
  if (hasSpacing)
50
157
  ctx.letterSpacing = '0px';
51
158
  };
52
- // ── Liminal (green) ─────────────────────────────────────────────────
53
- if (model.showLiminal) {
54
- ctx.strokeStyle = COLORS.liminal;
55
- ctx.lineWidth = LIMINAL_WIDTH;
56
- ctx.stroke(new Path2D(LIMINAL_PATH));
57
- legend(LABELS.liminal);
58
- }
59
- // ── Volatile (red) ──────────────────────────────────────────────────
60
- if (model.showVolatile) {
61
- ctx.strokeStyle = COLORS.volatile;
62
- ctx.lineWidth = VOLATILE_WIDTH;
63
- ctx.stroke(new Path2D(VOLATILE_PATH));
64
- legend(LABELS.volatile);
65
- }
66
- // ── Counter-factual (dark) ──────────────────────────────────────────
67
- if (model.showCounterfactual) {
68
- ctx.strokeStyle = COLORS.counterfactual;
69
- ctx.lineWidth = COUNTERFACTUAL_WIDTH;
70
- ctx.stroke(new Path2D(COUNTERFACTUAL_PATH));
71
- legend(LABELS.counterfactual);
159
+ // ── The three curves, as ghosts ─────────────────────────────────────
160
+ // `save`/`restore` around each one so neither the stretch nor the dash nor
161
+ // the alpha leaks onto the legend that follows it — a legend is a name, and
162
+ // names stay solid, upright and undeformed.
163
+ for (const curve of estuarineCurves()) {
164
+ if (!model[curve.visibleProp])
165
+ continue;
166
+ ctx.save();
167
+ applyEstuarineTransform(ctx, w, h);
168
+ ctx.strokeStyle = curve.color;
169
+ ctx.lineWidth = curve.width * fit.curveLineScale;
170
+ ctx.globalAlpha = GHOST_ALPHA;
171
+ ctx.setLineDash([...GHOST_DASH]);
172
+ ctx.stroke(curve.path);
173
+ ctx.restore();
174
+ legend(LABELS[curve.key]);
72
175
  }
73
176
  // ── Italic e / t axis letters ───────────────────────────────────────
74
177
  if (model.showAxisLabels) {
75
178
  ctx.fillStyle = COLORS.axisLabel;
76
- ctx.font = `italic 700 ${AXIS_LABELS.size}px Georgia, serif`;
179
+ ctx.font = `italic 700 ${AXIS_LABELS.size * fit.strokeScale}px Georgia, serif`;
77
180
  ctx.textAlign = 'left';
78
181
  ctx.textBaseline = 'alphabetic';
79
- ctx.fillText(AXIS_LABELS.e.text, AXIS_LABELS.e.x, AXIS_LABELS.e.y);
80
- ctx.fillText(AXIS_LABELS.t.text, AXIS_LABELS.t.x, AXIS_LABELS.t.y);
182
+ ctx.fillText(AXIS_LABELS.e.text, ax(AXIS_LABELS.e.x), ay(AXIS_LABELS.e.y));
183
+ ctx.fillText(AXIS_LABELS.t.text, ax(AXIS_LABELS.t.x), ay(AXIS_LABELS.t.y));
81
184
  }
82
185
  };
83
186
  export const EstuarineRendererExtension = ElementRendererExtension('estuarine', estuarine);
@@ -0,0 +1,134 @@
1
+ import { type CanvasRenderer, Overlay, type RoughCanvas } from '@formicoidea/labre-core/blocks/surface';
2
+ import { InteractivityExtension } from '@formicoidea/labre-core/std/gfx';
3
+ /**
4
+ * The REVEAL — the ~600 ms animation that plays when an Estuarine curve is
5
+ * switched back on (WS4, PO arbitration of 26/08/2026).
6
+ *
7
+ * The permanent look of a curve is now a discreet ghost (`GHOST_ALPHA`, dashed:
8
+ * see `./element-renderer.ts`). That is the right resting state and the wrong
9
+ * ARRIVAL: a user who clicks "show the Volatile line" and gets a 45 %-opacity
10
+ * dashed curve on a busy map can reasonably fail to notice anything happened,
11
+ * and conclude the toggle is broken. So the flip is animated — the dashes march
12
+ * along the path while a second, brighter stroke rides above the ghost, then
13
+ * decays away and leaves the ghost alone.
14
+ *
15
+ * An OVERLAY rather than a renderer change, for the same reasons the validation
16
+ * bracket is one: it touches no element model, writes nothing to the document,
17
+ * creates no undo entry, and holds nothing that could survive a reload. "When
18
+ * did I last flip this toggle" is session state and must never reach the
19
+ * document.
20
+ */
21
+ /** How long the reveal stroke takes to march across the curve. */
22
+ export declare const GHOST_REVEAL_MS = 600;
23
+ /** How long it then takes to fade back into the permanent ghost. */
24
+ export declare const GHOST_DECAY_MS = 200;
25
+ /** Total life of one reveal. */
26
+ export declare const GHOST_TOTAL_MS: number;
27
+ /**
28
+ * Reference-space units the dash pattern travels during the reveal — four
29
+ * periods of {@link GHOST_DASH}, so the march reads as motion rather than as a
30
+ * jitter.
31
+ */
32
+ export declare const GHOST_DASH_TRAVEL: number;
33
+ /**
34
+ * Peak opacity of the reveal stroke, ON TOP of the ghost the renderer already
35
+ * painted. Chosen so the two together reach a full-strength line at the crest
36
+ * and never exceed it.
37
+ */
38
+ export declare const GHOST_PEAK_ALPHA = 0.55;
39
+ /** One frame of the reveal. */
40
+ export interface GhostRevealFrame {
41
+ /** Opacity of the reveal stroke; `0` means "paint nothing". */
42
+ alpha: number;
43
+ /** `lineDashOffset` for this frame, negative so the dashes march forward. */
44
+ dashOffset: number;
45
+ /** Whether this reveal is over and can be forgotten. */
46
+ done: boolean;
47
+ }
48
+ /**
49
+ * The whole animation, as a pure function of elapsed milliseconds.
50
+ *
51
+ * Pure and exported on purpose: it is the only part of this file that has an
52
+ * opinion, and a function taking a number is testable without a canvas, a
53
+ * surface, a DI container or a clock. The overlay below is then reduced to
54
+ * "ask this, then stroke" — which is the part that cannot go wrong quietly.
55
+ *
56
+ * Out-of-range input is answered rather than trusted: a negative or non-finite
57
+ * elapsed (a clock that went backwards, a `performance.now()` mock) paints
58
+ * nothing instead of throwing or flashing.
59
+ */
60
+ export declare function ghostRevealFrame(elapsed: number): GhostRevealFrame;
61
+ /**
62
+ * Whether the user has asked their system for less motion.
63
+ *
64
+ * Read at every reveal rather than cached: the setting can change mid-session
65
+ * (an OS toggle, a devtools emulation), and the answer costs one media query.
66
+ * `globalThis.matchMedia` is optional-chained because this module is imported
67
+ * by unit specs running under Node.
68
+ */
69
+ export declare function prefersReducedMotion(): boolean;
70
+ /**
71
+ * Paints the reveal stroke over whatever curves are currently on.
72
+ *
73
+ * The rAF loop is copied from `ValidationOverlay`, deliberately and including
74
+ * its two guards: the clock is never armed when nothing is animating, and a
75
+ * detached renderer stops it dead. An overlay that keeps requesting frames for
76
+ * a surface that no longer exists is sixty repaints a second of nothing.
77
+ */
78
+ export declare class EstuarineGhostOverlay extends Overlay {
79
+ static overlayName: string;
80
+ /** Element id → `performance.now()` at the moment its toggle flipped. */
81
+ private readonly _reveals;
82
+ /** Armed only while something is still revealing. */
83
+ private _frame;
84
+ /**
85
+ * Whether the renderer this overlay paints into is gone. The manager below
86
+ * lives on the gfx scope and keeps its subscriptions on the surface MODEL,
87
+ * which outlives the surface COMPONENT — so a toggle can perfectly well be
88
+ * flipped after this overlay was torn down.
89
+ */
90
+ private _detached;
91
+ private readonly _onFrame;
92
+ /** Whether any reveal is still inside its window at `now`. */
93
+ isAnimating(now: number): boolean;
94
+ /**
95
+ * Start (or restart) the reveal on `elementId`.
96
+ *
97
+ * Reduced motion is honoured HERE rather than at the call site, so every
98
+ * future trigger inherits it: the permanent ghost the renderer paints is
99
+ * already the end state, so declining to animate is a complete no-op and not
100
+ * a degraded mode.
101
+ */
102
+ reveal(elementId: string): void;
103
+ /**
104
+ * Repaint, and keep repainting while a reveal is inside its window. Stops on
105
+ * its own: an idle board requests no animation frame at all.
106
+ */
107
+ private _schedule;
108
+ private _cancelFrame;
109
+ private _forget;
110
+ setRenderer(renderer: CanvasRenderer | null): void;
111
+ clear(): void;
112
+ dispose(): void;
113
+ render(ctx: CanvasRenderingContext2D, _rc: RoughCanvas): void;
114
+ }
115
+ /**
116
+ * Turns "a curve toggle just went from off to on" into a reveal.
117
+ *
118
+ * The transition is read from the `elementUpdated` payload — `props` carries
119
+ * the new value, `oldValues` the previous one — and BOTH halves are required:
120
+ * `props.showVolatile === true` alone also fires when the map is created, when
121
+ * a remote peer syncs an unrelated change, or when the value is rewritten
122
+ * identically. Only a genuine `false → true` flip is a user asking to see a
123
+ * line appear.
124
+ */
125
+ export declare class EstuarineGhostManager extends InteractivityExtension {
126
+ static key: string;
127
+ private _subscriptions;
128
+ private _disposeSurfaceEffect;
129
+ private get _overlay();
130
+ mounted(): void;
131
+ unmounted(): void;
132
+ private _unsubscribe;
133
+ private _resubscribe;
134
+ }
@@ -0,0 +1,277 @@
1
+ import { Overlay, OverlayIdentifier, } from '@formicoidea/labre-core/blocks/surface';
2
+ import { EstuarineElementModel } from '@formicoidea/labre-core/model';
3
+ import { InteractivityExtension } from '@formicoidea/labre-core/std/gfx';
4
+ import { effect } from '@preact/signals-core';
5
+ import { applyEstuarineTransform, estuarineCurves, GHOST_DASH, } from './element-renderer.js';
6
+ /**
7
+ * The REVEAL — the ~600 ms animation that plays when an Estuarine curve is
8
+ * switched back on (WS4, PO arbitration of 26/08/2026).
9
+ *
10
+ * The permanent look of a curve is now a discreet ghost (`GHOST_ALPHA`, dashed:
11
+ * see `./element-renderer.ts`). That is the right resting state and the wrong
12
+ * ARRIVAL: a user who clicks "show the Volatile line" and gets a 45 %-opacity
13
+ * dashed curve on a busy map can reasonably fail to notice anything happened,
14
+ * and conclude the toggle is broken. So the flip is animated — the dashes march
15
+ * along the path while a second, brighter stroke rides above the ghost, then
16
+ * decays away and leaves the ghost alone.
17
+ *
18
+ * An OVERLAY rather than a renderer change, for the same reasons the validation
19
+ * bracket is one: it touches no element model, writes nothing to the document,
20
+ * creates no undo entry, and holds nothing that could survive a reload. "When
21
+ * did I last flip this toggle" is session state and must never reach the
22
+ * document.
23
+ */
24
+ /** How long the reveal stroke takes to march across the curve. */
25
+ export const GHOST_REVEAL_MS = 600;
26
+ /** How long it then takes to fade back into the permanent ghost. */
27
+ export const GHOST_DECAY_MS = 200;
28
+ /** Total life of one reveal. */
29
+ export const GHOST_TOTAL_MS = GHOST_REVEAL_MS + GHOST_DECAY_MS;
30
+ /**
31
+ * Reference-space units the dash pattern travels during the reveal — four
32
+ * periods of {@link GHOST_DASH}, so the march reads as motion rather than as a
33
+ * jitter.
34
+ */
35
+ export const GHOST_DASH_TRAVEL = 4 * (GHOST_DASH[0] + GHOST_DASH[1]);
36
+ /**
37
+ * Peak opacity of the reveal stroke, ON TOP of the ghost the renderer already
38
+ * painted. Chosen so the two together reach a full-strength line at the crest
39
+ * and never exceed it.
40
+ */
41
+ export const GHOST_PEAK_ALPHA = 0.55;
42
+ /**
43
+ * The whole animation, as a pure function of elapsed milliseconds.
44
+ *
45
+ * Pure and exported on purpose: it is the only part of this file that has an
46
+ * opinion, and a function taking a number is testable without a canvas, a
47
+ * surface, a DI container or a clock. The overlay below is then reduced to
48
+ * "ask this, then stroke" — which is the part that cannot go wrong quietly.
49
+ *
50
+ * Out-of-range input is answered rather than trusted: a negative or non-finite
51
+ * elapsed (a clock that went backwards, a `performance.now()` mock) paints
52
+ * nothing instead of throwing or flashing.
53
+ */
54
+ export function ghostRevealFrame(elapsed) {
55
+ if (!Number.isFinite(elapsed) || elapsed <= 0) {
56
+ return { alpha: 0, dashOffset: 0, done: false };
57
+ }
58
+ if (elapsed >= GHOST_TOTAL_MS) {
59
+ return { alpha: 0, dashOffset: 0, done: true };
60
+ }
61
+ // The march stops when the reveal does; the decay fades a still line.
62
+ const marched = Math.min(elapsed, GHOST_REVEAL_MS) / GHOST_REVEAL_MS;
63
+ const dashOffset = -GHOST_DASH_TRAVEL * marched;
64
+ const alpha = elapsed <= GHOST_REVEAL_MS
65
+ ? GHOST_PEAK_ALPHA * marched
66
+ : GHOST_PEAK_ALPHA * (1 - (elapsed - GHOST_REVEAL_MS) / GHOST_DECAY_MS);
67
+ return { alpha, dashOffset, done: false };
68
+ }
69
+ /**
70
+ * Whether the user has asked their system for less motion.
71
+ *
72
+ * Read at every reveal rather than cached: the setting can change mid-session
73
+ * (an OS toggle, a devtools emulation), and the answer costs one media query.
74
+ * `globalThis.matchMedia` is optional-chained because this module is imported
75
+ * by unit specs running under Node.
76
+ */
77
+ export function prefersReducedMotion() {
78
+ return (globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false);
79
+ }
80
+ /**
81
+ * Paints the reveal stroke over whatever curves are currently on.
82
+ *
83
+ * The rAF loop is copied from `ValidationOverlay`, deliberately and including
84
+ * its two guards: the clock is never armed when nothing is animating, and a
85
+ * detached renderer stops it dead. An overlay that keeps requesting frames for
86
+ * a surface that no longer exists is sixty repaints a second of nothing.
87
+ */
88
+ export class EstuarineGhostOverlay extends Overlay {
89
+ constructor() {
90
+ super(...arguments);
91
+ /** Element id → `performance.now()` at the moment its toggle flipped. */
92
+ this._reveals = new Map();
93
+ /** Armed only while something is still revealing. */
94
+ this._frame = null;
95
+ /**
96
+ * Whether the renderer this overlay paints into is gone. The manager below
97
+ * lives on the gfx scope and keeps its subscriptions on the surface MODEL,
98
+ * which outlives the surface COMPONENT — so a toggle can perfectly well be
99
+ * flipped after this overlay was torn down.
100
+ */
101
+ this._detached = false;
102
+ this._onFrame = () => {
103
+ this._frame = null;
104
+ this._schedule();
105
+ };
106
+ }
107
+ static { this.overlayName = 'estuarine-ghost'; }
108
+ /** Whether any reveal is still inside its window at `now`. */
109
+ isAnimating(now) {
110
+ for (const start of this._reveals.values()) {
111
+ if (!ghostRevealFrame(now - start).done)
112
+ return true;
113
+ }
114
+ return false;
115
+ }
116
+ /**
117
+ * Start (or restart) the reveal on `elementId`.
118
+ *
119
+ * Reduced motion is honoured HERE rather than at the call site, so every
120
+ * future trigger inherits it: the permanent ghost the renderer paints is
121
+ * already the end state, so declining to animate is a complete no-op and not
122
+ * a degraded mode.
123
+ */
124
+ reveal(elementId) {
125
+ if (this._detached)
126
+ return;
127
+ if (prefersReducedMotion())
128
+ return;
129
+ this._reveals.set(elementId, performance.now());
130
+ this._schedule();
131
+ }
132
+ /**
133
+ * Repaint, and keep repainting while a reveal is inside its window. Stops on
134
+ * its own: an idle board requests no animation frame at all.
135
+ */
136
+ _schedule() {
137
+ if (this._detached)
138
+ return;
139
+ this.refresh();
140
+ if (this._frame !== null)
141
+ return;
142
+ if (!this.isAnimating(performance.now()))
143
+ return;
144
+ this._frame = requestAnimationFrame(this._onFrame);
145
+ }
146
+ _cancelFrame() {
147
+ if (this._frame === null)
148
+ return;
149
+ cancelAnimationFrame(this._frame);
150
+ this._frame = null;
151
+ }
152
+ _forget() {
153
+ this._cancelFrame();
154
+ this._reveals.clear();
155
+ }
156
+ setRenderer(renderer) {
157
+ this._detached = renderer === null;
158
+ super.setRenderer(renderer);
159
+ }
160
+ clear() {
161
+ this._forget();
162
+ super.clear();
163
+ }
164
+ dispose() {
165
+ this._detached = true;
166
+ this._forget();
167
+ super.dispose();
168
+ }
169
+ render(ctx, _rc) {
170
+ if (this._reveals.size === 0)
171
+ return;
172
+ const surface = this.gfx.surface;
173
+ if (!surface)
174
+ return;
175
+ const now = performance.now();
176
+ for (const [id, start] of [...this._reveals]) {
177
+ const frame = ghostRevealFrame(now - start);
178
+ if (frame.done) {
179
+ this._reveals.delete(id);
180
+ continue;
181
+ }
182
+ if (frame.alpha <= 0)
183
+ continue;
184
+ const model = surface.getElementById(id);
185
+ // Deleted, or replaced by something else entirely: the reveal is about
186
+ // an element that no longer exists.
187
+ if (!(model instanceof EstuarineElementModel)) {
188
+ this._reveals.delete(id);
189
+ continue;
190
+ }
191
+ const [x, y, w, h] = model.deserializedXYWH;
192
+ ctx.save();
193
+ // Model space, exactly like the element renderer: translate to the
194
+ // element, rotate about its centre, then enter the STRETCHED reference
195
+ // frame through the shared transform — the reveal stroke has to sit on
196
+ // the ghost to the pixel, so the two go through one function, never two
197
+ // copies of the same arithmetic. Recomputed at PAINT time rather than
198
+ // captured at reveal time, so the stroke follows a map the user drags,
199
+ // rotates or resizes mid-animation.
200
+ ctx.translate(x, y);
201
+ ctx.translate(w / 2, h / 2);
202
+ ctx.rotate((model.rotate * Math.PI) / 180);
203
+ ctx.translate(-w / 2, -h / 2);
204
+ const fit = applyEstuarineTransform(ctx, w, h);
205
+ ctx.lineCap = 'round';
206
+ ctx.lineJoin = 'round';
207
+ ctx.globalAlpha = frame.alpha;
208
+ ctx.setLineDash([...GHOST_DASH]);
209
+ ctx.lineDashOffset = frame.dashOffset;
210
+ for (const curve of estuarineCurves()) {
211
+ if (!model[curve.visibleProp])
212
+ continue;
213
+ ctx.strokeStyle = curve.color;
214
+ ctx.lineWidth = curve.width * fit.curveLineScale;
215
+ ctx.stroke(curve.path);
216
+ }
217
+ ctx.restore();
218
+ }
219
+ }
220
+ }
221
+ /** The three toggles a reveal can be triggered by. */
222
+ const SHOW_PROPS = [
223
+ 'showLiminal',
224
+ 'showVolatile',
225
+ 'showCounterfactual',
226
+ ];
227
+ /**
228
+ * Turns "a curve toggle just went from off to on" into a reveal.
229
+ *
230
+ * The transition is read from the `elementUpdated` payload — `props` carries
231
+ * the new value, `oldValues` the previous one — and BOTH halves are required:
232
+ * `props.showVolatile === true` alone also fires when the map is created, when
233
+ * a remote peer syncs an unrelated change, or when the value is rewritten
234
+ * identically. Only a genuine `false → true` flip is a user asking to see a
235
+ * line appear.
236
+ */
237
+ export class EstuarineGhostManager extends InteractivityExtension {
238
+ constructor() {
239
+ super(...arguments);
240
+ this._subscriptions = [];
241
+ this._disposeSurfaceEffect = null;
242
+ }
243
+ static { this.key = 'estuarine-ghost'; }
244
+ get _overlay() {
245
+ return this.std.getOptional(OverlayIdentifier(EstuarineGhostOverlay.overlayName));
246
+ }
247
+ mounted() {
248
+ // The surface is a SIGNAL, not a fact: it can be null at mount and arrive
249
+ // later, and it is replaced if the surface block is.
250
+ this._disposeSurfaceEffect = effect(() => {
251
+ this._resubscribe(this.gfx.surface$.value);
252
+ });
253
+ }
254
+ unmounted() {
255
+ this._disposeSurfaceEffect?.();
256
+ this._disposeSurfaceEffect = null;
257
+ this._unsubscribe();
258
+ super.unmounted();
259
+ }
260
+ _unsubscribe() {
261
+ for (const subscription of this._subscriptions)
262
+ subscription.unsubscribe();
263
+ this._subscriptions = [];
264
+ }
265
+ _resubscribe(surface) {
266
+ this._unsubscribe();
267
+ if (!surface)
268
+ return;
269
+ this._subscriptions.push(surface.elementUpdated.subscribe(({ id, props, oldValues }) => {
270
+ if (!props || !oldValues)
271
+ return;
272
+ const flipped = SHOW_PROPS.some(prop => props[prop] === true && oldValues[prop] === false);
273
+ if (flipped)
274
+ this._overlay?.reveal(id);
275
+ }));
276
+ }
277
+ }