@formicoidea/labre-framework-wardley 0.34.2 → 0.36.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.
package/dist/actions.d.ts CHANGED
@@ -1,42 +1,25 @@
1
1
  import { WardleyBackgroundElementModel, type WardleyBgVariant } from '@formicoidea/labre-core/model';
2
+ import { Bound } from '@formicoidea/labre-core/global/gfx';
2
3
  import type { BlockStdScope } from '@formicoidea/labre-core/std';
3
4
  import { type GfxController } from '@formicoidea/labre-core/std/gfx';
4
5
  import { type WardleyExportBoard } from './export.js';
6
+ import { type WardleyAreaShape, type WardleyArtefactKind } from './presets.js';
5
7
  /**
6
8
  * The single-circle node flavours: one connectable ellipse + a label to its
7
9
  * right, grouped. The glyph itself (anchor silhouette, ecosystem hatching,
8
10
  * method inner circle) is drawn by the node renderer from `kind`.
11
+ *
12
+ * A subset of {@link WardleyArtefactKind}, and named away from
13
+ * `WardleyNodeKind` on purpose: the model declares a type of that name with
14
+ * eleven values, and two homonyms of different cardinality — one of them the
15
+ * source of the semantic vocabulary — is a trap. The market, the pipeline and
16
+ * the Porter's-forces glyph are composites with creation functions of their
17
+ * own; the accelerator and the decelerator are not circles at all and place
18
+ * their label on the side their arrow points from
19
+ * ({@link createWardleyAccelerator}); the area is a zone with no label element
20
+ * at all ({@link createWardleyArea}); the handle is not an artefact.
9
21
  */
10
- declare const NODE_PRESETS: {
11
- readonly component: {
12
- readonly d: 18;
13
- readonly fill: "#ffffff";
14
- readonly label: string;
15
- };
16
- readonly anchor: {
17
- readonly d: 18;
18
- readonly fill: "#ffffff";
19
- readonly label: string;
20
- };
21
- readonly ecosystem: {
22
- readonly d: 40;
23
- readonly fill: "#ffffff";
24
- readonly label: "Ecosystem";
25
- };
26
- readonly method: {
27
- readonly d: 35;
28
- readonly fill: "#d9d9d9";
29
- readonly label: "Component";
30
- };
31
- };
32
- /**
33
- * The subset of {@link WardleyNodeKind} that the single-circle presets above
34
- * cover. Renamed away from `WardleyNodeKind` on purpose: the model declares a
35
- * type of that name with SEVEN values, and two homonyms of different
36
- * cardinality — one of them now the source of the semantic vocabulary — is a
37
- * trap. `WARDLEY_ROLE[kind]` below only compiles while this stays a subset.
38
- */
39
- export type WardleySingleCircleKind = keyof typeof NODE_PRESETS;
22
+ export type WardleySingleCircleKind = Exclude<WardleyArtefactKind, 'market' | 'pipeline' | 'porter' | 'accelerator' | 'decelerator' | 'area'>;
40
23
  /** Create a wardley map background of the given variant, viewport-centered. */
41
24
  export declare function createWardleyBackground(gfx: GfxController, variant?: WardleyBgVariant): void;
42
25
  /**
@@ -63,6 +46,104 @@ export declare function createWardleyPipeline(gfx: GfxController): void;
63
46
  * everything is grouped into one object.
64
47
  */
65
48
  export declare function createWardleyMarket(gfx: GfxController): void;
