@formicoidea/labre-framework-wardley 0.23.1 → 0.23.2

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.
Files changed (61) hide show
  1. package/package.json +6 -15
  2. package/{dist/consts.js → src/consts.ts} +72 -63
  3. package/{dist/descriptor.js → src/descriptor.ts} +1 -1
  4. package/src/effects.ts +17 -0
  5. package/src/element-renderer.ts +242 -0
  6. package/src/element-view.ts +143 -0
  7. package/src/gradient.ts +137 -0
  8. package/src/index.ts +1 -0
  9. package/src/label-layout.ts +126 -0
  10. package/src/legend.ts +438 -0
  11. package/{dist/node/consts.js → src/node/consts.ts} +109 -101
  12. package/src/node/node-renderer.ts +142 -0
  13. package/{dist/node/node-view.d.ts → src/node/node-view.ts} +11 -11
  14. package/src/templates/index.ts +236 -0
  15. package/src/templates/maps.ts +283 -0
  16. package/src/toolbar/config.ts +280 -0
  17. package/{dist/toolbar/icons.js → src/toolbar/icons.ts} +66 -51
  18. package/{dist/toolbar/node-config.js → src/toolbar/node-config.ts} +28 -21
  19. package/{dist/toolbar/senior-tool.js → src/toolbar/senior-tool.ts} +11 -11
  20. package/src/toolbar/wardley-menu.ts +552 -0
  21. package/src/toolbar/wardley-senior-button.ts +154 -0
  22. package/src/view.ts +39 -0
  23. package/dist/consts.d.ts +0 -72
  24. package/dist/descriptor.d.ts +0 -7
  25. package/dist/effects.d.ts +0 -10
  26. package/dist/effects.js +0 -7
  27. package/dist/element-renderer.d.ts +0 -15
  28. package/dist/element-renderer.js +0 -160
  29. package/dist/element-view.d.ts +0 -21
  30. package/dist/element-view.js +0 -122
  31. package/dist/gradient.d.ts +0 -18
  32. package/dist/gradient.js +0 -112
  33. package/dist/index.d.ts +0 -2
  34. package/dist/index.js +0 -2
  35. package/dist/label-layout.d.ts +0 -21
  36. package/dist/label-layout.js +0 -73
  37. package/dist/legend.d.ts +0 -12
  38. package/dist/legend.js +0 -333
  39. package/dist/node/consts.d.ts +0 -107
  40. package/dist/node/label-editor.d.ts +0 -28
  41. package/dist/node/label-editor.js +0 -216
  42. package/dist/node/node-renderer.d.ts +0 -17
  43. package/dist/node/node-renderer.js +0 -106
  44. package/dist/node/node-view.js +0 -10
  45. package/dist/templates/index.d.ts +0 -3
  46. package/dist/templates/index.js +0 -172
  47. package/dist/templates/maps.d.ts +0 -3
  48. package/dist/templates/maps.js +0 -247
  49. package/dist/toolbar/config.d.ts +0 -75
  50. package/dist/toolbar/config.js +0 -206
  51. package/dist/toolbar/icons.d.ts +0 -31
  52. package/dist/toolbar/node-config.d.ts +0 -2
  53. package/dist/toolbar/senior-tool.d.ts +0 -2
  54. package/dist/toolbar/wardley-menu.d.ts +0 -53
  55. package/dist/toolbar/wardley-menu.js +0 -408
  56. package/dist/toolbar/wardley-senior-button.d.ts +0 -18
  57. package/dist/toolbar/wardley-senior-button.js +0 -146
  58. package/dist/toolbar/wardley-tool-button.d.ts +0 -10
  59. package/dist/toolbar/wardley-tool-button.js +0 -123
  60. package/dist/view.d.ts +0 -7
  61. package/dist/view.js +0 -36
