@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.
@@ -0,0 +1,554 @@
1
+ import { ConnectorMode, DEFAULT_POLYGON_VERTICES, FontFamily, PointStyle, ShapeStyle, StrokeStyle, TextFitMode, } from '@formicoidea/labre-core/model';
2
+ import { Bound } from '@formicoidea/labre-core/global/gfx';
3
+ import { ACCELERATOR_FILL, ACCELERATOR_LABEL, ACCELERATOR_SIZE, ACCELERATOR_STROKE_WIDTH, ACCELERATOR_VERTICES, AREA_FILL, AREA_POLYGON_SIZE, AREA_RECT_SIZE, AREA_STROKE, AREA_STROKE_WIDTH, DECELERATOR_LABEL, DECELERATOR_VERTICES, ECOSYSTEM_LABEL, ECOSYSTEM_SIZE, HANDLE_SIZE, LABEL_DEFAULT, LABEL_FONT_SIZE, 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, PORTER_ARROW, PORTER_LETTER_FONT_SIZE, PORTER_SIZE, WARDLEY_RED, } from './node/consts.js';
4
+ import { WARDLEY_ROLE } from './roles.js';
5
+ /**
6
+ * The size a kind is BORN at, which for Wardley is the notation rather than a
7
+ * default.
8
+ *
9
+ * A market is 30 across and a component is 18 because that is what tells a
10
+ * reader which is which — the two are the same white circle otherwise. Same for
11
+ * the ecosystem's 40 and the pipeline's 120×25 bar. This is the table the morph
12
+ * re-centres an artefact on, and the one the palette draws from.
13
+ */
14
+ export const WARDLEY_NODE_SIZE = {
15
+ component: { w: NODE_SIZE, h: NODE_SIZE },
16
+ anchor: { w: NODE_SIZE, h: NODE_SIZE },
17
+ ecosystem: { w: ECOSYSTEM_SIZE, h: ECOSYSTEM_SIZE },
18
+ method: { w: METHOD_SIZE, h: METHOD_SIZE },
19
+ market: { w: MARKET_SIZE, h: MARKET_SIZE },
20
+ pipeline: { w: PIPELINE_WIDTH, h: PIPELINE_HEIGHT },
21
+ porter: { w: PORTER_SIZE, h: PORTER_SIZE },
22
+ accelerator: ACCELERATOR_SIZE,
23
+ decelerator: ACCELERATOR_SIZE,
24
+ // The RECT's size, because a kind gets one entry and the rect is the common
25
+ // area. The polygon's is {@link WARDLEY_AREA_SIZE} right below — the one
26
+ // place where a kind's canonical box depends on the shape it is drawn as.
27
+ area: AREA_RECT_SIZE,
28
+ };
29
+ /**
30
+ * The size an area is born at, per shape.
31
+ *
32
+ * Beside {@link WARDLEY_NODE_SIZE} rather than in it, because the table above
33
+ * is keyed by KIND and these two artefacts are one kind: a rectangle 240 × 160
34
+ * covers a band of the map, and the polygon is born square so its default
35
+ * pentagon is regular. `WARDLEY_NODE_SIZE.area` is the rect's, so anything that
36
+ * asks a kind for its canonical box — the morph does — still gets an answer.
37
+ */
38
+ export const WARDLEY_AREA_SIZE = {
39
+ rect: AREA_RECT_SIZE,
40
+ polygon: AREA_POLYGON_SIZE,
41
+ };
42
+ /**
43
+ * The kinds drawn as a native POLYGON, and the outline each one is.
44
+ *
45
+ * The table {@link wardleyNodeProps} reads to decide the shape type, so a kind's
46
+ * membership here is the whole of what makes it an arrow rather than a circle.
47
+ * A fresh array per call: `vertices` goes into a document, and a shared frozen
48
+ * literal handed to two elements would be one array two elements point at.
49
+ */
50
+ const NODE_VERTICES_OF = {
51
+ accelerator: ACCELERATOR_VERTICES,
52
+ decelerator: DECELERATOR_VERTICES,
53
+ };
54
+ /**
55
+ * The words a kind is created NEXT TO — the prompt an artefact nobody has named
56
+ * still carries, and the only string a morph is ever allowed to rewrite.
57
+ */
58
+ export const WARDLEY_NODE_LABEL = {
59
+ component: LABEL_DEFAULT.component,
60
+ anchor: LABEL_DEFAULT.anchor,
61
+ ecosystem: ECOSYSTEM_LABEL,
62
+ method: METHOD_LABEL,
63
+ market: MARKET_LABEL,
64
+ pipeline: PIPELINE_LABEL,
65
+ accelerator: ACCELERATOR_LABEL,
66
+ decelerator: DECELERATOR_LABEL,
67
+ };
68
+ /**
69
+ * The fill a kind is born with. White for every circle that means "a thing on
70
+ * the value chain"; the method's grey is the one that carries meaning — it
71
+ * ENCODES the chosen method and stays editable from the toolbar.
72
+ */
73
+ const NODE_FILL_OF = {
74
+ component: NODE_FILL,
75
+ anchor: NODE_FILL,
76
+ ecosystem: NODE_FILL,
77
+ method: METHOD_FILL,
78
+ market: NODE_FILL,
79
+ // White at ~60% opacity: a pipeline is a bar you place components ON, so the
80
+ // map has to stay visible through it. The 1px border stays opaque.
81
+ pipeline: PIPELINE_FILL,
82
+ // Opaque white, like every other circle: the RED is in the four arrows, and a
83
+ // filled disk would fight the letter it is there to carry.
84
+ porter: NODE_FILL,
85
+ // Flat grey. The reference draws these arrows with a gradient; this canvas
86
+ // has no gradient fill, so a solid mid-grey under a thick dark border is what
87
+ // reads as the same solid arrow at the zoom an architect works at.
88
+ accelerator: ACCELERATOR_FILL,
89
+ decelerator: ACCELERATOR_FILL,
90
+ // Peace light at ~25 % opacity, for the reason the pipeline's white wash
91
+ // gives: a zone is drawn OVER the components it groups, so the map has to
92
+ // stay readable through it. The 1px border stays opaque.
93
+ area: AREA_FILL,
94
+ };
95
+ /**
96
+ * The rim colour of the kinds that do not wear the house one.
97
+ *
98
+ * A table with a single entry, and it is the honest way to say what an area is:
99
+ * every other artefact is a black-rimmed drawing of a thing in the value chain,
100
+ * and a zone is a coloured wash that FRAMES those drawings. A border in the
101
+ * same near-black would read as one more outline on a map already full of them.
102
+ */
103
+ const NODE_STROKE_OF = {
104
+ area: AREA_STROKE,
105
+ };
106
+ /** A box centred on a point — how every Wardley creation site places a node. */
107
+ export function wardleyCenteredBox(cx, cy, w, h) {
108
+ return new Bound(cx - w / 2, cy - h / 2, w, h).serialize();
109
+ }
110
+ /** The canonical box of one kind, centred where the artefact already stands. */
111
+ export function wardleyCanonicalBox(kind, cx, cy) {
112
+ const { w, h } = WARDLEY_NODE_SIZE[kind];
113
+ return wardleyCenteredBox(cx, cy, w, h);
114
+ }
115
+ /**
116
+ * Every prop a Wardley node is created with, for one kind and one box.
117
+ *
118
+ * Most of the pack is a native ELLIPSE and differs only in fill and size: the
119
+ * glyph — the anchor's silhouette, the ecosystem's hatched donut, the method's
120
+ * inner circle — is drawn by the renderer from `kind`, off this same model, so
121
+ * stroke and fill stay editable from the shape toolbar exactly like any other
122
+ * shape's. Two families are the exception, and they are the reason `clearOf`
123
+ * exists on this pack: the pipeline and the area are RECTS and the only kinds
124
+ * that write `radius`, and the accelerator / decelerator are POLYGONS — the
125
+ * only kinds that write `vertices` and `isClosed` from here.
126
+ *
127
+ * No `text`: a Wardley label is a separate free-text element grouped with the
128
+ * node (see `addLabel` in `actions.ts`), never words stored on the shape. The
129
+ * porter and the area are the two exceptions, and neither writes it HERE: their
130
+ * inner text is content the author types, added at the creation site.
131
+ */
132
+ export function wardleyNodeProps(kind, box) {
133
+ // An area is born a RECT, and {@link wardleyAreaProps} is what turns the
134
+ // other half of the kind into a polygon: the two artefacts are one kind
135
+ // because the `shapeType` is the whole of what differs between them, so the
136
+ // common description lives here and the outline is spread over it.
137
+ const rect = kind === 'pipeline' || kind === 'area';
138
+ const outline = NODE_VERTICES_OF[kind];
139
+ return {
140
+ type: 'wardleyNode',
141
+ kind,
142
+ // Semantic identity (PF1): posted next to `kind`, which stays untouched and
143
+ // keeps driving the rendering. The role is what every rule reads — no rule
144
+ // will ever look at a shape type.
145
+ role: WARDLEY_ROLE[kind],
146
+ shapeType: outline ? 'polygon' : rect ? 'rect' : 'ellipse',
147
+ filled: true,
148
+ fillColor: NODE_FILL_OF[kind],
149
+ strokeColor: NODE_STROKE_OF[kind] ?? NODE_STROKE,
150
+ // A thick rim on the arrows, the house thin one everywhere else: an arrow
151
+ // with a 1px border reads as an outline drawing rather than as the solid
152
+ // arrow the reference draws.
153
+ strokeWidth: outline ? ACCELERATOR_STROKE_WIDTH : NODE_STROKE_WIDTH,
154
+ shapeStyle: ShapeStyle.General,
155
+ roughness: 0,
156
+ // Square corners, and ONLY on the kind that has corners at all. Spread
157
+ // conditionally rather than written as 0 everywhere, because that is the
158
+ // truth about the ellipses — and because a key some kinds write and others
159
+ // do not is precisely what `wardleyMorphClears` is for.
160
+ ...(rect ? { radius: 0 } : {}),
161
+ // The outline, and the fact that it closes. A fresh array of fresh pairs:
162
+ // this goes into a document, and two elements sharing one literal would be
163
+ // two elements sharing one array.
164
+ ...(outline
165
+ ? { vertices: outline.map(([x, y]) => [x, y]), isClosed: true }
166
+ : {}),
167
+ xywh: box.xywh,
168
+ };
169
+ }
170
+ /**
171
+ * An AREA, as props — one kind, two shapes.
172
+ *
173
+ * The second discriminator this pack needed, and the reason it is a parameter
174
+ * rather than a second `kind`: a rectangular zone and a polygonal one are the
175
+ * same statement about the map ("all of this is one thing"), drawn with a
176
+ * different number of corners. A second kind would have to be kept in step in
177
+ * the role table, the legend, the export and the morph's key union, for a
178
+ * difference the `shapeType` already carries.
179
+ *
180
+ * Everything but the outline comes from {@link wardleyNodeProps}, which is the
181
+ * house rule: the fill, the rim and the role are the area's, wherever it is
182
+ * built from. What is added here is the two shapes and the fact that an area's
183
+ * NAME is its own inner text.
184
+ *
185
+ * ## The text is the name, and the zone never grows to fit it
186
+ *
187
+ * `TextFitMode.Overflow`, exactly as the porter's letter and the inertia bar:
188
+ * an area is a boundary drawn around real components, so a long name must never
189
+ * push that boundary out and swallow a component the author did not mean to
190
+ * include. Top-left, because a zone's name belongs in its corner rather than
191
+ * across the middle of the map it covers — and no `text` at all, so the editor
192
+ * a double-click opens starts on an empty line.
193
+ */
194
+ export function wardleyAreaProps(shape, box) {
195
+ const props = {
196
+ ...wardleyNodeProps('area', box),
197
+ // A thin rim under its own name, so the zone frames the map rather than
198
+ // adding one more black outline to it.
199
+ strokeWidth: AREA_STROKE_WIDTH,
200
+ // The name, as the shape's own text: left-aligned in the top corner, and a
201
+ // fit mode that never resizes the zone.
202
+ color: NODE_STROKE,
203
+ fontFamily: FontFamily.Inter,
204
+ fontSize: LABEL_FONT_SIZE,
205
+ textAlign: 'left',
206
+ textVerticalAlign: 'top',
207
+ textFitMode: TextFitMode.Overflow,
208
+ };
209
+ if (shape !== 'polygon')
210
+ return props;
211
+ props.shapeType = 'polygon';
212
+ // The editor's own default outline — a regular pentagon — copied FRESH,
213
+ // because `vertices` goes into a document and two elements sharing one
214
+ // literal would be two elements sharing one array. The author then moves the
215
+ // corners from the shape toolbar's vertex editor.
216
+ props.vertices = DEFAULT_POLYGON_VERTICES.map(([x, y]) => [x, y]);
217
+ props.isClosed = true;
218
+ // …and a polygon has no corner radius. Deleted rather than never written,
219
+ // because the base description is the RECT's — same idiom, and same reason,
220
+ // as `wardleyMorphProps`: a patch cannot express absence, so a key that means
221
+ // nothing here must not be sitting in the Y.Map saying it does.
222
+ delete props.radius;
223
+ return props;
224
+ }
225
+ /** Where an area of the given shape is born, centred on a point. */
226
+ export function wardleyAreaBox(shape, cx, cy) {
227
+ const { w, h } = WARDLEY_AREA_SIZE[shape];
228
+ return wardleyCenteredBox(cx, cy, w, h);
229
+ }
230
+ /**
231
+ * The pipeline's handle: a node-sized square straddling the body's top edge, and
232
+ * the only place a connector may land on a pipeline.
233
+ *
234
+ * It inherits `centerAnchorOnly` from the model like every other Wardley node,
235
+ * and it carries `wardley:handle` — a role of its own, so nothing that judges
236
+ * components ever judges it.
237
+ */
238
+ export function wardleyHandleProps(box) {
239
+ return {
240
+ type: 'wardleyNode',
241
+ kind: 'handle',
242
+ role: WARDLEY_ROLE.handle,
243
+ shapeType: 'rect',
244
+ filled: true,
245
+ fillColor: NODE_FILL,
246
+ strokeColor: NODE_STROKE,
247
+ strokeWidth: NODE_STROKE_WIDTH,
248
+ shapeStyle: ShapeStyle.General,
249
+ roughness: 0,
250
+ radius: 0,
251
+ xywh: box.xywh,
252
+ };
253
+ }
254
+ /** Where a handle sits, given the body's centre: astride the top edge. */
255
+ export function wardleyHandleBox(cx, cy) {
256
+ const top = cy - WARDLEY_NODE_SIZE.pipeline.h / 2;
257
+ return wardleyCenteredBox(cx, top, HANDLE_SIZE, HANDLE_SIZE);
258
+ }
259
+ /**
260
+ * One of the market's three inner dots — thicker-bordered, and deliberately
261
+ * ROLE-LESS.
262
+ *
263
+ * They are the GLYPH's own wiring rather than artefacts the user placed, the
264
+ * same reason the triangle between them carries no role: they sit inside the
265
+ * market circle by construction, so a role would make every market composite
266
+ * report an overlap with itself (W3).
267
+ */
268
+ export function wardleyMarketDotProps(box) {
269
+ return {
270
+ type: 'wardleyNode',
271
+ kind: 'component',
272
+ role: undefined,
273
+ shapeType: 'ellipse',
274
+ filled: true,
275
+ fillColor: NODE_FILL,
276
+ strokeColor: NODE_STROKE,
277
+ strokeWidth: MARKET_DOT_STROKE_WIDTH,
278
+ shapeStyle: ShapeStyle.General,
279
+ roughness: 0,
280
+ xywh: box.xywh,
281
+ };
282
+ }
283
+ /** The three dot boxes of a market centred on (cx, cy) — an upright triangle. */
284
+ export function wardleyMarketDotBoxes(cx, cy) {
285
+ const rho = MARKET_DOT_RING;
286
+ const sin60 = Math.sqrt(3) / 2;
287
+ return [
288
+ [0, -rho],
289
+ [rho * sin60, rho / 2],
290
+ [-rho * sin60, rho / 2],
291
+ ].map(([vx, vy]) => wardleyCenteredBox(cx + vx, cy + vy, MARKET_DOT_SIZE, MARKET_DOT_SIZE));
292
+ }
293
+ /**
294
+ * One side of the market's triangle: an ATTACHED connector, so it auto-routes
295
+ * centre to centre and follows the dots on move and resize.
296
+ */
297
+ export function wardleyMarketLinkProps(sourceId, targetId) {
298
+ return {
299
+ type: 'connector',
300
+ mode: ConnectorMode.Straight,
301
+ source: { id: sourceId },
302
+ target: { id: targetId },
303
+ stroke: MARKET_LINK_COLOR,
304
+ strokeStyle: StrokeStyle.Solid,
305
+ strokeWidth: MARKET_LINK_WIDTH,
306
+ frontEndpointStyle: PointStyle.None,
307
+ rearEndpointStyle: PointStyle.None,
308
+ };
309
+ }
310
+ /** The pairs the triangle wires, given the three dots in creation order. */
311
+ export function wardleyMarketLinkPairs(dots) {
312
+ return [
313
+ [dots[0], dots[1]],
314
+ [dots[1], dots[2]],
315
+ [dots[2], dots[0]],
316
+ ];
317
+ }
318
+ /**
319
+ * The four arrow outlines, north first and then clockwise.
320
+ *
321
+ * Normalized, so they are the same seven points at every size — the BOX carries
322
+ * the scale — and one list per direction rather than one list plus a `rotate`,
323
+ * because a rotated element is one the selection, the resize handles and every
324
+ * bounding-box reader then have to de-rotate. Each is: tip, the two head
325
+ * corners, and the four corners of the shaft, with the head
326
+ * `headLength / length` of the way down and the shaft `width / headWidth` wide.
327
+ */
328
+ const PORTER_HEAD = PORTER_ARROW.headLength / PORTER_ARROW.length; // 0.4
329
+ const PORTER_SHAFT_LOW = (1 - PORTER_ARROW.width / PORTER_ARROW.headWidth) / 2;
330
+ const PORTER_SHAFT_HIGH = 1 - PORTER_SHAFT_LOW;
331
+ const PORTER_VERTICES = [
332
+ // North — the tip at the top edge, the shaft running back down to the rim.
333
+ [
334
+ [0.5, 0],
335
+ [1, PORTER_HEAD],
336
+ [PORTER_SHAFT_HIGH, PORTER_HEAD],
337
+ [PORTER_SHAFT_HIGH, 1],
338
+ [PORTER_SHAFT_LOW, 1],
339
+ [PORTER_SHAFT_LOW, PORTER_HEAD],
340
+ [0, PORTER_HEAD],
341
+ ],
342
+ // East — the same outline turned a quarter, written out rather than rotated.
343
+ [
344
+ [1, 0.5],
345
+ [1 - PORTER_HEAD, 1],
346
+ [1 - PORTER_HEAD, PORTER_SHAFT_HIGH],
347
+ [0, PORTER_SHAFT_HIGH],
348
+ [0, PORTER_SHAFT_LOW],
349
+ [1 - PORTER_HEAD, PORTER_SHAFT_LOW],
350
+ [1 - PORTER_HEAD, 0],
351
+ ],
352
+ // South.
353
+ [
354
+ [0.5, 1],
355
+ [0, 1 - PORTER_HEAD],
356
+ [PORTER_SHAFT_LOW, 1 - PORTER_HEAD],
357
+ [PORTER_SHAFT_LOW, 0],
358
+ [PORTER_SHAFT_HIGH, 0],
359
+ [PORTER_SHAFT_HIGH, 1 - PORTER_HEAD],
360
+ [1, 1 - PORTER_HEAD],
361
+ ],
362
+ // West.
363
+ [
364
+ [0, 0.5],
365
+ [PORTER_HEAD, 0],
366
+ [PORTER_HEAD, PORTER_SHAFT_LOW],
367
+ [1, PORTER_SHAFT_LOW],
368
+ [1, PORTER_SHAFT_HIGH],
369
+ [PORTER_HEAD, PORTER_SHAFT_HIGH],
370
+ [PORTER_HEAD, 1],
371
+ ],
372
+ ];
373
+ /** Whether a direction runs along X — the axis its box is long on. */
374
+ const PORTER_HORIZONTAL = [false, true, false, true];
375
+ /** Unit vector per direction, north first and then clockwise. */
376
+ const PORTER_DIRECTIONS = [
377
+ [0, -1],
378
+ [1, 0],
379
+ [0, 1],
380
+ [-1, 0],
381
+ ];
382
+ /**
383
+ * The four arrows of a Porter's-forces glyph, for a circle of the given radius
384
+ * centred on (cx, cy) — a box and an outline each.
385
+ *
386
+ * The ONE description of the geometry, so the creation site, the map legend and
387
+ * the tests read the same numbers rather than three sets that agreed the day
388
+ * they were written. Each arrow starts `PORTER_ARROW.gap` clear of the rim —
389
+ * the arrows push against the circle, they do not touch it — and runs
390
+ * `PORTER_ARROW.length` further out, tip at the far end.
391
+ *
392
+ * Every distance scales WITH the radius, and that is what makes a legend row the
393
+ * same drawing at another size: a 12-unit glyph carrying four map-sized arrows
394
+ * would not be a small Porter, it would be a circle somebody stabbed.
395
+ */
396
+ export function wardleyPorterArrows(cx, cy, radius = PORTER_SIZE / 2) {
397
+ const scale = radius / (PORTER_SIZE / 2);
398
+ const near = radius + PORTER_ARROW.gap * scale;
399
+ const long = PORTER_ARROW.length * scale;
400
+ const across = PORTER_ARROW.headWidth * scale;
401
+ return PORTER_DIRECTIONS.map(([dx, dy], index) => {
402
+ const horizontal = PORTER_HORIZONTAL[index];
403
+ const w = horizontal ? long : across;
404
+ const h = horizontal ? across : long;
405
+ // The near edge of the box sits `near` from the centre along the direction;
406
+ // the box then extends outward, and is centred on the other axis.
407
+ const x = horizontal ? (dx > 0 ? cx + near : cx - near - long) : cx - w / 2;
408
+ const y = horizontal ? cy - h / 2 : dy > 0 ? cy + near : cy - near - long;
409
+ return {
410
+ xywh: new Bound(x, y, w, h).serialize(),
411
+ vertices: PORTER_VERTICES[index].map(([vx, vy]) => [vx, vy]),
412
+ };
413
+ });
414
+ }
415
+ /**
416
+ * One of those arrows as props: a filled POLYGON on a `wardleyNode`, and
417
+ * deliberately ROLE-LESS.
418
+ *
419
+ * A polygon and not a connector, which is the first fix the recette of #210
420
+ * asked for: a connector's triangle head is sized off its stroke width, so the
421
+ * heads arrived longer than the arrows and covered the circle, the letter and
422
+ * the double-click target. A polygon's outline is exactly the seven points
423
+ * above.
424
+ *
425
+ * A `wardleyNode` and not a plain `shape`, which is the second: recette v2
426
+ * found that `ShapeElementView` gives EVERY plain shape a double-click that
427
+ * mounts the inner-text editor, and mounting it grew an arrow from 24 units
428
+ * high to 44 — a deformation that survived Escape. An arrow is not a thing you
429
+ * write in. Posting it as a `wardleyNode` moves it onto `WardleyNodeView`,
430
+ * which opens an editor only on the artefact that HAS a letter; the renderer is
431
+ * unaffected, because a Wardley node draws itself through the native shape
432
+ * renderer and that renderer already dispatches on `shapeType`.
433
+ *
434
+ * Role-less, and `kind: 'porter'` with it, for the reason the market's three
435
+ * inner dots already give — they are `kind: 'component'` and carry no role: this
436
+ * is the glyph's own wiring rather than anything the author drew, so a role
437
+ * would make every composite report an overlap with itself (W3), would offer
438
+ * four arrows to the OWM writer, and would let a stray label bind to one.
439
+ * `undefined` writes no key at all, exactly as it does for those dots.
440
+ *
441
+ * Filled rather than stroked, so nothing about them is sized off a line width
442
+ * ever again.
443
+ */
444
+ export function wardleyPorterArrowProps(arrow) {
445
+ return {
446
+ type: 'wardleyNode',
447
+ kind: 'porter',
448
+ role: undefined,
449
+ shapeType: 'polygon',
450
+ vertices: arrow.vertices,
451
+ isClosed: true,
452
+ filled: true,
453
+ fillColor: WARDLEY_RED,
454
+ // The same red, so the arrow has no rim of another colour around it.
455
+ strokeColor: WARDLEY_RED,
456
+ strokeWidth: 0,
457
+ shapeStyle: ShapeStyle.General,
458
+ roughness: 0,
459
+ xywh: arrow.xywh,
460
+ };
461
+ }
462
+ /**
463
+ * The letter inside the circle, as shape-text props.
464
+ *
465
+ * A plain STRING and not a `Y.Text`: `ShapeElementModel.propsToY` builds the
466
+ * Y type on the way into the document, and this package depends on yjs only in
467
+ * its tests. `TextFitMode.Overflow` is the load-bearing line — the glyph has a
468
+ * canonical size that says "external force" at a glance, so the circle must
469
+ * never grow to fit whatever the author types into it, exactly as the inertia
470
+ * bar never deforms around its own text.
471
+ */
472
+ export function wardleyPorterLetterProps(letter, fontSize = PORTER_LETTER_FONT_SIZE) {
473
+ return {
474
+ text: letter,
475
+ color: NODE_STROKE,
476
+ fontFamily: FontFamily.Inter,
477
+ fontSize,
478
+ textAlign: 'center',
479
+ textHorizontalAlign: 'center',
480
+ textVerticalAlign: 'center',
481
+ textFitMode: TextFitMode.Overflow,
482
+ };
483
+ }
484
+ /**
485
+ * The box {@link wardleyMorphProps} hands {@link wardleyNodeProps} and then
486
+ * throws away. Never written to a document: the morph places the element
487
+ * itself, from {@link wardleyCanonicalBox}, in `afterMorph`.
488
+ */
489
+ const DISCARDED_BOX = '[0,0,0,0]';
490
+ /** What a morph must never rewrite: identity, geometry, and the user's words. */
491
+ const NOT_A_MORPH = ['type', 'xywh', 'text'];
492
+ /**
493
+ * What a kind is worth to a node that ALREADY EXISTS — {@link wardleyNodeProps}
494
+ * minus the three things a morph has no business touching.
495
+ *
496
+ * The whole preset and not `{kind, role}`, and on this pack it is visibly not a
497
+ * formality: a pipeline is a rect with square corners and a semi-transparent
498
+ * fill, and the other three members of the family are opaque white ellipses. A
499
+ * two-key patch would turn a component into a "pipeline" still drawn as a
500
+ * circle.
501
+ */
502
+ export function wardleyMorphProps(kind) {
503
+ const props = {
504
+ ...wardleyNodeProps(kind, { xywh: DISCARDED_BOX }),
505
+ };
506
+ for (const key of NOT_A_MORPH)
507
+ delete props[key];
508
+ return props;
509
+ }
510
+ /**
511
+ * The kinds that can actually MORPH — the one family `morph.ts` declares.
512
+ *
513
+ * Restated here rather than imported, and that is a circular-import dodge with
514
+ * a real reason behind it: `morph.ts` reads this module for the presets, so
515
+ * this module cannot read it back. `morph.unit.spec.ts` asserts the two lists
516
+ * are the same set, so the restatement cannot drift.
517
+ *
518
+ * It exists because {@link wardleyMorphClears} needs a UNION, and a union over
519
+ * the whole pack is the wrong one. Since the accelerator and the decelerator
520
+ * joined it, "every key any kind writes" includes `vertices` and `isClosed` —
521
+ * keys of two POLYGONS that are in no family and that nothing may ever morph
522
+ * into. Left unrestricted, morphing a market back to a component would emit a
523
+ * delete for `vertices` on an ellipse that never had one: harmless today, and a
524
+ * patch that says something untrue about the pack the day a morphable kind
525
+ * gains an outline.
526
+ */
527
+ export const WARDLEY_MORPHABLE_KINDS = [
528
+ 'component',
529
+ 'market',
530
+ 'ecosystem',
531
+ 'pipeline',
532
+ ];
533
+ /**
534
+ * Every key a MORPHABLE kind's props may carry — the union over that family.
535
+ *
536
+ * Computed rather than listed, so a preset that starts spreading a key
537
+ * conditionally is covered on the day it is added rather than on the day
538
+ * somebody notices.
539
+ */
540
+ const EVERY_MORPH_KEY = new Set(WARDLEY_MORPHABLE_KINDS.flatMap(kind => Object.keys(wardleyMorphProps(kind))));
541
+ /**
542
+ * The fields to DELETE after morphing to `kind` — the keys some other kind
543
+ * writes and this one does not.
544
+ *
545
+ * `radius` for every kind but the pipeline, and that is not a formality either:
546
+ * a patch cannot express absence, so a pipeline morphed back to a component
547
+ * would keep `radius: 0` sitting in the Y.Map, silently in force on an ellipse.
548
+ * Derived rather than listed, so the day a preset gains a conditional key it is
549
+ * covered without anybody remembering to come here.
550
+ */
551
+ export function wardleyMorphClears(kind) {
552
+ const present = new Set(Object.keys(wardleyMorphProps(kind)));
553
+ return [...EVERY_MORPH_KEY].filter(key => !present.has(key));
554
+ }
package/dist/roles.d.ts CHANGED
@@ -41,6 +41,10 @@ export declare const WARDLEY_ROLE: {
41
41
  readonly market: "wardley:market";
42
42
  readonly ecosystem: "wardley:ecosystem";
43
43
  readonly method: "wardley:method";
44
+ readonly porter: "wardley:porter";
45
+ readonly accelerator: "wardley:accelerator";
46
+ readonly decelerator: "wardley:decelerator";
47
+ readonly area: "wardley:area";
44
48
  readonly dependency: "wardley:dependency";
45
49
  readonly map: "wardley:map";
46
50
  readonly changeArrow: "wardley:change-arrow";
package/dist/roles.js CHANGED
@@ -7,6 +7,10 @@ export const WARDLEY_ROLE = {
7
7
  market: 'wardley:market',
8
8
  ecosystem: 'wardley:ecosystem',
9
9
  method: 'wardley:method',
10
+ porter: 'wardley:porter',
11
+ accelerator: 'wardley:accelerator',
12
+ decelerator: 'wardley:decelerator',
13
+ area: 'wardley:area',
10
14
  dependency: 'wardley:dependency',
11
15
  map: 'wardley:map',
12
16
  changeArrow: 'wardley:change-arrow',
@@ -55,16 +59,84 @@ const DEFS = [
55
59
  labelKey: 'com.labre.wardley.role.method',
56
60
  },
57
61
  /**
58
- * The value-chain link: "A depends on B".
62
+ * A Porter's-forces glyph: one of the five forces of external competition
63
+ * bearing on the map (R relative competition, L struggle for survival, E
64
+ * struggle to establish — the letter inside the circle says which).
65
+ *
66
+ * NO `parent`, and that is the whole declaration rather than an omission. A
67
+ * market and an ecosystem specialise `component` because they ARE links in
68
+ * the value chain drawn at another grain. This one is not a link in it at
69
+ * all: it is a pressure applied from OUTSIDE, so no rule written on
70
+ * `wardley:component` — W3's overlap pairs above all — may ever match it. A
71
+ * force is allowed to sit on top of the components it presses against; that
72
+ * is what pressing against them looks like.
73
+ */
74
+ {
75
+ id: WARDLEY_ROLE.porter,
76
+ kind: 'node',
77
+ labelKey: 'com.labre.wardley.role.porter',
78
+ labelFallback: "Porter's forces",
79
+ },
80
+ /**
81
+ * An accelerator, and its mirror the decelerator: something that speeds
82
+ * evolution up, or slows it down.
83
+ *
84
+ * NO `parent`, for the reason the porter above gives and one of its own. An
85
+ * accelerator is not a link in the value chain drawn at another grain — it is
86
+ * an annotation of the CLIMATE, a statement about the pace at which the map
87
+ * moves rather than about what depends on what. So no rule written on
88
+ * `wardley:component` may match one: W3's overlap pairs above all, since an
89
+ * accelerator is drawn precisely where the components it accelerates are.
90
+ *
91
+ * Two roles and not one with a direction, because the two say opposite
92
+ * things: "this is going faster" and "this is being held back" are not the
93
+ * same claim read from either end, and a rule about one must never fall on
94
+ * the other.
95
+ */
96
+ {
97
+ id: WARDLEY_ROLE.accelerator,
98
+ kind: 'node',
99
+ labelKey: 'com.labre.wardley.role.accelerator',
100
+ labelFallback: 'Accelerator',
101
+ },
102
+ {
103
+ id: WARDLEY_ROLE.decelerator,
104
+ kind: 'node',
105
+ labelKey: 'com.labre.wardley.role.decelerator',
106
+ labelFallback: 'Decelerator',
107
+ },
108
+ /**
109
+ * An area: a zone of the map, drawn around the components it groups.
110
+ *
111
+ * NO `parent`, and for the sharpest version of the reason the porter and the
112
+ * accelerator already give. A zone is not a link in the value chain at any
113
+ * grain — it is a statement about a REGION of the map — and it is drawn
114
+ * precisely ON TOP of the components it names, so a rule written on
115
+ * `wardley:component` would report an overlap (W3) on every area that does
116
+ * its job. Covering the chain is what a zone IS.
117
+ */
118
+ {
119
+ id: WARDLEY_ROLE.area,
120
+ kind: 'node',
121
+ labelKey: 'com.labre.wardley.role.area',
122
+ labelFallback: 'Area',
123
+ },
124
+ /**
125
+ * The value-chain link: "A needs B".
59
126
  *
60
127
  * The comment above was the whole of the convention until `docs/adr/0010`
61
128
  * made it DATA. Tier 1 of that ADR is generic — `source` is the subject of
62
129
  * the role's verb, `target` its object — and tier 2 is this `direction`
63
- * block: the verb of THIS role is "depends on", so its source is the
64
- * consumer and its target is what the consumer needs. Needs descend from
65
- * source to target; value flows back up. W4 evaluates exactly that reading,
66
- * and the tool hint below is what makes it a statement the user made rather
67
- * than a by-product of which end their finger landed on first.
130
+ * block: the verb of THIS role is "needs", so its source is the consumer and
131
+ * its target is what the consumer needs. Needs descend from source to target;
132
+ * value flows back up. W4 evaluates exactly that reading, and the tool hint
133
+ * below is what makes it a statement the user made rather than a by-product
134
+ * of which end their finger landed on first.
135
+ *
136
+ * The verb read "depends on" until the PO recette of 02/09/2026: one word, in
137
+ * the user's own vocabulary, on a chip that is laid ALONG the link and must
138
+ * not outgrow it. Only the FALLBACK moved — `verbKey` is stable, and the
139
+ * French wording ("A besoin") comes from the host's catalogue.
68
140
  */
69
141
  {
70
142
  id: WARDLEY_ROLE.dependency,
@@ -73,9 +145,15 @@ const DEFS = [
73
145
  labelFallback: 'Dependency',
74
146
  direction: {
75
147
  verbKey: 'com.labre.wardley.role.dependency.verb',
76
- verbFallback: 'depends on',
148
+ verbFallback: 'needs',
77
149
  gestureHintKey: 'com.labre.wardley.role.dependency.gesture',
78
150
  gestureHintFallback: 'Drag from the component that has the need to what it needs.',
151
+ // The house primary, on THIS chip and no other (same recette). The value
152
+ // chain is what a Wardley map is read along, and the PO asked for the
153
+ // relation that carries it to be unmistakable — a decision about one
154
+ // relation, so it is declared on that relation rather than on the
155
+ // mechanism, and every other framework's reveal keeps the colour it had.
156
+ chipColor: '#2563eb',
79
157
  },
80
158
  },
81
159
  // The map itself: the axes frame the other roles are positioned against.