49
+ /**
50
+ * Create a Porter's-forces glyph: a white circle carrying ONE letter, pushed on
51
+ * from the four cardinal directions by solid red arrows, all grouped as one
52
+ * object.
53
+ *
54
+ * It marks an EXTERNAL competition force acting on the map — which is why the
55
+ * circle carries `wardley:porter` and nothing under `wardley:component`: it is
56
+ * not a link in the value chain, and no rule written about the chain has
57
+ * anything to say about a pressure applied from outside it.
58
+ *
59
+ * No label, and that is the notation rather than an omission: the letter says
60
+ * which force this is (R relative competition, L struggle for survival, E
61
+ * struggle to establish) and it is the circle's OWN inner text, so a
62
+ * double-click opens the native shape editor on it (`node/node-view.ts`). The
63
+ * four arrows are the glyph's own wiring — role-less filled red polygons,
64
+ * outside the rim — exactly as the market's triangle is its own.
65
+ */
66
+ export declare function createWardleyPorter(gfx: GfxController): void;
67
+ /**
68
+ * Create an accelerator or a decelerator: one fat native POLYGON and the words
69
+ * that say what it is, grouped like every other artefact.
70
+ *
71
+ * Not a link in the value chain, and not a pressure on it either: an annotation
72
+ * of the CLIMATE, saying that something is speeding evolution up (an arrow
73
+ * pointing right, towards commodity) or holding it back (the mirror, pointing
74
+ * left). Which way it points IS the notation, so the two are separate kinds
75
+ * with outlines of their own rather than one kind with a `rotate`.
76
+ *
77
+ * ## Where the words go, and why it is not always the right
78
+ *
79
+ * Every other single artefact on this canvas wears its name on its right,
80
+ * because a circle has no direction to disagree with. An arrow does. The
81
+ * reference puts the words on the side the arrow's SHAFT is on — right of an
82
+ * accelerator, left of a decelerator — so the reading runs into the arrow
83
+ * rather than across it, and the label is aligned towards it (left-aligned on
84
+ * the right, right-aligned on the left) so the words end against the shaft
85
+ * whatever their length.
86
+ *
87
+ * SemiBold, which no other Wardley label is: these two are annotations laid
88
+ * over a map that is already full of names, and the weight is what keeps the
89
+ * word readable as a remark ABOUT the map rather than as one more component.
90
+ */
91
+ export declare function createWardleyAccelerator(gfx: GfxController, kind: 'accelerator' | 'decelerator'): void;
92
+ /**
93
+ * Create an AREA: a translucent zone of the map, drawn as a rectangle or as a
94
+ * polygon whose corners the author then moves.
95
+ *
96
+ * One element and no group, which makes it the plainest creation site in this
97
+ * file — and the difference is the name. Every other artefact wears its name as
98
+ * a text element beside it, so every other artefact is a group of two. A zone's
99
+ * name belongs INSIDE the zone, so it is the shape's own inner text: created
100
+ * empty, opened by a double-click on the zone (`node/node-view.ts`), and set in
101
+ * a fit mode that never resizes the boundary around it.
102
+ *
103
+ * ## Why it is sent to the back
104
+ *
105
+ * A zone is drawn precisely OVER the components it groups, and the surface
106
+ * paints in index order: an area added last would sit on top of everything it
107
+ * surrounds — hiding it under a wash, and worse, intercepting every click meant
108
+ * for a component inside it. So the element is lowered the moment it exists,
109
+ * to the depth {@link wardleyAreaIndexOver} works out.
110
+ */
111
+ export declare function createWardleyArea(gfx: GfxController, shape: WardleyAreaShape): void;
112
+ /**
113
+ * One already-placed element, as {@link wardleyAreaIndexOver} needs to read it.
114
+ *
115
+ * Three fields and no model, so the placement can be reasoned about — and
116
+ * tested — without a document: what it is, where it is, and how deep.
117
+ */
118
+ export interface WardleyStackedElement {
119
+ /** Fractional index. Sorts lexicographically, which IS the paint order. */
120
+ index: string;
121
+ /** Serialized box. */
122
+ xywh: string;
123
+ /** Whether this is a framework BACKGROUND — a map, a pool, a C4 board… */
124
+ isBackground: boolean;
125
+ }
126
+ /**
127
+ * How deep a freshly drawn zone goes: just ABOVE the framework backgrounds it
128
+ * covers, and below everything else. `null` means the back of the surface.
129
+ *
130
+ * "The back" alone was the first answer and it was wrong, as the recette of
131
+ * #213 found: a Wardley map is a framework BACKGROUND — an opaque one — so a
132
+ * zone sent behind the whole surface went behind the map and vanished. What a
133
+ * zone must be under is the artefacts it groups; what it must be over is the
134
+ * canvas they are drawn on. Those are two different depths, and only the first
135
+ * one is "the back".
136
+ *
137
+ * So: find the topmost background the zone actually OVERLAPS — the map it was
138
+ * drawn on, and not some other framework's board parked elsewhere on the same
139
+ * canvas — and mint a key between it and whatever sits directly above it. With
140
+ * no background under the zone at all there is nothing to clear, and the back
141
+ * of the surface is right again.
142
+ *
143
+ * `siblings` need not be sorted: fractional indexes sort lexicographically, so
144
+ * this sorts them itself and reads the paint order straight off the strings.
145
+ */
146
+ export declare function wardleyAreaIndexOver(siblings: readonly WardleyStackedElement[], box: Bound): string | null;
66
147
  /**
67
148
  * Read an SVG the user picks as a SKETCH, and say what it cost.
68
149
  *
@@ -114,4 +195,3 @@ export declare function exportOwmFile(std: BlockStdScope): void;
114
195
  * the capability handed to it.
115
196
  */
