@formicoidea/labre-framework-wardley 0.23.2 → 0.23.3
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/consts.d.ts +71 -0
- package/{src/consts.ts → dist/consts.js} +62 -72
- package/dist/descriptor.d.ts +7 -0
- package/{src/descriptor.ts → dist/descriptor.js} +4 -5
- package/dist/effects.d.ts +9 -0
- package/dist/effects.js +6 -0
- package/dist/element-renderer.d.ts +14 -0
- package/dist/element-renderer.js +159 -0
- package/dist/element-view.d.ts +20 -0
- package/dist/element-view.js +121 -0
- package/dist/gradient.d.ts +17 -0
- package/dist/gradient.js +111 -0
- package/{src/index.ts → dist/index.d.ts} +1 -1
- package/dist/index.js +1 -0
- package/dist/label-layout.d.ts +20 -0
- package/dist/label-layout.js +72 -0
- package/dist/legend.d.ts +11 -0
- package/dist/legend.js +332 -0
- package/dist/node/consts.d.ts +106 -0
- package/{src/node/consts.ts → dist/node/consts.js} +100 -109
- package/dist/node/node-renderer.d.ts +16 -0
- package/dist/node/node-renderer.js +105 -0
- package/{src/node/node-view.ts → dist/node/node-view.d.ts} +10 -11
- package/dist/node/node-view.js +9 -0
- package/dist/templates/index.d.ts +2 -0
- package/dist/templates/index.js +171 -0
- package/dist/templates/maps.d.ts +2 -0
- package/dist/templates/maps.js +246 -0
- package/dist/toolbar/config.d.ts +74 -0
- package/dist/toolbar/config.js +205 -0
- package/dist/toolbar/icons.d.ts +30 -0
- package/{src/toolbar/icons.ts → dist/toolbar/icons.js} +50 -66
- package/dist/toolbar/node-config.d.ts +1 -0
- package/{src/toolbar/node-config.ts → dist/toolbar/node-config.js} +20 -28
- package/dist/toolbar/senior-tool.d.ts +1 -0
- package/{src/toolbar/senior-tool.ts → dist/toolbar/senior-tool.js} +9 -10
- package/dist/toolbar/wardley-menu.d.ts +52 -0
- package/dist/toolbar/wardley-menu.js +407 -0
- package/dist/toolbar/wardley-senior-button.d.ts +17 -0
- package/dist/toolbar/wardley-senior-button.js +145 -0
- package/dist/view.d.ts +6 -0
- package/dist/view.js +35 -0
- package/package.json +15 -6
- package/src/effects.ts +0 -17
- package/src/element-renderer.ts +0 -242
- package/src/element-view.ts +0 -143
- package/src/gradient.ts +0 -137
- package/src/label-layout.ts +0 -126
- package/src/legend.ts +0 -438
- package/src/node/node-renderer.ts +0 -142
- package/src/templates/index.ts +0 -236
- package/src/templates/maps.ts +0 -283
- package/src/toolbar/config.ts +0 -280
- package/src/toolbar/wardley-menu.ts +0 -552
- package/src/toolbar/wardley-senior-button.ts +0 -154
- package/src/view.ts +0 -39
package/src/element-renderer.ts
DELETED
|
@@ -1,242 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
type ElementRenderer,
|
|
3
|
-
ElementRendererExtension,
|
|
4
|
-
} from '@formicoidea/labre-core/blocks/surface';
|
|
5
|
-
import type { WardleyBackgroundElementModel } from '@formicoidea/labre-core/model';
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
ARROW,
|
|
9
|
-
CARD_RADIUS,
|
|
10
|
-
COLORS,
|
|
11
|
-
EVOLUTION_BOUNDARIES,
|
|
12
|
-
FONT_FAMILY,
|
|
13
|
-
FONTS,
|
|
14
|
-
LINE,
|
|
15
|
-
MARGIN,
|
|
16
|
-
OFFSETS,
|
|
17
|
-
} from './consts';
|
|
18
|
-
import { BENEFIT_ZERO_FRAC, paintGradientBackground } from './gradient';
|
|
19
|
-
|
|
20
|
-
function roundRectPath(
|
|
21
|
-
ctx: CanvasRenderingContext2D,
|
|
22
|
-
x: number,
|
|
23
|
-
y: number,
|
|
24
|
-
w: number,
|
|
25
|
-
h: number,
|
|
26
|
-
r: number
|
|
27
|
-
) {
|
|
28
|
-
const rr = Math.min(r, w / 2, h / 2);
|
|
29
|
-
ctx.beginPath();
|
|
30
|
-
ctx.moveTo(x + rr, y);
|
|
31
|
-
ctx.arcTo(x + w, y, x + w, y + h, rr);
|
|
32
|
-
ctx.arcTo(x + w, y + h, x, y + h, rr);
|
|
33
|
-
ctx.arcTo(x, y + h, x, y, rr);
|
|
34
|
-
ctx.arcTo(x, y, x + w, y, rr);
|
|
35
|
-
ctx.closePath();
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Canvas renderer for the Wardley map background — reproduces mockup C:
|
|
40
|
-
* an L-shaped axes frame (no top/right border), dashed evolution dividers and
|
|
41
|
-
* the symmetric axis labels.
|
|
42
|
-
*
|
|
43
|
-
* All sizes (fonts, margins, offsets, strokes) are FIXED model units — they do
|
|
44
|
-
* not scale with the element size. Only the plot interior scales.
|
|
45
|
-
*/
|
|
46
|
-
export const wardley: ElementRenderer<WardleyBackgroundElementModel> = (
|
|
47
|
-
model,
|
|
48
|
-
ctx,
|
|
49
|
-
matrix
|
|
50
|
-
) => {
|
|
51
|
-
const [, , w, h] = model.deserializedXYWH;
|
|
52
|
-
const cx = w / 2;
|
|
53
|
-
const cy = h / 2;
|
|
54
|
-
ctx.setTransform(
|
|
55
|
-
matrix.translateSelf(cx, cy).rotateSelf(model.rotate).translateSelf(-cx, -cy)
|
|
56
|
-
);
|
|
57
|
-
|
|
58
|
-
const px0 = MARGIN.left;
|
|
59
|
-
const px1 = w - MARGIN.right;
|
|
60
|
-
const py0 = MARGIN.top;
|
|
61
|
-
const py1 = h - MARGIN.bottom;
|
|
62
|
-
const pw = px1 - px0;
|
|
63
|
-
const ph = py1 - py0;
|
|
64
|
-
const ex = (r: number) => px0 + r * pw;
|
|
65
|
-
|
|
66
|
-
const line = (x1: number, y1: number, x2: number, y2: number) => {
|
|
67
|
-
ctx.beginPath();
|
|
68
|
-
ctx.moveTo(x1, y1);
|
|
69
|
-
ctx.lineTo(x2, y2);
|
|
70
|
-
ctx.stroke();
|
|
71
|
-
};
|
|
72
|
-
const vtext = (
|
|
73
|
-
text: string,
|
|
74
|
-
x: number,
|
|
75
|
-
y: number,
|
|
76
|
-
fontSize: number,
|
|
77
|
-
color: string
|
|
78
|
-
) => {
|
|
79
|
-
ctx.save();
|
|
80
|
-
ctx.translate(x, y);
|
|
81
|
-
ctx.rotate(-Math.PI / 2);
|
|
82
|
-
ctx.font = `${fontSize}px ${FONT_FAMILY}`;
|
|
83
|
-
ctx.fillStyle = color;
|
|
84
|
-
ctx.textAlign = 'center';
|
|
85
|
-
ctx.textBaseline = 'alphabetic';
|
|
86
|
-
ctx.fillText(text, 0, 0);
|
|
87
|
-
ctx.restore();
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
// ── Card (element bounds) ───────────────────────────────────────────
|
|
91
|
-
const inset = LINE.card / 2;
|
|
92
|
-
roundRectPath(ctx, inset, inset, w - inset * 2, h - inset * 2, CARD_RADIUS);
|
|
93
|
-
ctx.fillStyle = COLORS.card;
|
|
94
|
-
ctx.fill();
|
|
95
|
-
ctx.strokeStyle = COLORS.cardBorder;
|
|
96
|
-
ctx.lineWidth = LINE.card;
|
|
97
|
-
ctx.stroke();
|
|
98
|
-
|
|
99
|
-
// ── Curve-driven gradient variants (inscribed in the frame) ─────────
|
|
100
|
-
// Hidden when `showGradient` is false → plain white background.
|
|
101
|
-
if (model.variant !== 'classic' && model.showGradient) {
|
|
102
|
-
paintGradientBackground(ctx, model.variant, px0, px1, py0, py1);
|
|
103
|
-
if (model.variant === 'benefit') {
|
|
104
|
-
const zy = py1 - BENEFIT_ZERO_FRAC * ph;
|
|
105
|
-
ctx.strokeStyle = COLORS.axis;
|
|
106
|
-
ctx.lineWidth = 1.2;
|
|
107
|
-
ctx.beginPath();
|
|
108
|
-
ctx.moveTo(px0, zy);
|
|
109
|
-
ctx.lineTo(px1, zy);
|
|
110
|
-
ctx.stroke();
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// ── Optional evolution band tints ───────────────────────────────────
|
|
115
|
-
if (model.banded) {
|
|
116
|
-
const starts = [0, 0.175, 0.4, 0.7];
|
|
117
|
-
const ends = [0.175, 0.4, 0.7, 1];
|
|
118
|
-
for (let i = 0; i < 4; i++) {
|
|
119
|
-
ctx.fillStyle = COLORS.band[i];
|
|
120
|
-
ctx.fillRect(ex(starts[i]), py0, ex(ends[i]) - ex(starts[i]), ph);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// ── Evolution phase dividers (dashed) ───────────────────────────────
|
|
125
|
-
if (model.showColumnDividers) {
|
|
126
|
-
ctx.strokeStyle = COLORS.divider;
|
|
127
|
-
ctx.lineWidth = LINE.divider;
|
|
128
|
-
ctx.setLineDash([5, 5]);
|
|
129
|
-
for (const r of EVOLUTION_BOUNDARIES) {
|
|
130
|
-
line(ex(r), py0, ex(r), py1);
|
|
131
|
-
}
|
|
132
|
-
ctx.setLineDash([]);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// ── Axes (L shape) + arrowheads ─────────────────────────────────────
|
|
136
|
-
// X and Y axes are independently toggleable. Each line stops at the base of
|
|
137
|
-
// its arrowhead (1px overlap, hidden under the triangle) so the line never
|
|
138
|
-
// pokes past the tip on zoom.
|
|
139
|
-
ctx.strokeStyle = COLORS.axis;
|
|
140
|
-
ctx.lineWidth = LINE.axis;
|
|
141
|
-
ctx.fillStyle = COLORS.axis;
|
|
142
|
-
if (model.showXAxis) {
|
|
143
|
-
line(px0, py1, px1 - ARROW + 1, py1); // X axis (arrow tip at px1)
|
|
144
|
-
ctx.beginPath(); // X arrow (points right)
|
|
145
|
-
ctx.moveTo(px1, py1);
|
|
146
|
-
ctx.lineTo(px1 - ARROW, py1 - ARROW / 2);
|
|
147
|
-
ctx.lineTo(px1 - ARROW, py1 + ARROW / 2);
|
|
148
|
-
ctx.closePath();
|
|
149
|
-
ctx.fill();
|
|
150
|
-
}
|
|
151
|
-
if (model.showYAxis) {
|
|
152
|
-
line(px0, py1, px0, py0 + ARROW - 1); // Y axis (arrow tip at py0)
|
|
153
|
-
ctx.beginPath(); // Y arrow (points up)
|
|
154
|
-
ctx.moveTo(px0, py0);
|
|
155
|
-
ctx.lineTo(px0 - ARROW / 2, py0 + ARROW);
|
|
156
|
-
ctx.lineTo(px0 + ARROW / 2, py0 + ARROW);
|
|
157
|
-
ctx.closePath();
|
|
158
|
-
ctx.fill();
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// ── Horizontal labels ───────────────────────────────────────────────
|
|
162
|
-
ctx.textBaseline = 'alphabetic';
|
|
163
|
-
// Phase (column) labels (left-aligned at each zone start)
|
|
164
|
-
if (model.showColumnLabels) {
|
|
165
|
-
ctx.font = `${FONTS.phase}px ${FONT_FAMILY}`;
|
|
166
|
-
ctx.fillStyle = COLORS.label;
|
|
167
|
-
ctx.textAlign = 'left';
|
|
168
|
-
const phases: Array<[string, number]> = [
|
|
169
|
-
[model.phase0, 0],
|
|
170
|
-
[model.phase1, 0.175],
|
|
171
|
-
[model.phase2, 0.4],
|
|
172
|
-
[model.phase3, 0.7],
|
|
173
|
-
];
|
|
174
|
-
for (const [label, start] of phases) {
|
|
175
|
-
ctx.fillText(
|
|
176
|
-
label,
|
|
177
|
-
ex(start) + OFFSETS.phasePad,
|
|
178
|
-
py1 + OFFSETS.phaseBaseline
|
|
179
|
-
);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
// "Evolution" title near the X arrow (tied to the X axis)
|
|
183
|
-
if (model.showXAxis) {
|
|
184
|
-
ctx.font = `${FONTS.axis}px ${FONT_FAMILY}`;
|
|
185
|
-
ctx.fillStyle = COLORS.axis;
|
|
186
|
-
ctx.textAlign = 'right';
|
|
187
|
-
ctx.fillText(
|
|
188
|
-
model.xAxisTitle,
|
|
189
|
-
px1 - OFFSETS.evolutionPadRight,
|
|
190
|
-
py1 + OFFSETS.phaseBaseline
|
|
191
|
-
);
|
|
192
|
-
}
|
|
193
|
-
// Direction indicators (Uncharted / Industrialized, top corners)
|
|
194
|
-
if (model.showCornerLabels) {
|
|
195
|
-
ctx.font = `${FONTS.direction}px ${FONT_FAMILY}`;
|
|
196
|
-
ctx.fillStyle = COLORS.label;
|
|
197
|
-
ctx.textAlign = 'left';
|
|
198
|
-
ctx.fillText(
|
|
199
|
-
model.evolutionStart,
|
|
200
|
-
px0 + OFFSETS.directionPadLeft,
|
|
201
|
-
py0 + OFFSETS.directionTop
|
|
202
|
-
);
|
|
203
|
-
ctx.textAlign = 'right';
|
|
204
|
-
ctx.fillText(
|
|
205
|
-
model.evolutionEnd,
|
|
206
|
-
px1 - OFFSETS.directionPadRight,
|
|
207
|
-
py0 + OFFSETS.directionTop
|
|
208
|
-
);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// ── Rotated Y labels (hugging the axis, symmetric with the X labels) ─
|
|
212
|
-
if (model.showYAxis) {
|
|
213
|
-
vtext(
|
|
214
|
-
model.yAxisTitle,
|
|
215
|
-
px0 - OFFSETS.yHug,
|
|
216
|
-
(py0 + py1) / 2,
|
|
217
|
-
FONTS.axis,
|
|
218
|
-
COLORS.axis
|
|
219
|
-
);
|
|
220
|
-
}
|
|
221
|
-
if (model.showVisibilityLabels) {
|
|
222
|
-
vtext(
|
|
223
|
-
model.visibilityHigh,
|
|
224
|
-
px0 - OFFSETS.yHug,
|
|
225
|
-
py0 + OFFSETS.visibleTop,
|
|
226
|
-
FONTS.visibility,
|
|
227
|
-
COLORS.label
|
|
228
|
-
);
|
|
229
|
-
vtext(
|
|
230
|
-
model.visibilityLow,
|
|
231
|
-
px0 - OFFSETS.yHug,
|
|
232
|
-
py1 - OFFSETS.invisibleBottom,
|
|
233
|
-
FONTS.visibility,
|
|
234
|
-
COLORS.label
|
|
235
|
-
);
|
|
236
|
-
}
|
|
237
|
-
};
|
|
238
|
-
|
|
239
|
-
export const WardleyElementRendererExtension = ElementRendererExtension(
|
|
240
|
-
'wardley',
|
|
241
|
-
wardley
|
|
242
|
-
);
|
package/src/element-view.ts
DELETED
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
import { EdgelessCRUDIdentifier } from '@formicoidea/labre-core/blocks/surface';
|
|
2
|
-
import type { WardleyBackgroundElementModel } from '@formicoidea/labre-core/model';
|
|
3
|
-
import { rotatePoint } from '@formicoidea/labre-core/global/gfx';
|
|
4
|
-
import type { PointerEventState } from '@formicoidea/labre-core/std';
|
|
5
|
-
import {
|
|
6
|
-
GfxElementModelView,
|
|
7
|
-
GfxViewInteractionExtension,
|
|
8
|
-
} from '@formicoidea/labre-core/std/gfx';
|
|
9
|
-
|
|
10
|
-
import {
|
|
11
|
-
getWardleyLabelHits,
|
|
12
|
-
hitTestWardleyLabel,
|
|
13
|
-
type WardleyLabelField,
|
|
14
|
-
} from './label-layout';
|
|
15
|
-
|
|
16
|
-
export class WardleyView extends GfxElementModelView<WardleyBackgroundElementModel> {
|
|
17
|
-
static override type: string = 'wardley';
|
|
18
|
-
|
|
19
|
-
/** The in-place `<input>` used to edit a label, or null when idle. */
|
|
20
|
-
private _labelEditor: HTMLInputElement | null = null;
|
|
21
|
-
|
|
22
|
-
override onCreated(): void {
|
|
23
|
-
super.onCreated();
|
|
24
|
-
this.on('dblclick', e => this._onDblClick(e));
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
override onDestroyed(): void {
|
|
28
|
-
this._closeLabelEditor();
|
|
29
|
-
super.onDestroyed();
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Double-click on a label → edit its text in place. */
|
|
33
|
-
private _onDblClick(e: PointerEventState): void {
|
|
34
|
-
if (this.model.isLocked()) return;
|
|
35
|
-
|
|
36
|
-
const [mx, my] = this.gfx.viewport.toModelCoord(e.x, e.y);
|
|
37
|
-
const [bx, by, w, h] = this.model.deserializedXYWH;
|
|
38
|
-
|
|
39
|
-
// Convert the model-space point into element-local coordinates, undoing the
|
|
40
|
-
// element rotation around its center.
|
|
41
|
-
let lx = mx - bx;
|
|
42
|
-
let ly = my - by;
|
|
43
|
-
const rot = this.model.rotate ?? 0;
|
|
44
|
-
if (rot) {
|
|
45
|
-
const center: [number, number] = [bx + w / 2, by + h / 2];
|
|
46
|
-
const [ux, uy] = rotatePoint([mx, my], center, -rot);
|
|
47
|
-
lx = ux - bx;
|
|
48
|
-
ly = uy - by;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const hit = hitTestWardleyLabel(getWardleyLabelHits(this.model, w, h), lx, ly);
|
|
52
|
-
if (!hit) return;
|
|
53
|
-
|
|
54
|
-
this._openLabelEditor(hit.field, e);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
private _openLabelEditor(field: WardleyLabelField, e: PointerEventState): void {
|
|
58
|
-
this._closeLabelEditor();
|
|
59
|
-
|
|
60
|
-
const input = document.createElement('input');
|
|
61
|
-
input.value = String(this.model[field] ?? '');
|
|
62
|
-
Object.assign(input.style, {
|
|
63
|
-
position: 'fixed',
|
|
64
|
-
left: `${e.raw.clientX}px`,
|
|
65
|
-
top: `${e.raw.clientY}px`,
|
|
66
|
-
transform: 'translate(-50%, -50%)',
|
|
67
|
-
zIndex: '10000',
|
|
68
|
-
minWidth: '140px',
|
|
69
|
-
padding: '3px 8px',
|
|
70
|
-
font: '14px Inter, sans-serif',
|
|
71
|
-
color: 'var(--affine-text-primary-color, #1f2328)',
|
|
72
|
-
background: 'var(--affine-background-overlay-panel-color, #ffffff)',
|
|
73
|
-
border: '1px solid var(--affine-primary-color, #1e96eb)',
|
|
74
|
-
borderRadius: '6px',
|
|
75
|
-
boxShadow: 'var(--affine-shadow-2, 0 2px 8px rgba(0,0,0,0.18))',
|
|
76
|
-
outline: 'none',
|
|
77
|
-
});
|
|
78
|
-
document.body.append(input);
|
|
79
|
-
this._labelEditor = input;
|
|
80
|
-
|
|
81
|
-
// Mark the element as "editing" so the global edgeless key handlers
|
|
82
|
-
// (delete, escape, etc.) don't act on it while the user types.
|
|
83
|
-
this.gfx.selection.set({ elements: [this.model.id], editing: true });
|
|
84
|
-
|
|
85
|
-
input.focus();
|
|
86
|
-
input.select();
|
|
87
|
-
|
|
88
|
-
const commit = () => {
|
|
89
|
-
// Guard against re-entrancy: removing the input fires `blur`, which would
|
|
90
|
-
// otherwise call `commit` a second time.
|
|
91
|
-
if (this._labelEditor !== input) return;
|
|
92
|
-
const value = input.value;
|
|
93
|
-
this._closeLabelEditor();
|
|
94
|
-
this.gfx.std.store.captureSync();
|
|
95
|
-
this.gfx.std
|
|
96
|
-
.get(EdgelessCRUDIdentifier)
|
|
97
|
-
.updateElement(this.model.id, { [field]: value });
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
input.addEventListener('keydown', ev => {
|
|
101
|
-
ev.stopPropagation();
|
|
102
|
-
if (ev.key === 'Enter') {
|
|
103
|
-
ev.preventDefault();
|
|
104
|
-
commit();
|
|
105
|
-
} else if (ev.key === 'Escape') {
|
|
106
|
-
ev.preventDefault();
|
|
107
|
-
this._closeLabelEditor();
|
|
108
|
-
}
|
|
109
|
-
});
|
|
110
|
-
input.addEventListener('blur', commit);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
private _closeLabelEditor(): void {
|
|
114
|
-
if (!this._labelEditor) return;
|
|
115
|
-
const input = this._labelEditor;
|
|
116
|
-
this._labelEditor = null;
|
|
117
|
-
input.remove();
|
|
118
|
-
if (this.isConnected) {
|
|
119
|
-
this.gfx.selection.set({ elements: [this.model.id], editing: false });
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Resize gating: the resize handles are hidden unless `model.resizeEnabled` is
|
|
126
|
-
* true. `beforeResize` is re-evaluated every time the allowed handles are
|
|
127
|
-
* computed (manager.ts), so toggling the field from the toolbar updates the
|
|
128
|
-
* handles reactively. Moving/selecting stays available throughout.
|
|
129
|
-
*/
|
|
130
|
-
export const WardleyInteraction = GfxViewInteractionExtension<WardleyView>(
|
|
131
|
-
WardleyView.type,
|
|
132
|
-
{
|
|
133
|
-
handleResize({ model }) {
|
|
134
|
-
return {
|
|
135
|
-
beforeResize({ set }) {
|
|
136
|
-
if (!model.resizeEnabled) {
|
|
137
|
-
set({ allowedHandlers: [] });
|
|
138
|
-
}
|
|
139
|
-
},
|
|
140
|
-
};
|
|
141
|
-
},
|
|
142
|
-
}
|
|
143
|
-
);
|
package/src/gradient.ts
DELETED
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Curve-driven gradient backgrounds (Slice C). Each analytic background is a
|
|
3
|
-
* smooth mathematical curve (piecewise asymmetric Gaussian bells); the gradient
|
|
4
|
-
* opacity at each evolution position X follows that curve, normalised between
|
|
5
|
-
* its own min and max — i.e. the gradient is strongest where the curve peaks and
|
|
6
|
-
* fades to nothing at its minimum. Validated against the reference images at
|
|
7
|
-
* `../wardley-mockups/gradient-backgrounds.html`.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
const bell = (x: number, mu: number, s: number) =>
|
|
11
|
-
Math.exp(-0.5 * ((x - mu) / s) ** 2);
|
|
12
|
-
const asym = (x: number, mu: number, sL: number, sR: number) =>
|
|
13
|
-
Math.exp(-0.5 * ((x - mu) / (x < mu ? sL : sR)) ** 2);
|
|
14
|
-
|
|
15
|
-
// Opportunity — differential value (green): early peak + long decay.
|
|
16
|
-
const fDiff = (x: number) => asym(x, 0.175, 0.24, 0.36);
|
|
17
|
-
const DIFF_DOM: readonly [number, number] = [0, 0.86];
|
|
18
|
-
// Opportunity — operational value (red): bump centred on commodity.
|
|
19
|
-
const fOper = (x: number) => asym(x, 0.85, 0.1, 0.075);
|
|
20
|
-
const OPER_DOM: readonly [number, number] = [0.62, 1];
|
|
21
|
-
// Benefit / investment (signed): big positive bell − small negative bell.
|
|
22
|
-
const fBen = (x: number) => asym(x, 0.49, 0.17, 0.24) - 0.42 * bell(x, 0.1, 0.075);
|
|
23
|
-
|
|
24
|
-
// Evolution-gradient — Simon Wardley's classic evolution presentation: a
|
|
25
|
-
// symmetric grey "U", strong at both evolution extremes (uncharted /
|
|
26
|
-
// industrialised), fading to white through the build middle.
|
|
27
|
-
const fGrey = (x: number) => {
|
|
28
|
-
const left = Math.max(0, (0.26 - x) / 0.26);
|
|
29
|
-
const right = Math.max(0, (x - 0.64) / 0.36);
|
|
30
|
-
return Math.max(left, right);
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
function rangeOf(fn: (x: number) => number, x0: number, x1: number) {
|
|
34
|
-
let lo = Infinity;
|
|
35
|
-
let hi = -Infinity;
|
|
36
|
-
for (let i = 0; i <= 240; i++) {
|
|
37
|
-
const v = fn(x0 + ((x1 - x0) * i) / 240);
|
|
38
|
-
if (v < lo) lo = v;
|
|
39
|
-
if (v > hi) hi = v;
|
|
40
|
-
}
|
|
41
|
-
return { lo, hi };
|
|
42
|
-
}
|
|
43
|
-
const RG = rangeOf(fDiff, DIFF_DOM[0], DIFF_DOM[1]);
|
|
44
|
-
const RR = rangeOf(fOper, OPER_DOM[0], OPER_DOM[1]);
|
|
45
|
-
const RB = rangeOf(fBen, 0, 1);
|
|
46
|
-
|
|
47
|
-
const clamp01 = (v: number) => Math.max(0, Math.min(1, v));
|
|
48
|
-
const norm = (v: number, lo: number, hi: number) => (hi > lo ? (v - lo) / (hi - lo) : 0);
|
|
49
|
-
|
|
50
|
-
export const GRADIENT_GREEN = '#1f9e4d';
|
|
51
|
-
export const GRADIENT_RED = '#d6455d';
|
|
52
|
-
const GRADIENT_GREY = '#7c8389';
|
|
53
|
-
/** Validated peak opacity for the green/red variants. */
|
|
54
|
-
const GRADIENT_MAX_OPACITY = 0.45;
|
|
55
|
-
/** Peak opacity for the grey evolution-gradient variant. */
|
|
56
|
-
const GREY_MAX_OPACITY = 0.38;
|
|
57
|
-
/** Benefit/investment zero-line height (fraction of plot height from bottom). */
|
|
58
|
-
export const BENEFIT_ZERO_FRAC = 0.3;
|
|
59
|
-
|
|
60
|
-
function rgba(hex: string, alpha: number) {
|
|
61
|
-
const r = parseInt(hex.slice(1, 3), 16);
|
|
62
|
-
const g = parseInt(hex.slice(3, 5), 16);
|
|
63
|
-
const b = parseInt(hex.slice(5, 7), 16);
|
|
64
|
-
return `rgba(${r},${g},${b},${alpha})`;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Add stops to a horizontal gradient (offset 0..1 spanning the plot width) for
|
|
69
|
-
* a function-driven opacity profile within [x0, x1] (zero outside).
|
|
70
|
-
*/
|
|
71
|
-
function addStops(
|
|
72
|
-
grad: CanvasGradient,
|
|
73
|
-
hex: string,
|
|
74
|
-
opacityFn: (x: number) => number,
|
|
75
|
-
x0: number,
|
|
76
|
-
x1: number,
|
|
77
|
-
maxOp: number = GRADIENT_MAX_OPACITY
|
|
78
|
-
) {
|
|
79
|
-
const eps = 0.001;
|
|
80
|
-
if (x0 > eps) grad.addColorStop(Math.max(0, x0 - eps), rgba(hex, 0));
|
|
81
|
-
const N = 48;
|
|
82
|
-
for (let i = 0; i <= N; i++) {
|
|
83
|
-
const x = x0 + ((x1 - x0) * i) / N;
|
|
84
|
-
grad.addColorStop(clamp01(x), rgba(hex, clamp01(opacityFn(x)) * maxOp));
|
|
85
|
-
}
|
|
86
|
-
if (x1 < 1 - eps) grad.addColorStop(Math.min(1, x1 + eps), rgba(hex, 0));
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Paint the curve-driven gradient over the plot rectangle [px0,px1]×[py0,py1]
|
|
91
|
-
* in element-local coordinates. `classic` paints nothing.
|
|
92
|
-
*/
|
|
93
|
-
export function paintGradientBackground(
|
|
94
|
-
ctx: CanvasRenderingContext2D,
|
|
95
|
-
variant: 'opportunity' | 'benefit' | 'evolution-gradient',
|
|
96
|
-
px0: number,
|
|
97
|
-
px1: number,
|
|
98
|
-
py0: number,
|
|
99
|
-
py1: number
|
|
100
|
-
) {
|
|
101
|
-
const w = px1 - px0;
|
|
102
|
-
const h = py1 - py0;
|
|
103
|
-
|
|
104
|
-
if (variant === 'evolution-gradient') {
|
|
105
|
-
const grey = ctx.createLinearGradient(px0, 0, px1, 0);
|
|
106
|
-
addStops(grey, GRADIENT_GREY, fGrey, 0, 1, GREY_MAX_OPACITY);
|
|
107
|
-
ctx.fillStyle = grey;
|
|
108
|
-
ctx.fillRect(px0, py0, w, h);
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if (variant === 'opportunity') {
|
|
113
|
-
const green = ctx.createLinearGradient(px0, 0, px1, 0);
|
|
114
|
-
addStops(green, GRADIENT_GREEN, x => norm(fDiff(x), RG.lo, RG.hi), DIFF_DOM[0], DIFF_DOM[1]);
|
|
115
|
-
ctx.fillStyle = green;
|
|
116
|
-
ctx.fillRect(px0, py0, w, h);
|
|
117
|
-
|
|
118
|
-
const red = ctx.createLinearGradient(px0, 0, px1, 0);
|
|
119
|
-
addStops(red, GRADIENT_RED, x => norm(fOper(x), RR.lo, RR.hi), OPER_DOM[0], OPER_DOM[1]);
|
|
120
|
-
ctx.fillStyle = red;
|
|
121
|
-
ctx.fillRect(px0, py0, w, h);
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// benefit: green where the curve is positive, red where negative.
|
|
126
|
-
const maxPos = RB.hi;
|
|
127
|
-
const maxNeg = -RB.lo;
|
|
128
|
-
const green = ctx.createLinearGradient(px0, 0, px1, 0);
|
|
129
|
-
addStops(green, GRADIENT_GREEN, x => Math.max(0, fBen(x)) / maxPos, 0, 1);
|
|
130
|
-
ctx.fillStyle = green;
|
|
131
|
-
ctx.fillRect(px0, py0, w, h);
|
|
132
|
-
|
|
133
|
-
const red = ctx.createLinearGradient(px0, 0, px1, 0);
|
|
134
|
-
addStops(red, GRADIENT_RED, x => Math.max(0, -fBen(x)) / maxNeg, 0, 1);
|
|
135
|
-
ctx.fillStyle = red;
|
|
136
|
-
ctx.fillRect(px0, py0, w, h);
|
|
137
|
-
}
|
package/src/label-layout.ts
DELETED
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
import type { WardleyBackgroundElementModel } from '@formicoidea/labre-core/model';
|
|
2
|
-
|
|
3
|
-
import { FONTS, MARGIN, OFFSETS } from './consts';
|
|
4
|
-
|
|
5
|
-
/** The editable label fields of a Wardley background. */
|
|
6
|
-
export type WardleyLabelField =
|
|
7
|
-
| 'xAxisTitle'
|
|
8
|
-
| 'yAxisTitle'
|
|
9
|
-
| 'evolutionStart'
|
|
10
|
-
| 'evolutionEnd'
|
|
11
|
-
| 'visibilityHigh'
|
|
12
|
-
| 'visibilityLow'
|
|
13
|
-
| 'phase0'
|
|
14
|
-
| 'phase1'
|
|
15
|
-
| 'phase2'
|
|
16
|
-
| 'phase3';
|
|
17
|
-
|
|
18
|
-
/** A label's hit box in element-local coordinates (axis-aligned, padded). */
|
|
19
|
-
export interface WardleyLabelHit {
|
|
20
|
-
field: WardleyLabelField;
|
|
21
|
-
minX: number;
|
|
22
|
-
minY: number;
|
|
23
|
-
maxX: number;
|
|
24
|
-
maxY: number;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Rough per-character advance — only used to size generous hit boxes. */
|
|
28
|
-
const approxTextWidth = (text: string, fontSize: number) =>
|
|
29
|
-
Math.max(fontSize, text.length * fontSize * 0.6);
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Compute the clickable boxes of every *visible* Wardley label, in the
|
|
33
|
-
* element's local space. Positions are derived from the SAME constants the
|
|
34
|
-
* renderer uses (`MARGIN` / `OFFSETS` / `FONTS`), so the hit boxes track the
|
|
35
|
-
* drawn text. Boxes are padded so double-clicking is forgiving.
|
|
36
|
-
*/
|
|
37
|
-
export function getWardleyLabelHits(
|
|
38
|
-
model: WardleyBackgroundElementModel,
|
|
39
|
-
w: number,
|
|
40
|
-
h: number
|
|
41
|
-
): WardleyLabelHit[] {
|
|
42
|
-
const px0 = MARGIN.left;
|
|
43
|
-
const px1 = w - MARGIN.right;
|
|
44
|
-
const py0 = MARGIN.top;
|
|
45
|
-
const py1 = h - MARGIN.bottom;
|
|
46
|
-
const ex = (r: number) => px0 + r * (px1 - px0);
|
|
47
|
-
|
|
48
|
-
const pad = 6;
|
|
49
|
-
const hits: WardleyLabelHit[] = [];
|
|
50
|
-
|
|
51
|
-
// Horizontal label anchored on its text baseline.
|
|
52
|
-
const addH = (
|
|
53
|
-
field: WardleyLabelField,
|
|
54
|
-
text: string,
|
|
55
|
-
fontSize: number,
|
|
56
|
-
ax: number,
|
|
57
|
-
baseline: number,
|
|
58
|
-
align: 'left' | 'right'
|
|
59
|
-
) => {
|
|
60
|
-
const tw = approxTextWidth(text, fontSize);
|
|
61
|
-
const minX = align === 'right' ? ax - tw : ax;
|
|
62
|
-
const maxX = align === 'right' ? ax : ax + tw;
|
|
63
|
-
hits.push({
|
|
64
|
-
field,
|
|
65
|
-
minX: minX - pad,
|
|
66
|
-
maxX: maxX + pad,
|
|
67
|
-
minY: baseline - fontSize - pad,
|
|
68
|
-
maxY: baseline + fontSize * 0.3 + pad,
|
|
69
|
-
});
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
// Vertical label (drawn rotated -90°), centered on (ax, ay).
|
|
73
|
-
const addV = (
|
|
74
|
-
field: WardleyLabelField,
|
|
75
|
-
text: string,
|
|
76
|
-
fontSize: number,
|
|
77
|
-
ax: number,
|
|
78
|
-
ay: number
|
|
79
|
-
) => {
|
|
80
|
-
const tw = approxTextWidth(text, fontSize);
|
|
81
|
-
hits.push({
|
|
82
|
-
field,
|
|
83
|
-
minX: ax - fontSize - pad,
|
|
84
|
-
maxX: ax + fontSize * 0.4 + pad,
|
|
85
|
-
minY: ay - tw / 2 - pad,
|
|
86
|
-
maxY: ay + tw / 2 + pad,
|
|
87
|
-
});
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
if (model.showColumnLabels) {
|
|
91
|
-
addH('phase0', model.phase0, FONTS.phase, ex(0) + OFFSETS.phasePad, py1 + OFFSETS.phaseBaseline, 'left');
|
|
92
|
-
addH('phase1', model.phase1, FONTS.phase, ex(0.175) + OFFSETS.phasePad, py1 + OFFSETS.phaseBaseline, 'left');
|
|
93
|
-
addH('phase2', model.phase2, FONTS.phase, ex(0.4) + OFFSETS.phasePad, py1 + OFFSETS.phaseBaseline, 'left');
|
|
94
|
-
addH('phase3', model.phase3, FONTS.phase, ex(0.7) + OFFSETS.phasePad, py1 + OFFSETS.phaseBaseline, 'left');
|
|
95
|
-
}
|
|
96
|
-
if (model.showXAxis) {
|
|
97
|
-
addH('xAxisTitle', model.xAxisTitle, FONTS.axis, px1 - OFFSETS.evolutionPadRight, py1 + OFFSETS.phaseBaseline, 'right');
|
|
98
|
-
}
|
|
99
|
-
if (model.showCornerLabels) {
|
|
100
|
-
addH('evolutionStart', model.evolutionStart, FONTS.direction, px0 + OFFSETS.directionPadLeft, py0 + OFFSETS.directionTop, 'left');
|
|
101
|
-
addH('evolutionEnd', model.evolutionEnd, FONTS.direction, px1 - OFFSETS.directionPadRight, py0 + OFFSETS.directionTop, 'right');
|
|
102
|
-
}
|
|
103
|
-
if (model.showYAxis) {
|
|
104
|
-
addV('yAxisTitle', model.yAxisTitle, FONTS.axis, px0 - OFFSETS.yHug, (py0 + py1) / 2);
|
|
105
|
-
}
|
|
106
|
-
if (model.showVisibilityLabels) {
|
|
107
|
-
addV('visibilityHigh', model.visibilityHigh, FONTS.visibility, px0 - OFFSETS.yHug, py0 + OFFSETS.visibleTop);
|
|
108
|
-
addV('visibilityLow', model.visibilityLow, FONTS.visibility, px0 - OFFSETS.yHug, py1 - OFFSETS.invisibleBottom);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
return hits;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** First label whose (padded) box contains the local point, or null. */
|
|
115
|
-
export function hitTestWardleyLabel(
|
|
116
|
-
hits: WardleyLabelHit[],
|
|
117
|
-
lx: number,
|
|
118
|
-
ly: number
|
|
119
|
-
): WardleyLabelHit | null {
|
|
120
|
-
for (const hit of hits) {
|
|
121
|
-
if (lx >= hit.minX && lx <= hit.maxX && ly >= hit.minY && ly <= hit.maxY) {
|
|
122
|
-
return hit;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
return null;
|
|
126
|
-
}
|