@formicoidea/labre-framework-cynefin 0.32.0 → 0.34.1
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.
- package/dist/actions.js +8 -0
- package/dist/commands-manifest.d.ts +18 -0
- package/dist/commands-manifest.js +42 -0
- package/dist/cynefin/consts.js +49 -13
- package/dist/cynefin/element-renderer.js +4 -1
- package/dist/cynefin/toolbar/config.d.ts +5 -1
- package/dist/cynefin/toolbar/config.js +57 -7
- package/dist/estuarine/consts.d.ts +10 -3
- package/dist/estuarine/consts.js +41 -8
- package/dist/estuarine/element-renderer.d.ts +108 -2
- package/dist/estuarine/element-renderer.js +146 -43
- package/dist/estuarine/ghost-overlay.d.ts +134 -0
- package/dist/estuarine/ghost-overlay.js +277 -0
- package/dist/estuarine/nudges.d.ts +41 -0
- package/dist/estuarine/nudges.js +69 -0
- package/dist/estuarine/roles.d.ts +48 -0
- package/dist/estuarine/roles.js +28 -0
- package/dist/estuarine/toolbar/config.d.ts +6 -1
- package/dist/estuarine/toolbar/config.js +67 -8
- package/dist/templates/index.js +18 -2
- package/dist/toolbar/senior-button.js +4 -1
- package/dist/toolbar/senior-tool.js +1 -0
- package/dist/translations.d.ts +5 -0
- package/dist/translations.js +15 -2
- package/dist/view.js +49 -1
- package/package.json +7 -2
|
@@ -1,83 +1,186 @@
|
|
|
1
1
|
import { ElementRendererExtension, } from '@formicoidea/labre-core/blocks/surface';
|
|
2
|
-
import { FONT_FAMILY
|
|
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.
|
|
9
|
-
*
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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(
|
|
34
|
-
ctx.lineTo(
|
|
35
|
-
ctx.lineTo(
|
|
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
|
-
|
|
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
|
-
// ──
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
ctx.strokeStyle =
|
|
62
|
-
ctx.lineWidth =
|
|
63
|
-
ctx.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
+
}
|