116
197
  export declare function importOwmFile(std: BlockStdScope): Promise<void>;
117
- export {};
package/dist/actions.js CHANGED
@@ -1,16 +1,17 @@
1
1
  import { backgroundSize, DefaultTool, runInterchangeImportFile, } from '@formicoidea/labre-core/blocks/surface';
2
2
  import { ConnectorTool } from '@formicoidea/labre-core/gfx/connector';
3
3
  import { createGroupCommand } from '@formicoidea/labre-core/gfx/group';
4
- import { ConnectorMode, FontFamily, PointStyle, ShapeStyle, StrokeStyle, TextFitMode, WardleyBackgroundElementModel, } from '@formicoidea/labre-core/model';
4
+ import { ConnectorMode, FontFamily, FontWeight, FrameworkBackgroundElementModel, PointStyle, ShapeStyle, StrokeStyle, TextFitMode, WardleyBackgroundElementModel, } from '@formicoidea/labre-core/model';
5
5
  import { NotificationProvider, translateKey, } from '@formicoidea/labre-core/shared/services';
6
6
  import { downloadBlob } from '@formicoidea/labre-core/shared/utils';
7
7
  import { Bound } from '@formicoidea/labre-core/global/gfx';
8
- import { GfxControllerIdentifier } from '@formicoidea/labre-core/std/gfx';
8
+ import { generateKeyBetween, GfxControllerIdentifier, } from '@formicoidea/labre-core/std/gfx';
9
9
  import { WARDLEY_BACKGROUND } from './background.js';
10
10
  import { WARDLEY_SVG_IMPORT } from './interchange.js';
11
11
  import { wardleyBoardFrom, wardleySafeFilename, } from './export.js';
12
12
  import { WARDLEY_OWM_EXPORT, WARDLEY_OWM_IMPORT } from './interchange.js';
13
- import { ECOSYSTEM_LABEL, ECOSYSTEM_SIZE, HANDLE_SIZE, INERTIA_COLOR, INERTIA_SIZE, LABEL_DEFAULT, LABEL_FONT_SIZE, LABEL_GAP, LINK_GREY, LINK_STROKE_WIDTH, MARKET_DOT_RING, MARKET_DOT_SIZE, MARKET_DOT_STROKE_WIDTH, MARKET_LABEL, MARKET_LINK_COLOR, MARKET_LINK_WIDTH, MARKET_SIZE, METHOD_FILL, METHOD_LABEL, METHOD_SIZE, NODE_FILL, NODE_SIZE, NODE_STROKE, NODE_STROKE_WIDTH, PIPELINE_FILL, PIPELINE_HEIGHT, PIPELINE_LABEL, PIPELINE_WIDTH, WARDLEY_RED, } from './node/consts.js';
13
+ import { HANDLE_SIZE, INERTIA_COLOR, INERTIA_SIZE, LABEL_FONT_SIZE, LABEL_GAP, LINK_GREY, LINK_STROKE_WIDTH, NODE_STROKE, PORTER_DEFAULT_LETTER, WARDLEY_RED, } from './node/consts.js';
14
+ import { wardleyAreaBox, wardleyAreaProps, wardleyCanonicalBox, wardleyHandleBox, wardleyHandleProps, wardleyMarketDotBoxes, wardleyMarketDotProps, wardleyMarketLinkPairs, wardleyMarketLinkProps, WARDLEY_NODE_LABEL, WARDLEY_NODE_SIZE, wardleyNodeProps, wardleyPorterArrowProps, wardleyPorterArrows, wardleyPorterLetterProps, } from './presets.js';
14
15
  import { WARDLEY_ROLE } from './roles.js';
15
16
  /**
16
17
  * Standalone creation/activation actions for the Wardley toolbox — the
@@ -64,19 +65,13 @@ const BACKGROUND_VARIANT_DEFAULTS = {
64
65
  /** Height of the native free-text labels (Inter, size 18). */
65
66
  const LABEL_H = LABEL_FONT_SIZE + 8;
66
67
  /**
67
- * The single-circle node flavours: one connectable ellipse + a label to its
68
- * right, grouped. The glyph itself (anchor silhouette, ecosystem hatching,
69
- * method inner circle) is drawn by the node renderer from `kind`.
68
+ * Width of a label box, whatever it reads.
69
+ *
70
+ * A number rather than a measurement, which is why a RIGHT-aligned label has to
71
+ * subtract it: the box does not shrink to the words, so the only way to make
72
+ * the words end on a given edge is to start the box a full width before it.
70
73
  */
