@formicoidea/labre-framework-wardley 0.23.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.
@@ -0,0 +1,126 @@
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
+ }
package/src/legend.ts ADDED
@@ -0,0 +1,438 @@
1
+ import { createGroupCommand } from '@formicoidea/labre-core/gfx/group';
2
+ import {
3
+ ConnectorElementModel,
4
+ ConnectorMode,
5
+ FontFamily,
6
+ PointStyle,
7
+ ShapeElementModel,
8
+ ShapeStyle,
9
+ StrokeStyle,
10
+ type WardleyBackgroundElementModel,
11
+ WardleyNodeElementModel,
12
+ } from '@formicoidea/labre-core/model';
13
+ import { Bound } from '@formicoidea/labre-core/global/gfx';
14
+ import type { BlockStdScope } from '@formicoidea/labre-core/std';
15
+ import { GfxControllerIdentifier } from '@formicoidea/labre-core/std/gfx';
16
+
17
+ import { GRADIENT_GREEN, GRADIENT_RED } from './gradient';
18
+ import {
19
+ INERTIA_COLOR,
20
+ LINK_GREY,
21
+ LINK_STROKE_WIDTH,
22
+ MARKET_DOT_STROKE_WIDTH,
23
+ MARKET_LINK_COLOR,
24
+ MARKET_LINK_WIDTH,
25
+ METHOD_FILL,
26
+ NODE_FILL,
27
+ NODE_STROKE,
28
+ NODE_STROKE_WIDTH,
29
+ PIPELINE_FILL,
30
+ WARDLEY_RED,
31
+ } from './node/consts';
32
+
33
+ /** Component kinds the legend can describe, in display order. */
34
+ type LegendType =
35
+ | 'component'
36
+ | 'anchor'
37
+ | 'market'
38
+ | 'ecosystem'
39
+ | 'method'
40
+ | 'pipeline'
41
+ | 'link'
42
+ | 'arrow'
43
+ | 'inertia';
44
+
45
+ const LEGEND_ORDER: LegendType[] = [
46
+ 'component',
47
+ 'anchor',
48
+ 'market',
49
+ 'ecosystem',
50
+ 'method',
51
+ 'pipeline',
52
+ 'link',
53
+ 'arrow',
54
+ 'inertia',
55
+ ];
56
+
57
+ /** Default (editable) descriptions for each legend row. */
58
+ const LEGEND_DESC: Record<LegendType, string> = {
59
+ component: 'Need / capability (activity, practice, data…)',
60
+ anchor: 'Stakeholder (customer, user…)',
61
+ market: 'Market (set of actors)',
62
+ ecosystem: 'Ecosystem',
63
+ method: 'Component + method (color = phase)',
64
+ pipeline: 'Pipeline (possible choices for a capability)',
65
+ link: 'Need relation (parent → child)',
66
+ arrow: 'Evolution / movement (red = future)',
67
+ inertia: 'Inertia to change',
68
+ };
69
+
70
+ type GradientVariant = Exclude<WardleyBackgroundElementModel['variant'], 'classic'>;
71
+
72
+ /** Gradient-meaning block, keyed by variant (caption + 2-colour swatch). */
73
+ const LEGEND_GRADIENT: Record<
74
+ GradientVariant,
75
+ { caption: string; swatch: [string, string] }
76
+ > = {
77
+ opportunity: {
78
+ caption:
79
+ 'Opportunity gradient: differential value (green) vs operational value (red).',
80
+ swatch: [GRADIENT_GREEN, GRADIENT_RED],
81
+ },
82
+ benefit: {
83
+ caption: 'Gradient: investment (red) then benefit (green).',
84
+ swatch: [GRADIENT_RED, GRADIENT_GREEN],
85
+ },
86
+ 'evolution-gradient': {
87
+ caption:
88
+ "Gradient representing the growth of Wardley's evolution function.",
89
+ swatch: ['#9aa0a6', '#cfd2d6'],
90
+ },
91
+ };
92
+
93
+ /**
94
+ * Build a "Legend" group from real, editable elements (white rect frame +
95
+ * "Legend" text + one row of [real component glyph + description text] per
96
+ * Wardley component TYPE present inside the background's perimeter + a
97
+ * gradient-meaning block when the background is a gradient variant). A snapshot
98
+ * is created on each call; everything is grouped so it can be moved / resized /
99
+ * edited and is dropped bottom-left of the background.
100
+ */
101
+ export function createWardleyLegend(
102
+ std: BlockStdScope,
103
+ bg: WardleyBackgroundElementModel
104
+ ) {
105
+ const gfx = std.get(GfxControllerIdentifier);
106
+ const surface = gfx.surface;
107
+ if (!surface) return;
108
+
109
+ const [bx, by, , bh] = bg.deserializedXYWH;
110
+
111
+ // 1. Detect which component types are present inside the perimeter.
112
+ const present = new Set<LegendType>();
113
+ for (const el of gfx.getElementsByBound(Bound.deserialize(bg.xywh), {
114
+ type: 'canvas',
115
+ })) {
116
+ // Note: WardleyNodeElementModel extends ShapeElementModel, so the order of
117
+ // these instanceof checks matters.
118
+ if (el instanceof WardleyNodeElementModel) {
119
+ if (el.kind !== 'handle') present.add(el.kind);
120
+ } else if (el instanceof ConnectorElementModel) {
121
+ if (el.strokeStyle === StrokeStyle.Dash || el.stroke === WARDLEY_RED) {
122
+ present.add('arrow');
123
+ } else if (el.stroke === LINK_GREY) {
124
+ present.add('link');
125
+ }
126
+ // market triangle connectors (NODE_STROKE) are ignored.
127
+ } else if (el instanceof ShapeElementModel) {
128
+ if (el.fillColor === INERTIA_COLOR) present.add('inertia');
129
+ }
130
+ }
131
+ const rows = LEGEND_ORDER.filter(t => present.has(t));
132
+
133
+ // 2. Layout (model units). The text column is wide enough for one-line
134
+ // descriptions; the gradient row is taller as its caption may wrap.
135
+ const PAD = 16;
136
+ const TITLE_H = 28;
137
+ const ROW_H = 30;
138
+ const GLYPH_W = 46;
139
+ const GAP = 12;
140
+ const TEXT_FS = 15;
141
+ const TITLE_FS = 18;
142
+ const TEXT_W = 360;
143
+ const GRAD_ROW_H = 40;
144
+ const W = PAD * 2 + GLYPH_W + GAP + TEXT_W;
145
+
146
+ const variant = bg.variant;
147
+ const grad = variant !== 'classic' ? LEGEND_GRADIENT[variant] : null;
148
+ const gradH = grad ? 12 + GRAD_ROW_H : 0;
149
+ const H = PAD * 2 + TITLE_H + rows.length * ROW_H + gradH;
150
+
151
+ const x0 = bx + 50;
152
+ const y0 = by + bh - 56 - H;
153
+
154
+ const text = (
155
+ t: string,
156
+ x: number,
157
+ y: number,
158
+ w: number,
159
+ h: number,
160
+ fontSize: number,
161
+ align: 'left' | 'center' = 'left'
162
+ ) =>
163
+ surface.addElement({
164
+ type: 'text',
165
+ text: t,
166
+ fontFamily: FontFamily.Inter,
167
+ fontSize,
168
+ color: NODE_STROKE,
169
+ textAlign: align,
170
+ xywh: new Bound(x, y, w, h).serialize(),
171
+ });
172
+
173
+ // ── glyph builders (real, editable elements), centred on (cx, cy) ─────
174
+ const ellipse = (
175
+ kind: 'component' | 'anchor' | 'ecosystem' | 'method',
176
+ d: number,
177
+ fill: string,
178
+ sw: number,
179
+ cx: number,
180
+ cy: number
181
+ ) =>
182
+ surface.addElement({
183
+ type: 'wardleyNode',
184
+ kind,
185
+ shapeType: 'ellipse',
186
+ filled: true,
187
+ fillColor: fill,
188
+ strokeColor: NODE_STROKE,
189
+ strokeWidth: sw,
190
+ shapeStyle: ShapeStyle.General,
191
+ roughness: 0,
192
+ xywh: new Bound(cx - d / 2, cy - d / 2, d, d).serialize(),
193
+ });
194
+
195
+ const glyph = (type: LegendType, cx: number, cy: number): string[] => {
196
+ switch (type) {
197
+ case 'component':
198
+ return [ellipse('component', 16, NODE_FILL, NODE_STROKE_WIDTH, cx, cy)];
199
+ case 'anchor':
200
+ return [ellipse('anchor', 16, NODE_FILL, NODE_STROKE_WIDTH, cx, cy)];
201
+ case 'ecosystem':
202
+ return [ellipse('ecosystem', 20, NODE_FILL, NODE_STROKE_WIDTH, cx, cy)];
203
+ case 'method':
204
+ return [ellipse('method', 18, METHOD_FILL, NODE_STROKE_WIDTH, cx, cy)];
205
+ case 'inertia':
206
+ return [
207
+ surface.addElement({
208
+ type: 'shape',
209
+ shapeType: 'rect',
210
+ filled: true,
211
+ fillColor: INERTIA_COLOR,
212
+ strokeColor: INERTIA_COLOR,
213
+ strokeWidth: 0,
214
+ shapeStyle: ShapeStyle.General,
215
+ roughness: 0,
216
+ radius: 0,
217
+ xywh: new Bound(cx - 2.5, cy - 11, 5, 22).serialize(),
218
+ }),
219
+ ];
220
+ case 'pipeline': {
221
+ const bw2 = 34;
222
+ const bh2 = 12;
223
+ const hd = 10;
224
+ const top = cy - bh2 / 2;
225
+ return [
226
+ surface.addElement({
227
+ type: 'wardleyNode',
228
+ kind: 'pipeline',
229
+ shapeType: 'rect',
230
+ filled: true,
231
+ fillColor: PIPELINE_FILL,
232
+ strokeColor: NODE_STROKE,
233
+ strokeWidth: NODE_STROKE_WIDTH,
234
+ shapeStyle: ShapeStyle.General,
235
+ roughness: 0,
236
+ radius: 0,
237
+ xywh: new Bound(cx - bw2 / 2, top, bw2, bh2).serialize(),
238
+ }),
239
+ surface.addElement({
240
+ type: 'wardleyNode',
241
+ kind: 'handle',
242
+ shapeType: 'rect',
243
+ filled: true,
244
+ fillColor: NODE_FILL,
245
+ strokeColor: NODE_STROKE,
246
+ strokeWidth: NODE_STROKE_WIDTH,
247
+ shapeStyle: ShapeStyle.General,
248
+ roughness: 0,
249
+ radius: 0,
250
+ xywh: new Bound(cx - hd / 2, top - hd / 2, hd, hd).serialize(),
251
+ }),
252
+ ];
253
+ }
254
+ case 'market': {
255
+ const R = 11;
256
+ const dr = 3;
257
+ const rho = 6;
258
+ const sin60 = Math.sqrt(3) / 2;
259
+ const circle = surface.addElement({
260
+ type: 'wardleyNode',
261
+ kind: 'market',
262
+ shapeType: 'ellipse',
263
+ filled: true,
264
+ fillColor: NODE_FILL,
265
+ strokeColor: NODE_STROKE,
266
+ strokeWidth: NODE_STROKE_WIDTH,
267
+ shapeStyle: ShapeStyle.General,
268
+ roughness: 0,
269
+ xywh: new Bound(cx - R, cy - R, R * 2, R * 2).serialize(),
270
+ });
271
+ const verts = [
272
+ [0, -rho],
273
+ [rho * sin60, rho / 2],
274
+ [-rho * sin60, rho / 2],
275
+ ];
276
+ const dots = verts.map(([vx, vy]) =>
277
+ surface.addElement({
278
+ type: 'wardleyNode',
279
+ kind: 'component',
280
+ shapeType: 'ellipse',
281
+ filled: true,
282
+ fillColor: NODE_FILL,
283
+ strokeColor: NODE_STROKE,
284
+ strokeWidth: MARKET_DOT_STROKE_WIDTH,
285
+ shapeStyle: ShapeStyle.General,
286
+ roughness: 0,
287
+ xywh: new Bound(cx + vx - dr, cy + vy - dr, dr * 2, dr * 2).serialize(),
288
+ })
289
+ );
290
+ const conns = [
291
+ [dots[0], dots[1]],
292
+ [dots[1], dots[2]],
293
+ [dots[2], dots[0]],
294
+ ].map(([a, b]) =>
295
+ surface.addElement({
296
+ type: 'connector',
297
+ mode: ConnectorMode.Straight,
298
+ source: { id: a },
299
+ target: { id: b },
300
+ stroke: MARKET_LINK_COLOR,
301
+ strokeStyle: StrokeStyle.Solid,
302
+ strokeWidth: MARKET_LINK_WIDTH,
303
+ frontEndpointStyle: PointStyle.None,
304
+ rearEndpointStyle: PointStyle.None,
305
+ })
306
+ );
307
+ return [circle, ...dots, ...conns];
308
+ }
309
+ case 'link':
310
+ return [
311
+ surface.addElement({
312
+ type: 'connector',
313
+ mode: ConnectorMode.Straight,
314
+ source: { position: [cx - 18, cy + 6] },
315
+ target: { position: [cx + 18, cy - 6] },
316
+ stroke: LINK_GREY,
317
+ strokeStyle: StrokeStyle.Solid,
318
+ strokeWidth: LINK_STROKE_WIDTH,
319
+ frontEndpointStyle: PointStyle.None,
320
+ rearEndpointStyle: PointStyle.None,
321
+ }),
322
+ ];
323
+ case 'arrow':
324
+ return [
325
+ surface.addElement({
326
+ type: 'connector',
327
+ mode: ConnectorMode.Straight,
328
+ source: { position: [cx - 18, cy] },
329
+ target: { position: [cx + 16, cy] },
330
+ stroke: WARDLEY_RED,
331
+ strokeStyle: StrokeStyle.Dash,
332
+ strokeWidth: LINK_STROKE_WIDTH,
333
+ frontEndpointStyle: PointStyle.None,
334
+ rearEndpointStyle: PointStyle.Triangle,
335
+ }),
336
+ ];
337
+ }
338
+ };
339
+
340
+ // 3. Create the elements.
341
+ std.store.captureSync();
342
+ const ids: string[] = [];
343
+
344
+ // White frame.
345
+ ids.push(
346
+ surface.addElement({
347
+ type: 'shape',
348
+ shapeType: 'rect',
349
+ filled: true,
350
+ fillColor: '#ffffff',
351
+ strokeColor: '#cfd2d6',
352
+ strokeWidth: 1,
353
+ shapeStyle: ShapeStyle.General,
354
+ roughness: 0,
355
+ radius: 6,
356
+ xywh: new Bound(x0, y0, W, H).serialize(),
357
+ })
358
+ );
359
+
360
+ // Title.
361
+ ids.push(
362
+ text('Legend', x0 + PAD, y0 + PAD, W - PAD * 2, TITLE_FS + 6, TITLE_FS)
363
+ );
364
+
365
+ // Rows.
366
+ let ry = y0 + PAD + TITLE_H;
367
+ for (const t of rows) {
368
+ const cyRow = ry + ROW_H / 2;
369
+ ids.push(...glyph(t, x0 + PAD + GLYPH_W / 2, cyRow));
370
+ ids.push(
371
+ text(
372
+ LEGEND_DESC[t],
373
+ x0 + PAD + GLYPH_W + GAP,
374
+ cyRow - (TEXT_FS + 8) / 2,
375
+ TEXT_W,
376
+ TEXT_FS + 8,
377
+ TEXT_FS
378
+ )
379
+ );
380
+ ry += ROW_H;
381
+ }
382
+
383
+ // Gradient meaning block: a separator, then [2-colour swatch | caption].
384
+ if (grad) {
385
+ const sepY = ry + 4;
386
+ ids.push(
387
+ surface.addElement({
388
+ type: 'shape',
389
+ shapeType: 'rect',
390
+ filled: true,
391
+ fillColor: '#cfd2d6',
392
+ strokeColor: '#cfd2d6',
393
+ strokeWidth: 0,
394
+ shapeStyle: ShapeStyle.General,
395
+ roughness: 0,
396
+ radius: 0,
397
+ xywh: new Bound(x0 + PAD, sepY, W - PAD * 2, 1).serialize(),
398
+ })
399
+ );
400
+ const cyRow = sepY + 8 + GRAD_ROW_H / 2;
401
+ const sw = 14;
402
+ const sgap = 2;
403
+ const sx = x0 + PAD + GLYPH_W / 2 - (sw * 2 + sgap) / 2;
404
+ grad.swatch.forEach((col, i) => {
405
+ ids.push(
406
+ surface.addElement({
407
+ type: 'shape',
408
+ shapeType: 'rect',
409
+ filled: true,
410
+ fillColor: col,
411
+ strokeColor: '#cfd2d6',
412
+ strokeWidth: 0.5,
413
+ shapeStyle: ShapeStyle.General,
414
+ roughness: 0,
415
+ radius: 1,
416
+ xywh: new Bound(sx + i * (sw + sgap), cyRow - sw / 2, sw, sw).serialize(),
417
+ })
418
+ );
419
+ });
420
+ ids.push(
421
+ text(
422
+ grad.caption,
423
+ x0 + PAD + GLYPH_W + GAP,
424
+ cyRow - GRAD_ROW_H / 2,
425
+ TEXT_W,
426
+ GRAD_ROW_H,
427
+ TEXT_FS
428
+ )
429
+ );
430
+ }
431
+
432
+ // 4. Group everything and select it.
433
+ const [, result] = std.command.exec(createGroupCommand, { elements: ids });
434
+ gfx.selection.set({
435
+ elements: [result.groupId || ids[0]],
436
+ editing: false,
437
+ });
438
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Visual constants for Wardley nodes (component + anchor) and the connector /
3
+ * inertia presets created from the Wardley menu.
4
+ *
5
+ * The node is a NATIVE ellipse (ShapeElementModel-derived) so its stroke
6
+ * width / colors are editable via the shape toolbar; these are just the
7
+ * pre-formatted defaults at creation. The person glyph (anchor) is expressed as
8
+ * ratios of the circle radius R so it stays proportional at any size.
9
+ */
10
+
11
+ /** Default node diameter (= the map label font size, 18). White fill, thin border. */
12
+ export const NODE_SIZE = 18;
13
+ export const NODE_FILL = '#ffffff';
14
+ export const NODE_STROKE = '#1f2328';
15
+ /** Thin border, matching the link / silhouette line weight. */
16
+ export const NODE_STROKE_WIDTH = 1;
17
+
18
+ /**
19
+ * Person glyph (`kind: 'anchor'`) ratios of R, mirroring the validated icon
20
+ * ANCH-B (circle r6 → head r1.8 at cy-2.6 ; shoulders cubic touching the
21
+ * border at ±(4.6, 3.9) with controls at ±(3.8, 0.1)).
22
+ */
23
+ export const ANCHOR = {
24
+ headR: 0.3,
25
+ headCY: -0.433,
26
+ shoulderEndX: 0.767,
27
+ shoulderEndY: 0.65,
28
+ shoulderCtrlX: 0.633,
29
+ shoulderCtrlY: 0.017,
30
+ };
31
+
32
+ /** Native text label, same family as the axis labels, size 18. */
33
+ export const LABEL_FONT_SIZE = 18;
34
+ export const LABEL_GAP = 8;
35
+ export const LABEL_DEFAULT = { component: 'Component', anchor: 'Anchor' };
36
+
37
+ /**
38
+ * Pipeline defaults. The body is a wide, thin native rect (≈ 1.4× the node
39
+ * diameter tall) with a white slightly-transparent fill and a node-weight
40
+ * border. The handle is a node-sized square straddling the top edge — the only
41
+ * connection point (center anchor). Both reuse the WardleyNode (rect) so they
42
+ * stay native and editable; the body is made non-connectable in the model.
43
+ */
44
+ export const PIPELINE_HEIGHT = Math.round(NODE_SIZE * 1.4); // 25
45
+ export const PIPELINE_WIDTH = 120;
46
+ /** White ~60% opacity — fill only; the 1px border stays opaque. */
47
+ export const PIPELINE_FILL = '#ffffff99';
48
+ /** Handle square = node diameter. */
49
+ export const HANDLE_SIZE = NODE_SIZE;
50
+ export const PIPELINE_LABEL = 'Pipeline';
51
+
52
+ /**
53
+ * Connector line width. Connectors are constrained to the LineWidth enum
54
+ * {2,4,6,8,10,12}, so the thinnest available (2) is used — as close as possible
55
+ * to the 1px node border.
56
+ */
57
+ export const LINK_STROKE_WIDTH = 2;
58
+
59
+ /** Wardley red ("future"/evolution) — matches the validated arrow icon. */
60
+ export const WARDLEY_RED = '#d6455d';
61
+ /** Dependency link grey. */
62
+ export const LINK_GREY = '#666666';
63
+ /** Inertia bar color + size. */
64
+ export const INERTIA_COLOR = '#1f2328';
65
+ export const INERTIA_SIZE = { w: 8, h: 44 };
66
+
67
+ /**
68
+ * Market node (composite): a large thin-bordered circle containing 3 small
69
+ * thick-bordered component nodes wired into a triangle by native connectors.
70
+ */
71
+ export const MARKET_SIZE = 30;
72
+ export const MARKET_DOT_SIZE = 8;
73
+ /** Radius of the circle on which the 3 inner node centers sit. */
74
+ export const MARKET_DOT_RING = 8;
75
+ /** Inner nodes have a thicker border than the outer circle. */
76
+ export const MARKET_DOT_STROKE_WIDTH = 2;
77
+ /** Triangle connectors: thin + dark, no arrows. */
78
+ export const MARKET_LINK_WIDTH = 1;
79
+ export const MARKET_LINK_COLOR = NODE_STROKE;
80
+ export const MARKET_LABEL = 'Market';
81
+
82
+ /**
83
+ * Ecosystem node: a single connectable circle drawn as a GLYPH — a double border
84
+ * at the rim (outer circle + a 2nd inscribed circle with a thin blank band
85
+ * between them) and diagonal hatching confined to the inner donut (from the 2nd
86
+ * border down to a hollow central circle; the hatch never reaches the outer
87
+ * border). Ratios are of R so the glyph scales with the circle.
88
+ */
89
+ export const ECOSYSTEM_SIZE = 40;
90
+ export const ECOSYSTEM = {
91
+ secondBorderRatio: 0.88, // 2nd (inscribed) border
92
+ centerRatio: 0.43, // central hollow circle
93
+ hatchOuterRatio: 0.86, // hatch stays just inside the 2nd border
94
+ hatchSpacingRatio: 0.15, // gap between hatch lines
95
+ };
96
+ export const ECOSYSTEM_LABEL = 'Ecosystem';
97
+
98
+ /**
99
+ * Method node: a component inscribed in a slightly larger outer circle whose
100
+ * FILL color encodes the chosen method (editable via the toolbar). Glyph = the
101
+ * outer circle (colored fill + border, the base ellipse) + a white component
102
+ * circle drawn at its center. Default fill is a neutral grey.
103
+ */
104
+ export const METHOD_SIZE = 35;
105
+ export const METHOD_FILL = '#d9d9d9';
106
+ export const METHOD = {
107
+ centerRatio: 0.5, // inner white component radius / R
108
+ };
109
+ export const METHOD_LABEL = 'Component';