@@ -1,101 +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
- /** Default node diameter (= the map label font size, 18). White fill, thin border. */
11
- export const NODE_SIZE = 18;
12
- export const NODE_FILL = '#ffffff';
13
- export const NODE_STROKE = '#1f2328';
14
- /** Thin border, matching the link / silhouette line weight. */
15
- export const NODE_STROKE_WIDTH = 1;
16
- /**
17
- * Person glyph (`kind: 'anchor'`) ratios of R, mirroring the validated icon
18
- * ANCH-B (circle r6 → head r1.8 at cy-2.6 ; shoulders cubic touching the
19
- * border at ±(4.6, 3.9) with controls at ±(3.8, 0.1)).
20
- */
21
- export const ANCHOR = {
22
- headR: 0.3,
23
- headCY: -0.433,
24
- shoulderEndX: 0.767,
25
- shoulderEndY: 0.65,
26
- shoulderCtrlX: 0.633,
27
- shoulderCtrlY: 0.017,
28
- };
29
- /** Native text label, same family as the axis labels, size 18. */
30
- export const LABEL_FONT_SIZE = 18;
31
- export const LABEL_GAP = 8;
32
- export const LABEL_DEFAULT = { component: 'Component', anchor: 'Anchor' };
33
- /**
34
- * Pipeline defaults. The body is a wide, thin native rect (≈ 1.4× the node
35
- * diameter tall) with a white slightly-transparent fill and a node-weight
36
- * border. The handle is a node-sized square straddling the top edge — the only
37
- * connection point (center anchor). Both reuse the WardleyNode (rect) so they
38
- * stay native and editable; the body is made non-connectable in the model.
39
- */
40
- export const PIPELINE_HEIGHT = Math.round(NODE_SIZE * 1.4); // 25
41
- export const PIPELINE_WIDTH = 120;
42
- /** White ~60% opacity fill only; the 1px border stays opaque. */
43
- export const PIPELINE_FILL = '#ffffff99';
44
- /** Handle square = node diameter. */
45
- export const HANDLE_SIZE = NODE_SIZE;
46
- export const PIPELINE_LABEL = 'Pipeline';
47
- /**
48
- * Connector line width. Connectors are constrained to the LineWidth enum
49
- * {2,4,6,8,10,12}, so the thinnest available (2) is used — as close as possible
50
- * to the 1px node border.
51
- */
52
- export const LINK_STROKE_WIDTH = 2;
53
- /** Wardley red ("future"/evolution) matches the validated arrow icon. */
54
- export const WARDLEY_RED = '#d6455d';
55
- /** Dependency link grey. */
56
- export const LINK_GREY = '#666666';
57
- /** Inertia bar color + size. */
58
- export const INERTIA_COLOR = '#1f2328';
59
- export const INERTIA_SIZE = { w: 8, h: 44 };
60
- /**
61
- * Market node (composite): a large thin-bordered circle containing 3 small
62
- * thick-bordered component nodes wired into a triangle by native connectors.
63
- */
64
- export const MARKET_SIZE = 30;
65
- export const MARKET_DOT_SIZE = 8;
66
- /** Radius of the circle on which the 3 inner node centers sit. */
67
- export const MARKET_DOT_RING = 8;
68
- /** Inner nodes have a thicker border than the outer circle. */
69
- export const MARKET_DOT_STROKE_WIDTH = 2;
70
- /** Triangle connectors: thin + dark, no arrows. */
71
- export const MARKET_LINK_WIDTH = 1;
72
- export const MARKET_LINK_COLOR = NODE_STROKE;
73
- export const MARKET_LABEL = 'Market';
74
- /**
75
- * Ecosystem node: a single connectable circle drawn as a GLYPH — a double border
76
- * at the rim (outer circle + a 2nd inscribed circle with a thin blank band
77
- * between them) and diagonal hatching confined to the inner donut (from the 2nd
78
- * border down to a hollow central circle; the hatch never reaches the outer
79
- * border). Ratios are of R so the glyph scales with the circle.
80
- */
81
- export const ECOSYSTEM_SIZE = 40;
82
- export const ECOSYSTEM = {
83
- secondBorderRatio: 0.88, // 2nd (inscribed) border
84
- centerRatio: 0.43, // central hollow circle
85
- hatchOuterRatio: 0.86, // hatch stays just inside the 2nd border
86
- hatchSpacingRatio: 0.15, // gap between hatch lines
87
- };
88
- export const ECOSYSTEM_LABEL = 'Ecosystem';
89
- /**
90
- * Method node: a component inscribed in a slightly larger outer circle whose
91
- * FILL color encodes the chosen method (editable via the toolbar). Glyph = the
92
- * outer circle (colored fill + border, the base ellipse) + a white component
93
- * circle drawn at its center. Default fill is a neutral grey.
94
- */
95
- export const METHOD_SIZE = 35;
96
- export const METHOD_FILL = '#d9d9d9';
97
- export const METHOD = {
98
- centerRatio: 0.5, // inner white component radius / R
99
- };
100
- export const METHOD_LABEL = 'Component';
101
- //# sourceMappingURL=consts.js.map
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';
@@ -0,0 +1,142 @@
1
+ import {
2
+ type ElementRenderer,
3
+ ElementRendererExtension,
4
+ } from '@formicoidea/labre-core/blocks/surface';
5
+ import { shape as shapeRenderer } from '@formicoidea/labre-core/gfx/shape';
6
+ import { type WardleyNodeElementModel, DefaultTheme } from '@formicoidea/labre-core/model';
7
+
8
+ import { ANCHOR, ECOSYSTEM, METHOD, NODE_FILL } from './consts';
9
+
10
+ /**
11
+ * Renderer for a Wardley node. The circle is drawn by REUSING the native shape
12
+ * renderer (so stroke width, colors and theme behave exactly like a native
13
+ * ellipse). On top of it, a glyph is drawn for two kinds:
14
+ * - `anchor` → an inscribed person (head + shoulders), clipped to the circle.
15
+ * - `ecosystem` → a double border at the rim + diagonal hatching confined to the
16
+ * inner donut + a hollow central circle.
17
+ * All glyph strokes use the model's (editable) stroke color; the white band and
18
+ * the hollow center come from the base fill, so colors stay editable.
19
+ */
20
+ export const wardleyNode: ElementRenderer<WardleyNodeElementModel> = (
21
+ model,
22
+ ctx,
23
+ matrix,
24
+ renderer,
25
+ rc,
26
+ bound
27
+ ) => {
28
+ const [, , w, h] = model.deserializedXYWH;
29
+ const cx = w / 2;
30
+ const cy = h / 2;
31
+
32
+ // Capture the element-local transform BEFORE the shape renderer mutates the
33
+ // matrix, so the glyph can be drawn in the same space afterwards.
34
+ const glyphMatrix = DOMMatrix.fromMatrix(matrix)
35
+ .translateSelf(cx, cy)
36
+ .rotateSelf(model.rotate)
37
+ .translateSelf(-cx, -cy);
38
+
39
+ // Native ellipse (fill / stroke / theme handled natively).
40
+ shapeRenderer(model, ctx, matrix, renderer, rc, bound);
41
+
42
+ if (
43
+ model.kind !== 'anchor' &&
44
+ model.kind !== 'ecosystem' &&
45
+ model.kind !== 'method'
46
+ )
47
+ return;
48
+
49
+ const strokeWidth = model.strokeWidth || 1;
50
+ const R = Math.min(w, h) / 2 - strokeWidth / 2;
51
+ const color = renderer.getColorValue(
52
+ model.strokeColor,
53
+ DefaultTheme.shapeStrokeColor,
54
+ true
55
+ );
56
+
57
+ ctx.setTransform(glyphMatrix);
58
+
59
+ // ── Anchor: person glyph (clipped to the circle) ────────────────────
60
+ if (model.kind === 'anchor') {
61
+ ctx.save();
62
+ ctx.beginPath();
63
+ ctx.arc(cx, cy, R - strokeWidth / 2, 0, Math.PI * 2);
64
+ ctx.clip();
65
+
66
+ ctx.lineWidth = strokeWidth;
67
+ ctx.strokeStyle = color;
68
+ ctx.lineCap = 'round';
69
+ ctx.lineJoin = 'round';
70
+
71
+ // head
72
+ ctx.beginPath();
73
+ ctx.arc(cx, cy + ANCHOR.headCY * R, ANCHOR.headR * R, 0, Math.PI * 2);
74
+ ctx.stroke();
75
+
76
+ // rounded shoulders (extremities sit on the circle border)
77
+ const sx = ANCHOR.shoulderEndX * R;
78
+ const sy = ANCHOR.shoulderEndY * R;
79
+ const kx = ANCHOR.shoulderCtrlX * R;
80
+ const ky = ANCHOR.shoulderCtrlY * R;
81
+ ctx.beginPath();
82
+ ctx.moveTo(cx - sx, cy + sy);
83
+ ctx.bezierCurveTo(cx - kx, cy + ky, cx + kx, cy + ky, cx + sx, cy + sy);
84
+ ctx.stroke();
85
+
86
+ ctx.restore();
87
+ return;
88
+ }
89
+
90
+ // ── Method: a white component inscribed in the colored outer circle ──
91
+ if (model.kind === 'method') {
92
+ const rInner = R * METHOD.centerRatio;
93
+ ctx.fillStyle = NODE_FILL;
94
+ ctx.lineWidth = strokeWidth;
95
+ ctx.strokeStyle = color;
96
+ ctx.beginPath();
97
+ ctx.arc(cx, cy, rInner, 0, Math.PI * 2);
98
+ ctx.fill();
99
+ ctx.stroke();
100
+ return;
101
+ }
102
+
103
+ // ── Ecosystem: double border + hatched inner donut + hollow center ──
104
+ const rBorder2 = R * ECOSYSTEM.secondBorderRatio;
105
+ const rCenter = R * ECOSYSTEM.centerRatio;
106
+ const rHatch = R * ECOSYSTEM.hatchOuterRatio;
107
+
108
+ // Hatch confined to the donut [rCenter, rHatch] (even-odd clip = annulus).
109
+ ctx.save();
110
+ ctx.beginPath();
111
+ ctx.arc(cx, cy, rHatch, 0, Math.PI * 2);
112
+ ctx.moveTo(cx + rCenter, cy);
113
+ ctx.arc(cx, cy, rCenter, 0, Math.PI * 2);
114
+ ctx.clip('evenodd');
115
+
116
+ ctx.lineWidth = Math.max(0.5, strokeWidth * 0.6);
117
+ ctx.strokeStyle = color;
118
+ const step = R * ECOSYSTEM.hatchSpacingRatio;
119
+ for (let d = -2 * R; d <= 2 * R; d += step) {
120
+ ctx.beginPath();
121
+ ctx.moveTo(cx - R, cy - R + d);
122
+ ctx.lineTo(cx + R, cy + R + d);
123
+ ctx.stroke();
124
+ }
125
+ ctx.restore();
126
+
127
+ // Double border (2nd inscribed circle) + central hole border. No fill: the
128
+ // white band and hollow center come from the base ellipse fill.
129
+ ctx.lineWidth = strokeWidth;
130
+ ctx.strokeStyle = color;
131
+ ctx.beginPath();
132
+ ctx.arc(cx, cy, rBorder2, 0, Math.PI * 2);
133
+ ctx.stroke();
134
+ ctx.beginPath();
135
+ ctx.arc(cx, cy, rCenter, 0, Math.PI * 2);
136
+ ctx.stroke();
137
+ };
138
+
139
+ export const WardleyNodeRendererExtension = ElementRendererExtension(
140
+ 'wardleyNode',
141
+ wardleyNode
142
+ );
@@ -1,11 +1,11 @@
1
- import type { WardleyNodeElementModel } from '@formicoidea/labre-core/model';
2
- import { GfxElementModelView } from '@formicoidea/labre-core/std/gfx';
3
- /**
4
- * View for a Wardley node. Registering it ensures `gfx.view.get(model)` returns
5
- * a view (required so move / select / connector interactions work). The label
6
- * is a separate native text element, so no custom editing is needed here.
7
- */
8
- export declare class WardleyNodeView extends GfxElementModelView<WardleyNodeElementModel> {
9
- static type: string;
10
- }
11
- //# sourceMappingURL=node-view.d.ts.map
1
+ import type { WardleyNodeElementModel } from '@formicoidea/labre-core/model';
2
+ import { GfxElementModelView } from '@formicoidea/labre-core/std/gfx';
3
+
4
+ /**
5
+ * View for a Wardley node. Registering it ensures `gfx.view.get(model)` returns
6
+ * a view (required so move / select / connector interactions work). The label
7
+ * is a separate native text element, so no custom editing is needed here.
8
+ */
9
+ export class WardleyNodeView extends GfxElementModelView<WardleyNodeElementModel> {
10
+ static override type: string = 'wardleyNode';
11
+ }
@@ -0,0 +1,236 @@
1
+ import {
2
+ makeTemplateSnapshot,
3
+ type SurfaceElementsJSON,
4
+ surfaceText,
5
+ type Template,
6
+ type TemplateCategory,
7
+ } from '@formicoidea/labre-core/gfx/template';
8
+ import {
9
+ ConnectorMode,
10
+ FontFamily,
11
+ PointStyle,
12
+ ShapeStyle,
13
+ StrokeStyle,
14
+ TextAlign,
15
+ type WardleyBgVariant,
16
+ } from '@formicoidea/labre-core/model';
17
+
18
+ import { REF_WIDTH } from '../consts';
19
+ import { wardleyMaps } from './maps';
20
+ import {
21
+ ECOSYSTEM_SIZE,
22
+ HANDLE_SIZE,
23
+ INERTIA_COLOR,
24
+ INERTIA_SIZE,
25
+ LABEL_FONT_SIZE,
26
+ LINK_GREY,
27
+ LINK_STROKE_WIDTH,
28
+ MARKET_DOT_RING,
29
+ MARKET_DOT_SIZE,
30
+ MARKET_DOT_STROKE_WIDTH,
31
+ MARKET_LINK_COLOR,
32
+ MARKET_LINK_WIDTH,
33
+ MARKET_SIZE,
34
+ METHOD_FILL,
35
+ METHOD_SIZE,
36
+ NODE_FILL,
37
+ NODE_SIZE,
38
+ NODE_STROKE,
39
+ NODE_STROKE_WIDTH,
40
+ PIPELINE_FILL,
41
+ PIPELINE_HEIGHT,
42
+ PIPELINE_WIDTH,
43
+ WARDLEY_RED,
44
+ } from '../node/consts';
45
+
46
+ const VARIANT_DEFAULTS: Record<WardleyBgVariant, Record<string, unknown>> = {
47
+ classic: {},
48
+ opportunity: {
49
+ yAxisTitle: 'Opportunity',
50
+ showVisibilityLabels: false,
51
+ showCornerLabels: false,
52
+ },
53
+ benefit: {
54
+ yAxisTitle: '',
55
+ visibilityHigh: 'Benefit',
56
+ visibilityLow: 'Investment',
57
+ showCornerLabels: false,
58
+ },
59
+ 'evolution-gradient': {},
60
+ };
61
+
62
+ const bg = (variant: WardleyBgVariant, w = REF_WIDTH) => {
63
+ const h = Math.round((w * 9) / 16);
64
+ return { type: 'wardley', variant, ...VARIANT_DEFAULTS[variant], xywh: `[0,0,${w},${h}]` };
65
+ };
66
+
67
+ /** A wardley node ellipse positioned by top-left. */
68
+ function node(
69
+ kind: string,
70
+ x: number,
71
+ y: number,
72
+ d = NODE_SIZE,
73
+ fill = NODE_FILL,
74
+ strokeWidth = NODE_STROKE_WIDTH
75
+ ) {
76
+ return {
77
+ type: 'wardleyNode',
78
+ kind,
79
+ shapeType: 'ellipse',
80
+ filled: true,
81
+ fillColor: fill,
82
+ strokeColor: NODE_STROKE,
83
+ strokeWidth,
84
+ shapeStyle: ShapeStyle.General,
85
+ roughness: 0,
86
+ xywh: `[${x},${y},${d},${d}]`,
87
+ };
88
+ }
89
+
90
+ function label(x: number, y: number, str: string, align: 'left' | 'center' = 'left') {
91
+ return {
92
+ type: 'text',
93
+ text: surfaceText(str),
94
+ color: NODE_STROKE,
95
+ fontFamily: FontFamily.Inter,
96
+ fontSize: LABEL_FONT_SIZE,
97
+ textAlign: align === 'center' ? TextAlign.Center : TextAlign.Left,
98
+ xywh: `[${x},${y},140,26]`,
99
+ };
100
+ }
101
+
102
+ function connect(
103
+ source: Record<string, unknown>,
104
+ target: Record<string, unknown>,
105
+ opts: { red?: boolean } = {}
106
+ ) {
107
+ return {
108
+ type: 'connector',
109
+ mode: ConnectorMode.Straight,
110
+ stroke: opts.red ? WARDLEY_RED : LINK_GREY,
111
+ strokeStyle: opts.red ? StrokeStyle.Dash : StrokeStyle.Solid,
112
+ strokeWidth: LINK_STROKE_WIDTH,
113
+ frontEndpointStyle: PointStyle.None,
114
+ rearEndpointStyle: opts.red ? PointStyle.Triangle : PointStyle.None,
115
+ source,
116
+ target,
117
+ };
118
+ }
119
+
120
+ const inertia = (x = 0, y = 0) => ({
121
+ type: 'shape',
122
+ shapeType: 'rect',
123
+ filled: true,
124
+ fillColor: INERTIA_COLOR,
125
+ strokeColor: INERTIA_COLOR,
126
+ strokeWidth: 0,
127
+ shapeStyle: ShapeStyle.General,
128
+ roughness: 0,
129
+ radius: 0,
130
+ xywh: `[${x},${y},${INERTIA_SIZE.w},${INERTIA_SIZE.h}]`,
131
+ });
132
+
133
+ /** Pipeline composite: body rect + handle square (straddling top) + label. */
134
+ function pipeline(): SurfaceElementsJSON {
135
+ return {
136
+ label: label(0, 0, 'Pipeline', 'center'),
137
+ body: {
138
+ type: 'wardleyNode',
139
+ kind: 'pipeline',
140
+ shapeType: 'rect',
141
+ filled: true,
142
+ fillColor: PIPELINE_FILL,
143
+ strokeColor: NODE_STROKE,
144
+ strokeWidth: NODE_STROKE_WIDTH,
145
+ shapeStyle: ShapeStyle.General,
146
+ roughness: 0,
147
+ radius: 0,
148
+ xywh: `[0,39,${PIPELINE_WIDTH},${PIPELINE_HEIGHT}]`,
149
+ },
150
+ handle: {
151
+ type: 'wardleyNode',
152
+ kind: 'handle',
153
+ shapeType: 'rect',
154
+ filled: true,
155
+ fillColor: NODE_FILL,
156
+ strokeColor: NODE_STROKE,
157
+ strokeWidth: NODE_STROKE_WIDTH,
158
+ shapeStyle: ShapeStyle.General,
159
+ roughness: 0,
160
+ radius: 0,
161
+ xywh: `[${PIPELINE_WIDTH / 2 - HANDLE_SIZE / 2},${39 - HANDLE_SIZE / 2},${HANDLE_SIZE},${HANDLE_SIZE}]`,
162
+ },
163
+ };
164
+ }
165
+
166
+ /** Market composite: outer circle + 3 inner dots wired in a triangle + label. */
167
+ function market(): SurfaceElementsJSON {
168
+ const R = MARKET_SIZE / 2;
169
+ const c = R; // center within the [0,0,MARKET_SIZE,MARKET_SIZE] box
170
+ const rho = MARKET_DOT_RING;
171
+ const sin60 = Math.sqrt(3) / 2;
172
+ const verts = [
173
+ [0, -rho],
174
+ [rho * sin60, rho / 2],
175
+ [-rho * sin60, rho / 2],
176
+ ];
177
+ const dotAt = (vx: number, vy: number) =>
178
+ node('component', c + vx - MARKET_DOT_SIZE / 2, c + vy - MARKET_DOT_SIZE / 2, MARKET_DOT_SIZE, NODE_FILL, MARKET_DOT_STROKE_WIDTH);
179
+ const tri = (a: string, b: string) => ({
180
+ type: 'connector',
181
+ mode: ConnectorMode.Straight,
182
+ stroke: MARKET_LINK_COLOR,
183
+ strokeStyle: StrokeStyle.Solid,
184
+ strokeWidth: MARKET_LINK_WIDTH,
185
+ frontEndpointStyle: PointStyle.None,
186
+ rearEndpointStyle: PointStyle.None,
187
+ source: { id: a },
188
+ target: { id: b },
189
+ });
190
+ return {
191
+ circle: node('market', 0, 0, MARKET_SIZE, NODE_FILL),
192
+ d0: dotAt(verts[0][0], verts[0][1]),
193
+ d1: dotAt(verts[1][0], verts[1][1]),
194
+ d2: dotAt(verts[2][0], verts[2][1]),
195
+ t0: tri('d0', 'd1'),
196
+ t1: tri('d1', 'd2'),
197
+ t2: tri('d2', 'd0'),
198
+ label: label(MARKET_SIZE + 8, 2, 'Market'),
199
+ };
200
+ }
201
+
202
+ const single = (el: Record<string, unknown>): SurfaceElementsJSON => ({ a: el });
203
+ const nodeWithLabel = (kind: string, d: number, fill: string, name: string): SurfaceElementsJSON => ({
204
+ n: node(kind, 0, 0, d, fill),
205
+ l: label(d + 8, d / 2 - 13, name),
206
+ });
207
+
208
+ const ATTRS = 'width="100%" height="100%" viewBox="0 0 135 80" xmlns="http://www.w3.org/2000/svg"';
209
+ const bgPreview = (extra = '') =>
210
+ `<svg ${ATTRS} fill="none"><path d="M22 12 V64 H120" stroke="#3b3d42" stroke-width="2"/><path d="M44 12 V64 M68 12 V64 M94 12 V64" stroke="#9aa0a6" stroke-width="0.8"/>${extra}</svg>`;
211
+ const dotPreview = (fill: string, sw = 2) =>
212
+ `<svg ${ATTRS} fill="none"><circle cx="67" cy="40" r="13" fill="${fill}" stroke="#1f2328" stroke-width="${sw}"/></svg>`;
213
+
214
+ function tpl(name: string, preview: string, elements: SurfaceElementsJSON): Template {
215
+ return { name, type: 'template', preview, content: makeTemplateSnapshot(elements, name) };
216
+ }
217
+
218
+ export const wardleyTemplateCategory: TemplateCategory = {
219
+ name: 'Wardley',
220
+ templates: [
221
+ ...wardleyMaps,
222
+ tpl('Map background', bgPreview(), { bg: bg('classic') }),
223
+ tpl('Opportunity gradient', bgPreview('<rect x="22" y="12" width="98" height="52" fill="#eef4fb" opacity="0.6"/>'), { bg: bg('opportunity') }),
224
+ tpl('Benefit gradient', bgPreview('<rect x="22" y="12" width="98" height="26" fill="#e6eef8" opacity="0.6"/>'), { bg: bg('benefit') }),
225
+ tpl('Evolution gradient', bgPreview('<rect x="22" y="12" width="98" height="52" fill="#e3e2e4" opacity="0.5"/>'), { bg: bg('evolution-gradient') }),
226
+ tpl('Component', dotPreview('#ffffff', 1.5), nodeWithLabel('component', NODE_SIZE, NODE_FILL, 'Component')),
227
+ tpl('Anchor', `<svg ${ATTRS} fill="none"><circle cx="67" cy="40" r="13" fill="#fff" stroke="#1f2328" stroke-width="1.5"/><circle cx="67" cy="36" r="3.5" fill="#1f2328"/><path d="M59 48 q8 -9 16 0" stroke="#1f2328" stroke-width="1.5" fill="none"/></svg>`, nodeWithLabel('anchor', NODE_SIZE, NODE_FILL, 'Anchor')),
228
+ tpl('Ecosystem', `<svg ${ATTRS} fill="none"><circle cx="67" cy="40" r="15" fill="#fff" stroke="#1f2328" stroke-width="1.5"/><circle cx="67" cy="40" r="11" fill="none" stroke="#1f2328"/><circle cx="67" cy="40" r="5" fill="#fff" stroke="#1f2328"/></svg>`, nodeWithLabel('ecosystem', ECOSYSTEM_SIZE, NODE_FILL, 'Ecosystem')),
229
+ tpl('Method', `<svg ${ATTRS} fill="none"><circle cx="67" cy="40" r="15" fill="#d9d9d9" stroke="#1f2328" stroke-width="1.5"/><circle cx="67" cy="40" r="7" fill="#fff" stroke="#1f2328"/></svg>`, nodeWithLabel('method', METHOD_SIZE, METHOD_FILL, 'Component')),
230
+ tpl('Pipeline', `<svg ${ATTRS} fill="none"><rect x="34" y="40" width="66" height="14" fill="#fff" stroke="#1f2328"/><rect x="60" y="33" width="14" height="14" fill="#fff" stroke="#1f2328"/></svg>`, pipeline()),
231
+ tpl('Market', `<svg ${ATTRS} fill="none"><circle cx="67" cy="40" r="16" fill="#fff" stroke="#1f2328"/><circle cx="67" cy="30" r="3.5" fill="#fff" stroke="#1f2328" stroke-width="1.5"/><circle cx="75" cy="46" r="3.5" fill="#fff" stroke="#1f2328" stroke-width="1.5"/><circle cx="59" cy="46" r="3.5" fill="#fff" stroke="#1f2328" stroke-width="1.5"/><path d="M67 30 L75 46 L59 46 Z" stroke="#1f2328" stroke-width="0.8" fill="none"/></svg>`, market()),
232
+ tpl('Inertia', `<svg ${ATTRS} fill="none"><rect x="63" y="22" width="8" height="36" fill="#1f2328"/></svg>`, single(inertia())),
233
+ tpl('Link', `<svg ${ATTRS} fill="none"><path d="M24 40 H110" stroke="#666" stroke-width="2.4"/></svg>`, single(connect({ position: [0, 0] }, { position: [160, 0] }))),
234
+ tpl('Evolution arrow', `<svg ${ATTRS} fill="none"><path d="M24 40 H100" stroke="#d6455d" stroke-width="2.4" stroke-dasharray="6 4"/><path d="M98 33 L112 40 L98 47 Z" fill="#d6455d"/></svg>`, single(connect({ position: [0, 0] }, { position: [160, 0] }, { red: true }))),
235
+ ],
236
+ };