71
- const NODE_PRESETS = {
72
- component: { d: NODE_SIZE, fill: NODE_FILL, label: LABEL_DEFAULT.component },
73
- anchor: { d: NODE_SIZE, fill: NODE_FILL, label: LABEL_DEFAULT.anchor },
74
- // Ecosystem: glyph = double border + hatched donut; connectors attach to
75
- // this outer circle's center.
76
- ecosystem: { d: ECOSYSTEM_SIZE, fill: NODE_FILL, label: ECOSYSTEM_LABEL },
77
- // Method: the FILL color encodes the chosen method (editable).
78
- method: { d: METHOD_SIZE, fill: METHOD_FILL, label: METHOD_LABEL },
79
- };
74
+ const LABEL_W = 120;
80
75
  function finish(gfx, id) {
81
76
  gfx.doc.captureSync();
82
77
  gfx.tool.setTool(DefaultTool);
@@ -91,34 +86,30 @@ function group(gfx, ids) {
91
86
  });
92
87
  return result.groupId || ids[0];
93
88
  }
94
- /** Add a native ellipse wardley node centred on (cx, cy). */
95
- function addEllipseNode(surface, kind, cx, cy, d, fillColor, strokeWidth = NODE_STROKE_WIDTH,
96
- // The market's three inner dots are the GLYPH's own wiring, not artefacts the
97
- // user placed — the same reason its triangle connectors carry no role. They
98
- // sit inside the market circle by construction, so a role would make every
99
- // market composite report an overlap with itself (W3).
100
- neutral = false) {
89
+ /**
90
+ * Add a wardley node of one kind, at its canonical size, centred on (cx, cy).
91
+ *
92
+ * The APPEARANCE is {@link wardleyNodeProps}' and nothing else: the palette and
93
+ * the "Change type" dropdown read that one description, so a market drawn here
94
+ * and a component morphed into one are the same element (`presets.ts`).
95
+ *
96
+ * `extra` is for what a preset cannot carry — the Porter's-forces letter, which
97
+ * is CONTENT the author then edits, not a look. `wardleyMorphProps` strips
98
+ * `text` for exactly that reason, so a preset that wrote one would be a preset
99
+ * the morph has to un-write.
100
+ */
101
+ function addNode(surface, kind, cx, cy, extra = {}) {
101
102
  return surface.addElement({
102
- type: 'wardleyNode',
103
- kind,
104
- // Semantic identity (PF1): posted next to `kind`, which stays untouched
105
- // and keeps driving the rendering.
106
- role: neutral ? undefined : WARDLEY_ROLE[kind],
107
- shapeType: 'ellipse',
108
- filled: true,
109
- fillColor,
110
- strokeColor: NODE_STROKE,
111
- strokeWidth,
112
- shapeStyle: ShapeStyle.General,
113
- roughness: 0,
114
- xywh: new Bound(cx - d / 2, cy - d / 2, d, d).serialize(),
103
+ ...wardleyNodeProps(kind, { xywh: wardleyCanonicalBox(kind, cx, cy) }),
104
+ ...extra,
115
105
  });
116
106
  }
117
107
  /** Add a native free-text label (same Inter family as the axis labels). */
118
- function addLabel(surface, text, x, y, textAlign = 'left') {
108
+ function addLabel(surface, text, x, y, textAlign = 'left', fontWeight = FontWeight.Regular) {
119
109
  return surface.addElement({
120
110
  type: 'text',
121
111
  text,
112
+ fontWeight,
122
113
  // Semantic identity (PF1, revised in PF13.4): a Wardley label is a free
123
114
  // text element like any other, so its ROLE is the only thing that tells W3
124
115
  // it must not land on top of a node. A free text the user typed elsewhere
@@ -128,7 +119,7 @@ function addLabel(surface, text, x, y, textAlign = 'left') {
128
119
  fontSize: LABEL_FONT_SIZE,
129
120
  color: NODE_STROKE,
130
121
  textAlign,
131
- xywh: new Bound(x, y, 120, LABEL_H).serialize(),
122
+ xywh: new Bound(x, y, LABEL_W, LABEL_H).serialize(),
132
123
  });
133
124
  }
134
125
  /** Create a wardley map background of the given variant, viewport-centered. */
@@ -171,10 +162,10 @@ export function createWardleyNode(gfx, kind) {
171
162
  const surface = gfx.surface;
172
163
  if (!surface)
173
164
  return;
174
- const { d, fill, label } = NODE_PRESETS[kind];
165
+ const { w } = WARDLEY_NODE_SIZE[kind];
175
166
  const { centerX: cx, centerY: cy } = gfx.viewport;
176
- const nodeId = addEllipseNode(surface, kind, cx, cy, d, fill);
177
- const labelId = addLabel(surface, label, cx + d / 2 + LABEL_GAP, cy - LABEL_H / 2);
167
+ const nodeId = addNode(surface, kind, cx, cy);
168
+ const labelId = addLabel(surface, WARDLEY_NODE_LABEL[kind], cx + w / 2 + LABEL_GAP, cy - LABEL_H / 2);
178
169
  finish(gfx, group(gfx, [nodeId, labelId]));
179
170
  }
180
171
  /** Create an inertia bar (filled black rect). */
@@ -213,43 +204,15 @@ export function createWardleyPipeline(gfx) {
213
204
  if (!gfx.surface)
214
205
  return;
215
206
  const { centerX: cx, centerY: cy } = gfx.viewport;
216
- const W = PIPELINE_WIDTH;
217
- const H = PIPELINE_HEIGHT;
218
207
  const d = HANDLE_SIZE;
219
- const top = cy - H / 2;
208
+ const top = cy - WARDLEY_NODE_SIZE.pipeline.h / 2;
220
209
  // Body: a WardleyNode rect, made non-connectable by `kind: 'pipeline'`.
221
- const bodyId = gfx.surface.addElement({
222
- type: 'wardleyNode',
223
- kind: 'pipeline',
224
- role: WARDLEY_ROLE.pipeline,
225
- shapeType: 'rect',
226
- filled: true,
227
- fillColor: PIPELINE_FILL,
228
- strokeColor: NODE_STROKE,
229
- strokeWidth: NODE_STROKE_WIDTH,
230
- shapeStyle: ShapeStyle.General,
231
- roughness: 0,
232
- radius: 0,
233
- xywh: new Bound(cx - W / 2, top, W, H).serialize(),
234
- });
210
+ const bodyId = addNode(gfx.surface, 'pipeline', cx, cy);
235
211
  // Handle: a node-sized WardleyNode square straddling the top edge. Inherits
236
212
  // `centerAnchorOnly` so connectors attach to its center only.
237
- const handleId = gfx.surface.addElement({
238
- type: 'wardleyNode',
239
- kind: 'handle',
240
- role: WARDLEY_ROLE.handle,
241
- shapeType: 'rect',
242
- filled: true,
243
- fillColor: NODE_FILL,
244
- strokeColor: NODE_STROKE,
245
- strokeWidth: NODE_STROKE_WIDTH,
246
- shapeStyle: ShapeStyle.General,
247
- roughness: 0,
248
- radius: 0,
249
- xywh: new Bound(cx - d / 2, top - d / 2, d, d).serialize(),
250
- });
213
+ const handleId = gfx.surface.addElement(wardleyHandleProps({ xywh: wardleyHandleBox(cx, cy) }));
251
214
  // Label centered horizontally on the pipeline, sitting ABOVE the handle.
252
- const labelId = addLabel(gfx.surface, PIPELINE_LABEL, cx - 60, top - d / 2 - LABEL_H - LABEL_GAP, 'center');
215
+ const labelId = addLabel(gfx.surface, WARDLEY_NODE_LABEL.pipeline, cx - 60, top - d / 2 - LABEL_H - LABEL_GAP, 'center');
253
216
  // Nested groups: (handle + label), then (body + that group).
254
217
  const innerId = group(gfx, [handleId, labelId]);
255
218
  finish(gfx, group(gfx, [bodyId, innerId]));
@@ -266,37 +229,160 @@ export function createWardleyMarket(gfx) {
266
229
  if (!surface)
267
230
  return;
268
231
  const { centerX: cx, centerY: cy } = gfx.viewport;
269
- const R = MARKET_SIZE / 2;
270
- const rho = MARKET_DOT_RING;
271
- const sin60 = Math.sqrt(3) / 2;
232
+ const R = WARDLEY_NODE_SIZE.market.w / 2;
272
233
  // Outer circle = the market node (connectable, center-only).
273
- const circleId = addEllipseNode(surface, 'market', cx, cy, MARKET_SIZE, NODE_FILL);
234
+ const circleId = addNode(surface, 'market', cx, cy);
274
235
  // 3 inner component nodes (thick border, no label) at the triangle vertices.
275
- const verts = [
276
- [0, -rho],
277
- [rho * sin60, rho / 2],
278
- [-rho * sin60, rho / 2],
279
- ];
280
- const dotIds = verts.map(([vx, vy]) => addEllipseNode(surface, 'component', cx + vx, cy + vy, MARKET_DOT_SIZE, NODE_FILL, MARKET_DOT_STROKE_WIDTH, true));
236
+ const dotIds = wardleyMarketDotBoxes(cx, cy).map(xywh => surface.addElement(wardleyMarketDotProps({ xywh })));
281
237
  // Triangle: 3 attached connectors (auto-route center-to-center, clipped).
282
- const connIds = [
283
- [dotIds[0], dotIds[1]],
284
- [dotIds[1], dotIds[2]],
285
- [dotIds[2], dotIds[0]],
286
- ].map(([a, b]) => surface.addElement({
287
- type: 'connector',
288
- mode: ConnectorMode.Straight,
289
- source: { id: a },
290
- target: { id: b },
291
- stroke: MARKET_LINK_COLOR,
292
- strokeStyle: StrokeStyle.Solid,
293
- strokeWidth: MARKET_LINK_WIDTH,
294
- frontEndpointStyle: PointStyle.None,
295
- rearEndpointStyle: PointStyle.None,
296
- }));
297
- const labelId = addLabel(surface, MARKET_LABEL, cx + R + LABEL_GAP, cy - LABEL_H / 2);
238
+ const connIds = wardleyMarketLinkPairs(dotIds).map(([a, b]) => surface.addElement(wardleyMarketLinkProps(a, b)));
239
+ const labelId = addLabel(surface, WARDLEY_NODE_LABEL.market, cx + R + LABEL_GAP, cy - LABEL_H / 2);
298
240
  finish(gfx, group(gfx, [circleId, ...dotIds, ...connIds, labelId]));
299
241
  }
242
+ /**
243
+ * Create a Porter's-forces glyph: a white circle carrying ONE letter, pushed on
244
+ * from the four cardinal directions by solid red arrows, all grouped as one
245
+ * object.
246
+ *
247
+ * It marks an EXTERNAL competition force acting on the map — which is why the
248
+ * circle carries `wardley:porter` and nothing under `wardley:component`: it is
249
+ * not a link in the value chain, and no rule written about the chain has
250
+ * anything to say about a pressure applied from outside it.
251
+ *
252
+ * No label, and that is the notation rather than an omission: the letter says
253
+ * which force this is (R relative competition, L struggle for survival, E
254
+ * struggle to establish) and it is the circle's OWN inner text, so a
255
+ * double-click opens the native shape editor on it (`node/node-view.ts`). The
256
+ * four arrows are the glyph's own wiring — role-less filled red polygons,
257
+ * outside the rim — exactly as the market's triangle is its own.
258
+ */
259
+ export function createWardleyPorter(gfx) {
260
+ const surface = gfx.surface;
261
+ if (!surface)
262
+ return;
263
+ const { centerX: cx, centerY: cy } = gfx.viewport;
264
+ const circleId = addNode(surface, 'porter', cx, cy, wardleyPorterLetterProps(PORTER_DEFAULT_LETTER));
265
+ const arrowIds = wardleyPorterArrows(cx, cy).map(arrow => surface.addElement(wardleyPorterArrowProps(arrow)));
266
+ finish(gfx, group(gfx, [circleId, ...arrowIds]));
267
+ }
268
+ /**
269
+ * Create an accelerator or a decelerator: one fat native POLYGON and the words
270
+ * that say what it is, grouped like every other artefact.
271
+ *
272
+ * Not a link in the value chain, and not a pressure on it either: an annotation
273
+ * of the CLIMATE, saying that something is speeding evolution up (an arrow
274
+ * pointing right, towards commodity) or holding it back (the mirror, pointing
275
+ * left). Which way it points IS the notation, so the two are separate kinds
276
+ * with outlines of their own rather than one kind with a `rotate`.
277
+ *
278
+ * ## Where the words go, and why it is not always the right
279
+ *
280
+ * Every other single artefact on this canvas wears its name on its right,
281
+ * because a circle has no direction to disagree with. An arrow does. The
282
+ * reference puts the words on the side the arrow's SHAFT is on — right of an
283
+ * accelerator, left of a decelerator — so the reading runs into the arrow
284
+ * rather than across it, and the label is aligned towards it (left-aligned on
285
+ * the right, right-aligned on the left) so the words end against the shaft
286
+ * whatever their length.
287
+ *
288
+ * SemiBold, which no other Wardley label is: these two are annotations laid
289
+ * over a map that is already full of names, and the weight is what keeps the
290
+ * word readable as a remark ABOUT the map rather than as one more component.
291
+ */
292
+ export function createWardleyAccelerator(gfx, kind) {
293
+ const surface = gfx.surface;
294
+ if (!surface)
295
+ return;
296
+ const { w } = WARDLEY_NODE_SIZE[kind];
297
+ const { centerX: cx, centerY: cy } = gfx.viewport;
298
+ const nodeId = addNode(surface, kind, cx, cy);
299
+ const rightwards = kind === 'accelerator';
300
+ const labelId = addLabel(surface, WARDLEY_NODE_LABEL[kind],
301
+ // A label box is a fixed LABEL_W wide whatever it reads, so a right-aligned
302
+ // one has to start a box-width before the edge the words must end on.
303
+ rightwards ? cx + w / 2 + LABEL_GAP : cx - w / 2 - LABEL_GAP - LABEL_W, cy - LABEL_H / 2, rightwards ? 'left' : 'right', FontWeight.SemiBold);
304
+ finish(gfx, group(gfx, [nodeId, labelId]));
305
+ }
306
+ /**
307
+ * Create an AREA: a translucent zone of the map, drawn as a rectangle or as a
308
+ * polygon whose corners the author then moves.
309
+ *
310
+ * One element and no group, which makes it the plainest creation site in this
311
+ * file — and the difference is the name. Every other artefact wears its name as
312
+ * a text element beside it, so every other artefact is a group of two. A zone's
313
+ * name belongs INSIDE the zone, so it is the shape's own inner text: created
314
+ * empty, opened by a double-click on the zone (`node/node-view.ts`), and set in
315
+ * a fit mode that never resizes the boundary around it.
316
+ *
317
+ * ## Why it is sent to the back
318
+ *
319
+ * A zone is drawn precisely OVER the components it groups, and the surface
320
+ * paints in index order: an area added last would sit on top of everything it
321
+ * surrounds — hiding it under a wash, and worse, intercepting every click meant
322
+ * for a component inside it. So the element is lowered the moment it exists,
323
+ * to the depth {@link wardleyAreaIndexOver} works out.
324
+ */
325
+ export function createWardleyArea(gfx, shape) {
326
+ const surface = gfx.surface;
327
+ if (!surface)
328
+ return;
329
+ const { centerX: cx, centerY: cy } = gfx.viewport;
330
+ const xywh = wardleyAreaBox(shape, cx, cy);
331
+ const id = surface.addElement(wardleyAreaProps(shape, { xywh }));
332
+ const model = surface.getElementById(id);
333
+ if (model) {
334
+ const over = wardleyAreaIndexOver(stackedElementsOf(surface, id), Bound.deserialize(xywh));
335
+ // `null` is "nothing under it": the back of the surface, minted by the
336
+ // layer itself so an empty board gets the layer's own initial index rather
337
+ // than a key invented here.
338
+ model.index = over ?? gfx.layer.getReorderedIndex(model, 'back');
339
+ }
340
+ finish(gfx, id);
341
+ }
342
+ /** The top-level canvas elements of a surface, minus the one being placed. */
343
+ function stackedElementsOf(surface, exclude) {
344
+ return surface.elementModels
345
+ .filter(model => model.id !== exclude && model.group === null)
346
+ .map(model => ({
347
+ index: model.index,
348
+ xywh: model.xywh,
349
+ isBackground: model instanceof FrameworkBackgroundElementModel,
350
+ }));
351
+ }
352
+ /**
353
+ * How deep a freshly drawn zone goes: just ABOVE the framework backgrounds it
354
+ * covers, and below everything else. `null` means the back of the surface.
355
+ *
356
+ * "The back" alone was the first answer and it was wrong, as the recette of
357
+ * #213 found: a Wardley map is a framework BACKGROUND — an opaque one — so a
358
+ * zone sent behind the whole surface went behind the map and vanished. What a
359
+ * zone must be under is the artefacts it groups; what it must be over is the
360
+ * canvas they are drawn on. Those are two different depths, and only the first
361
+ * one is "the back".
362
+ *
363
+ * So: find the topmost background the zone actually OVERLAPS — the map it was
364
+ * drawn on, and not some other framework's board parked elsewhere on the same
365
+ * canvas — and mint a key between it and whatever sits directly above it. With
366
+ * no background under the zone at all there is nothing to clear, and the back
367
+ * of the surface is right again.
368
+ *
369
+ * `siblings` need not be sorted: fractional indexes sort lexicographically, so
370
+ * this sorts them itself and reads the paint order straight off the strings.
371
+ */
372
+ export function wardleyAreaIndexOver(siblings, box) {
373
+ const stack = [...siblings].sort((a, b) => a.index < b.index ? -1 : a.index > b.index ? 1 : 0);
374
+ // The LAST match, which is the topmost: a board with two maps on it gets the
375
+ // zone above the one it is actually drawn over, whichever was drawn first.
376
+ const under = stack.reduce((found, element, at) => element.isBackground &&
377
+ Bound.deserialize(element.xywh).isOverlapWithBound(box)
378
+ ? at
379
+ : found, -1);
380
+ if (under < 0)
381
+ return null;
382
+ // `null` for the upper bound when the background is the topmost element
383
+ // there is — `generateKeyBetween` reads that as "append after".
384
+ return generateKeyBetween(stack[under].index, stack[under + 1]?.index ?? null);
385
+ }
300
386
  /**
301
387
  * Read an SVG the user picks as a SKETCH, and say what it cost.
302
388
  *
@@ -119,6 +119,46 @@ export const wardleyCommandsManifest = [
119
119
  scope: 'edgeless',
120
120
  defaultKeys: { mac: ['w', 'i'], other: ['w', 'i'] },
121
121
  },
122
+ {
123
+ id: 'wardley.addPorter',
124
+ owner: 'wardley',
125
+ labelKey: 'com.labre.commands.wardley.addPorter',
126
+ labelFallback: "Porter's forces",
127
+ scope: 'edgeless',
128
+ defaultKeys: { mac: [], other: [] },
129
+ },
130
+ {
131
+ id: 'wardley.addAccelerator',
132
+ owner: 'wardley',
133
+ labelKey: 'com.labre.commands.wardley.addAccelerator',
134
+ labelFallback: 'Accelerator',
135
+ scope: 'edgeless',
136
+ defaultKeys: { mac: [], other: [] },
137
+ },
138
+ {
139
+ id: 'wardley.addDecelerator',
140
+ owner: 'wardley',
141
+ labelKey: 'com.labre.commands.wardley.addDecelerator',
142
+ labelFallback: 'Decelerator',
143
+ scope: 'edgeless',
144
+ defaultKeys: { mac: [], other: [] },
145
+ },
146
+ {
147
+ id: 'wardley.addAreaRect',
148
+ owner: 'wardley',
149
+ labelKey: 'com.labre.commands.wardley.addAreaRect',
150
+ labelFallback: 'Area (rectangle)',
151
+ scope: 'edgeless',
152
+ defaultKeys: { mac: [], other: [] },
153
+ },
154
+ {
155
+ id: 'wardley.addAreaPolygon',
156
+ owner: 'wardley',
157
+ labelKey: 'com.labre.commands.wardley.addAreaPolygon',
158
+ labelFallback: 'Area (polygon)',
159
+ scope: 'edgeless',
160
+ defaultKeys: { mac: [], other: [] },
161
+ },
122
162
  {
123
163
  id: 'wardley.importOwm',
124
164
  owner: 'wardley',
@@ -1,8 +1,25 @@
1
1
  import type { CommandDescriptor } from '@formicoidea/labre-core/std';
2
2
  import type { TemplateResult } from 'lit';
3
3
  /**
4
- * The Wardley registry: the thirteen toolbox entries, then the two directions
4
+ * The Wardley registry: the eighteen toolbox entries, then the two directions
5
5
  * of the OWM DSL and the SVG fallback (`docs/adr/0012`).
6
+ *
7
+ * ## Nineteen nominations, and the curation decision behind them
8
+ *
9
+ * `addPorter` spent the last seat of ADR 0014 R4's `SENIOR_MENU_CAP + 1`
10
+ * budget, and `addAccelerator` / `addDecelerator` are the two past it. The PO
11
+ * made the call on 2026-09-03 (Amendment 2026-09-03 under R4): **every Wardley
12
+ * artefact nominates the row**. The row itself is unchanged — thirteen
13
+ * arbitrated seats plus "More artefacts…", the cap of fourteen it has had since
14
+ * the OWM pair tipped this framework over — so what the decision buys is that
15
+ * a newly-drawn artefact is ELIGIBLE for a seat, and recency and frequency
16
+ * decide who gets one. Nothing becomes unreachable: the catalogue lists
17
+ * everything, which is the registry's own invariant.
18
+ *
19
+ * `addAreaRect` and `addAreaPolygon` are the two after those, and they land
20
+ * under that same amendment rather than reopening it: they nominate like every
21
+ * other Wardley artefact, the row is still thirteen arbitrated seats plus "More
22
+ * artefacts…", and the catalogue grows by a section of its own.
6
23
  */
7
24
  export declare const wardleyCommands: CommandDescriptor[];
8
25
  /** `iconKey` → template. Never travels through either manifest (ADR 0008). */