@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/morph.js ADDED
@@ -0,0 +1,405 @@
1
+ import { morphLabel, } from '@formicoidea/labre-core/blocks/surface';
2
+ import { ConnectorElementModel, GroupElementModel, TextElementModel, WardleyNodeElementModel, } from '@formicoidea/labre-core/model';
3
+ import { wardleyCommandIcons, wardleyCommands } from './commands.js';
4
+ import { wardleyCanonicalBox, wardleyHandleBox, wardleyHandleProps, wardleyMarketDotBoxes, wardleyMarketDotProps, wardleyMarketLinkPairs, wardleyMarketLinkProps, WARDLEY_NODE_LABEL, wardleyMorphClears, wardleyMorphProps, } from './presets.js';
5
+ import { WARDLEY_ROLE } from './roles.js';
6
+ /**
7
+ * What a Wardley artefact may BECOME — the declaration behind the "Change type"
8
+ * dropdown on a selected component's contextual toolbar.
9
+ *
10
+ * ## One family, and why it is exactly these four
11
+ *
12
+ * A component, a market, an ecosystem and a pipeline are four ways of saying
13
+ * the SAME thing: something the value chain depends on. The map's own reading
14
+ * agrees — `roles.ts` makes `market` and `ecosystem` children of `component` in
15
+ * the role tree — and the modelling gesture the morph exists for is the one
16
+ * every mapper makes: you draw a component, you look at the map, and you
17
+ * realise that what you drew is a market of many suppliers, or a whole
18
+ * ecosystem, or a pipeline of choices. Today that costs a delete, a re-draw,
19
+ * every dependency re-attached and the name typed again.
20
+ *
21
+ * The family is DATA and nothing derives it — the same call BPMN and C4 make.
22
+ * Deriving it from `roleIsA` would have been wrong in both directions here: it
23
+ * would reach from market to component and not back, and it would say nothing
24
+ * at all about the pipeline, which is not a specialisation of anything and is
25
+ * still the fourth way of drawing the same claim.
26
+ *
27
+ * ## The two kinds deliberately left out
28
+ *
29
+ * - **`anchor`** is the user, and the need they have. It is a role of its own
30
+ * precisely because it is NOT a component (`roles.ts`): it is what the value
31
+ * chain hangs from, not a link in it. A menu offering "turn this user into a
32
+ * pipeline" would invite a map with no anchor at all — the one thing a
33
+ * Wardley map cannot be read without.
34
+ * - **`method`** is not an artefact but an annotation ON one: the coloured ring
35
+ * says how a component is being BUILT (buy / build / outsource), and its fill
36
+ * encodes the choice. Morphing it away would silently discard that decision,
37
+ * and morphing into it would have to invent one.
38
+ *
39
+ * `handle` is in no family either, and does not need excluding by name: it is
40
+ * not an artefact at all, so nothing selects it and {@link wardleyNodeOfComponent}
41
+ * never resolves to one.
42
+ */
43
+ export const WARDLEY_MORPH_FAMILIES = [
44
+ // Declaration order is menu order, and it opens on the plain component: the
45
+ // undecorated artefact is the honest first draft, and the three others are
46
+ // refinements of it — the same call `commands.ts` makes about the senior row.
47
+ ['component', 'market', 'ecosystem', 'pipeline'],
48
+ ];
49
+ /** The same set, as a lookup — the gate every resolution below runs through. */
50
+ const MORPH_KINDS = new Set(WARDLEY_MORPH_FAMILIES.flat());
51
+ /**
52
+ * The creation command that draws each kind, keyed BY that kind.
53
+ *
54
+ * Derived from `telemetry.element`, which is where the kind is already written
55
+ * down (`node:market`) and is documented as a historical value that must not be
56
+ * renamed. Deriving rather than restating is what stops a second table of
57
+ * labels and icons drifting from the one the sub-menu and the catalogue read.
58
+ */
59
+ const NODE_COMMANDS = new Map(wardleyCommands.flatMap(command => {
60
+ const element = command.telemetry?.element;
61
+ return element?.startsWith('node:')
62
+ ? [[element.slice('node:'.length), command]]
63
+ : [];
64
+ }));
65
+ /** A kind's wording: the creation command's own key and English. */
66
+ function labelOf(kind) {
67
+ const command = NODE_COMMANDS.get(kind);
68
+ return {
69
+ key: command?.labelKey,
70
+ fallback: command?.labelFallback ?? kind,
71
+ };
72
+ }
73
+ /** A kind's icon: the creation command's own, reused rather than redrawn. */
74
+ function iconOf(kind) {
75
+ const iconKey = NODE_COMMANDS.get(kind)?.iconKey;
76
+ return ((iconKey && wardleyCommandIcons[iconKey]) ||
77
+ wardleyCommandIcons['wardley.component']);
78
+ }
79
+ /* ── Resolving the composite ───────────────────────────────────────────── */
80
+ /**
81
+ * Every element under a group, however deeply nested.
82
+ *
83
+ * Recursive because the pipeline's own creation site nests — (handle + label),
84
+ * then (body + that) — so a flat pass over `childElements` would miss half of
85
+ * one of the four artefacts this file is about.
86
+ */
87
+ function* descendants(group) {
88
+ for (const child of group.childElements) {
89
+ yield child;
90
+ if (child instanceof GroupElementModel)
91
+ yield* descendants(child);
92
+ }
93
+ }
94
+ /**
95
+ * The `wardleyNode` a selected GROUP is the artefact of — `undefined` when the
96
+ * group is not one.
97
+ *
98
+ * Every Wardley artefact in this family is a composite: a component is a circle
99
+ * and its label, a market adds three dots and a triangle, and a pipeline is a
100
+ * body plus a handle plus a label in a nested group. So what a click selects is
101
+ * the outer group, and what carries `kind` is one node inside it — RECURSIVELY
102
+ * inside it, because the pipeline's own creation nests.
103
+ *
104
+ * Three conditions, and each one excludes a piece of the notation rather than
105
+ * an accident:
106
+ *
107
+ * - a `WardleyNodeElementModel`, which leaves out the labels and the connectors;
108
+ * - with a ROLE, which leaves out the market's three neutral dots — they are the
109
+ * glyph's wiring and carry none by construction (`presets.ts`);
110
+ * - whose kind is in the family, which leaves out the pipeline's `handle` and
111
+ * any `anchor` or `method` somebody grouped in beside the artefact.
112
+ *
113
+ * TWO candidates is a REFUSAL rather than a first-wins pick, exactly as C4's
114
+ * resolution refuses a group of two components: morphing "it" would mean
115
+ * picking one by document order, and the honest answer to an ambiguous
116
+ * selection is nothing at all. A plain lasso round three shapes, a C4 component
117
+ * and a group of two Wardley components all answer `undefined`, so none of them
118
+ * is ever offered the menu.
119
+ */
120
+ export function wardleyNodeOfComponent(model) {
121
+ if (!(model instanceof GroupElementModel))
122
+ return undefined;
123
+ let found;
124
+ for (const child of descendants(model)) {
125
+ if (!(child instanceof WardleyNodeElementModel))
126
+ continue;
127
+ if (child.role === undefined)
128
+ continue;
129
+ if (!MORPH_KINDS.has(child.kind))
130
+ continue;
131
+ if (found)
132
+ return undefined;
133
+ found = child;
134
+ }
135
+ return found;
136
+ }
137
+ /* ── The structural half of the morph ──────────────────────────────────── */
138
+ /**
139
+ * The name an artefact should carry once it has morphed — or `null` when the
140
+ * name is the AUTHOR's and must not be touched.
141
+ *
142
+ * Exactly one case rewrites: the label is the source kind's own creation
143
+ * prompt, letter for letter, which is what an artefact nobody has named still
144
+ * says. Everything else — a name typed over it, the prompt with a word added, a
145
+ * cleared label, another kind's prompt — is content, and content survives a
146
+ * morph untouched. The same timid rule `c4MorphedTitle` states, and for the same
147
+ * reason: a morph may not take away words somebody put on the picture.
148
+ *
149
+ * Pure and total over every string: the input is a canvas text element somebody
150
+ * may have typed anything into.
151
+ */
152
+ export function wardleyMorphedLabel(from, to, rawText) {
153
+ return (rawText ?? '').trim() === WARDLEY_NODE_LABEL[from]
154
+ ? WARDLEY_NODE_LABEL[to]
155
+ : null;
156
+ }
157
+ /** The centre of an element, from the box it currently occupies. */
158
+ function centreOf(model) {
159
+ const [x, y, w, h] = model.deserializedXYWH;
160
+ return [x + w / 2, y + h / 2];
161
+ }
162
+ /** The label of a composite — `undefined` when there is not exactly one. */
163
+ function labelOfComposite(group) {
164
+ let found;
165
+ for (const child of descendants(group)) {
166
+ if (!(child instanceof TextElementModel))
167
+ continue;
168
+ if (child.role !== WARDLEY_ROLE.label)
169
+ continue;
170
+ if (found)
171
+ return undefined;
172
+ found = child;
173
+ }
174
+ return found;
175
+ }
176
+ /**
177
+ * Point every connector that named `fromId` at `toId` instead.
178
+ *
179
+ * The whole point of morphing rather than re-drawing: a component wired into
180
+ * half a value chain becomes a pipeline and keeps every dependency it had. The
181
+ * endpoint is rewritten to the bare `{ id }` and the `position` is dropped on
182
+ * purpose — a position is stored RELATIVE to the element it names, so carrying
183
+ * one across to a different box would land the arrow somewhere nobody chose.
184
+ * Every Wardley node is `centerAnchorOnly` anyway, so a connector this framework
185
+ * drew never had one.
186
+ */
187
+ function reanchor(surface, fromId, toId) {
188
+ for (const element of surface.elementModels) {
189
+ if (!(element instanceof ConnectorElementModel))
190
+ continue;
191
+ if (element.isLocked())
192
+ continue;
193
+ if (element.source?.id === fromId) {
194
+ surface.updateElement(element.id, { source: { id: toId } });
195
+ }
196
+ if (element.target?.id === fromId) {
197
+ surface.updateElement(element.id, { target: { id: toId } });
198
+ }
199
+ }
200
+ }
201
+ /**
202
+ * Take the market's glyph apart: the three neutral dots, and the triangle
203
+ * wiring them.
204
+ *
205
+ * Identified the way {@link wardleyNodeOfComponent} identifies what it must NOT
206
+ * resolve to — a `wardleyNode` with no role — and the connectors by the fact
207
+ * that both their ends name one of those dots. Never by document order, and
208
+ * never by "the connectors in this group": a dependency the user drew from
209
+ * their market to something else is a child of nothing and must survive, and one
210
+ * they drew INTO the composite still has one end outside it.
211
+ */
212
+ function removeMarketGlyph(group) {
213
+ const surface = group.surface;
214
+ const dots = [...descendants(group)].filter((child) => child instanceof WardleyNodeElementModel && child.role === undefined);
215
+ const dotIds = new Set(dots.map(dot => dot.id));
216
+ for (const child of [...descendants(group)]) {
217
+ if (!(child instanceof ConnectorElementModel))
218
+ continue;
219
+ const { id: sourceId } = child.source ?? {};
220
+ const { id: targetId } = child.target ?? {};
221
+ if (sourceId === undefined || targetId === undefined)
222
+ continue;
223
+ if (!dotIds.has(sourceId) || !dotIds.has(targetId))
224
+ continue;
225
+ surface.deleteElement(child.id);
226
+ }
227
+ for (const dot of dots)
228
+ surface.deleteElement(dot.id);
229
+ }
230
+ /**
231
+ * Build the market's glyph round a centre: three dots and the triangle between
232
+ * them, from the very same constants `createWardleyMarket` uses.
233
+ *
234
+ * Added to the group AFTER the circle they sit in, which is also the painting
235
+ * order the creation site produces — a market drawn from the sub-menu and one
236
+ * morphed into being are the same stack of elements.
237
+ */
238
+ function addMarketGlyph(group, cx, cy) {
239
+ const surface = group.surface;
240
+ const dotIds = wardleyMarketDotBoxes(cx, cy).map(xywh => surface.addElement(wardleyMarketDotProps({ xywh })));
241
+ const linkIds = wardleyMarketLinkPairs(dotIds).map(([a, b]) => surface.addElement(wardleyMarketLinkProps(a, b)));
242
+ for (const id of [...dotIds, ...linkIds]) {
243
+ const element = surface.getElementById(id);
244
+ if (element)
245
+ group.addChild(element);
246
+ }
247
+ }
248
+ /**
249
+ * Give a pipeline the handle it is connected through, and move the user's own
250
+ * links onto it.
251
+ *
252
+ * ## The handle lands FLAT in the outer group — a documented simplification
253
+ *
254
+ * `createWardleyPipeline` nests: it groups (handle + label), then groups that
255
+ * with the body. Nothing depends on the nesting — the handle is found by kind
256
+ * and the label by role, both recursively — and a morph that rebuilt it would
257
+ * have to move the user's label into a group it never asked for. So the morph
258
+ * adds the handle as a sibling of the body. A pipeline morphed into being is
259
+ * therefore ONE group of three elements where a drawn one is a group of two;
260
+ * they behave identically on the canvas, in the exporter and under every rule,
261
+ * and the reverse morph flattens a drawn one the same way.
262
+ */
263
+ function addHandle(group, carrier, cx, cy) {
264
+ const surface = group.surface;
265
+ const handleId = surface.addElement(wardleyHandleProps({ xywh: wardleyHandleBox(cx, cy) }));
266
+ const handle = surface.getElementById(handleId);
267
+ if (handle)
268
+ group.addChild(handle);
269
+ // A pipeline body declares `connectable === false`, so every link the user
270
+ // drew to the component has to move to the handle or it would point at
271
+ // something that no longer accepts it.
272
+ reanchor(surface, carrier.id, handleId);
273
+ }
274
+ /**
275
+ * Take the handle away, and give the user's links back to the body.
276
+ *
277
+ * The mirror of {@link addHandle}, plus the one piece of tidying the nesting
278
+ * makes necessary: a pipeline DRAWN from the sub-menu keeps its label in an
279
+ * inner group with the handle, and removing the handle would leave a group of
280
+ * one behind. The label is promoted into the outer group and the empty wrapper
281
+ * deleted, which is what leaves a morphed artefact looking exactly like a drawn
282
+ * one of its new kind.
283
+ */
284
+ function removeHandle(group, carrier) {
285
+ const surface = group.surface;
286
+ const handle = [...descendants(group)].find((child) => child instanceof WardleyNodeElementModel && child.kind === 'handle');
287
+ if (!handle)
288
+ return;
289
+ // Before the deletion, never after: the links have to name something.
290
+ reanchor(surface, handle.id, carrier.id);
291
+ const inner = handle.group;
292
+ surface.deleteElement(handle.id);
293
+ if (!(inner instanceof GroupElementModel) || inner === group)
294
+ return;
295
+ // Emptied first, so `deleteElement` — which deletes a group's children with
296
+ // it — has nothing left to take.
297
+ for (const orphan of [...inner.childElements]) {
298
+ inner.removeChild(orphan);
299
+ group.addChild(orphan);
300
+ }
301
+ surface.deleteElement(inner.id);
302
+ }
303
+ /**
304
+ * Keep the artefact's own name saying what the artefact now is.
305
+ *
306
+ * The one exception to "the words and the placement are the user's": the
307
+ * placeholder rule of {@link wardleyMorphedLabel}. In place and in one
308
+ * transaction, because the `Y.Text` instance is what a bound editor holds, and
309
+ * inside the caller's `captureSync`, so the rewrite is part of the same single
310
+ * ctrl+z as the kind that made it necessary.
311
+ */
312
+ function rewriteLabel(group, from, to) {
313
+ const label = labelOfComposite(group);
314
+ if (!label || label.isLocked())
315
+ return;
316
+ const text = label.text.toString().trim();
317
+ const next = wardleyMorphedLabel(from, to, text);
318
+ if (next === null || next === text)
319
+ return;
320
+ label.surface.store.transact(() => {
321
+ label.text.delete(0, label.text.length);
322
+ label.text.insert(0, next);
323
+ });
324
+ }
325
+ /**
326
+ * Everything the morph owes a Wardley composite beyond the props patch — the
327
+ * `afterMorph` half of the spec, run inside the generic module's one
328
+ * `captureSync` so all of it is a single ctrl+z.
329
+ *
330
+ * ## The geometry, and why this framework writes it
331
+ *
332
+ * A DEVIATION from BPMN and C4, decided by the PO: those two keep the box the
333
+ * user gave the element, because there a size is a preference. Here it is the
334
+ * NOTATION. A market is drawn bigger than a component so a reader can tell them
335
+ * apart at a glance, an ecosystem bigger again, and a pipeline is a 120-wide bar
336
+ * — a market left at a component's 18 pixels is an unreadable smudge with three
337
+ * dots crammed into it. So the target's canonical size is applied, CENTRED on
338
+ * where the artefact already stands: the thing does not move, it becomes the
339
+ * size its notation says it is. The label keeps its own place, which is the
340
+ * user's.
341
+ *
342
+ * ## And why the rest is here rather than in the patch
343
+ *
344
+ * Because a market IS its three dots and a pipeline IS its handle. A patch can
345
+ * write `kind: 'market'` onto a circle and the result is a circle that claims to
346
+ * be a market and does not look like one; a patch cannot create an element, nor
347
+ * move a connector somebody drew. That is exactly what `afterMorph` is for, and
348
+ * the ORDER below is the whole of the care it needs: the glyph the artefact is
349
+ * LEAVING comes apart before the one it is arriving at is built, so a
350
+ * market → pipeline never has three dots and a handle at once.
351
+ */
352
+ export function wardleyMorphComposite(selected, from, to) {
353
+ if (!(selected instanceof GroupElementModel))
354
+ return;
355
+ const carrier = wardleyNodeOfComponent(selected);
356
+ if (!carrier)
357
+ return;
358
+ const [cx, cy] = centreOf(carrier);
359
+ carrier.surface.updateElement(carrier.id, {
360
+ xywh: wardleyCanonicalBox(to, cx, cy),
361
+ });
362
+ if (from === 'market')
363
+ removeMarketGlyph(selected);
364
+ if (from === 'pipeline')
365
+ removeHandle(selected, carrier);
366
+ if (to === 'market')
367
+ addMarketGlyph(selected, cx, cy);
368
+ if (to === 'pipeline')
369
+ addHandle(selected, carrier, cx, cy);
370
+ rewriteLabel(selected, from, to);
371
+ }
372
+ /**
373
+ * Wardley's morph declaration, handed to the generic `morphToolbarConfig`.
374
+ *
375
+ * `modelType` is the native `GroupElementModel` and not
376
+ * `WardleyNodeElementModel`, because every artefact in this family is a
377
+ * composite and the group is what a click on one selects. Everything that makes
378
+ * that safe is in {@link wardleyNodeOfComponent}: the toolbar's own homogeneity
379
+ * test only proves that each selected element is A group, and the resolution
380
+ * proves that each one is one of OURS.
381
+ *
382
+ * `propsOf` is the shipped creation builder minus `type` / `xywh` / `text`
383
+ * ({@link wardleyMorphProps}), so the palette and the morph cannot disagree
384
+ * about what a pipeline looks like, and `clearOf` removes the keys the target
385
+ * does not write — `radius`, which only the pipeline has.
386
+ */
387
+ export const WARDLEY_MORPH_SPEC = {
388
+ framework: 'wardley',
389
+ families: WARDLEY_MORPH_FAMILIES,
390
+ modelType: GroupElementModel,
391
+ resolveTarget: wardleyNodeOfComponent,
392
+ kindOf: (model) => model instanceof WardleyNodeElementModel && MORPH_KINDS.has(model.kind)
393
+ ? model.kind
394
+ : undefined,
395
+ // The NODE's role, which is the only role a Wardley artefact has: the group
396
+ // carries none, the label carries `wardley:label` (which says it is a name,
397
+ // not what it names) and the market's dots carry none at all.
398
+ roleOf: kind => WARDLEY_ROLE[kind],
399
+ propsOf: wardleyMorphProps,
400
+ clearOf: wardleyMorphClears,
401
+ afterMorph: wardleyMorphComposite,
402
+ labelOf,
403
+ iconOf,
404
+ label: morphLabel('com.labre.morph.toolbar.label', 'Change type'),
405
+ };
package/dist/natures.d.ts CHANGED
@@ -47,4 +47,35 @@ export declare const WARDLEY_NATURE: {
47
47
  readonly practice: "wardley:nature/practice";
48
48
  readonly knowledge: "wardley:nature/knowledge";
49
49
  };
50
+ /**
51
+ * The Porter's-forces **competition** tag — which of the forces a glyph marks.
52
+ *
53
+ * The second tag of this pack, and the one that shows the mechanism was worth
54
+ * building: it is declared beside the nature, on the SAME pack, and it applies
55
+ * to a role the nature deliberately never reaches. A force is not a link in the
56
+ * value chain (`roles.ts` says why `wardley:porter` has no parent), so nothing
57
+ * written on `wardley:component` touches it — and this tag, written on
58
+ * `wardley:porter` alone, touches nothing else.
59
+ *
60
+ * ## Why it duplicates the letter, and why that is not a duplication
61
+ *
62
+ * The letter in the circle IS the notation: an architect reads `L` off the map
63
+ * and knows what it says. But a letter is not a FACT anything can query — no
64
+ * rule, no reading, no host report can ask "which forces does this map name?"
65
+ * of a `Y.Text` holding one character. The tag is the queryable half, and
66
+ * `WardleyPorterWatcher` keeps the two saying the same thing in both
67
+ * directions, so neither is the master and the author can work in whichever
68
+ * one is in front of them.
69
+ *
70
+ * `cardinality: 'single'` for the reason the nature gives, and one the drawing
71
+ * gives too: there is exactly one letter in the circle, so a second value would
72
+ * be a qualification the map could not show.
73
+ */
74
+ export declare const WARDLEY_COMPETITION_TAG_ID = "wardley:competition";
75
+ /** The three forces, as value ids. Ids are forever; a def is only deprecated. */
76
+ export declare const WARDLEY_COMPETITION: {
77
+ readonly relative: "wardley:competition/relative";
78
+ readonly struggle: "wardley:competition/struggle";
79
+ readonly establish: "wardley:competition/establish";
80
+ };
50
81
  export declare const WARDLEY_TAG_DEFS: UniverseTagDefs;
package/dist/natures.js CHANGED
@@ -47,6 +47,37 @@ export const WARDLEY_NATURE = {
47
47
  practice: `${WARDLEY_NATURE_TAG_ID}/practice`,
48
48
  knowledge: `${WARDLEY_NATURE_TAG_ID}/knowledge`,
49
49
  };
50
+ /**
51
+ * The Porter's-forces **competition** tag — which of the forces a glyph marks.
52
+ *
53
+ * The second tag of this pack, and the one that shows the mechanism was worth
54
+ * building: it is declared beside the nature, on the SAME pack, and it applies
55
+ * to a role the nature deliberately never reaches. A force is not a link in the
56
+ * value chain (`roles.ts` says why `wardley:porter` has no parent), so nothing
57
+ * written on `wardley:component` touches it — and this tag, written on
58
+ * `wardley:porter` alone, touches nothing else.
59
+ *
60
+ * ## Why it duplicates the letter, and why that is not a duplication
61
+ *
62
+ * The letter in the circle IS the notation: an architect reads `L` off the map
63
+ * and knows what it says. But a letter is not a FACT anything can query — no
64
+ * rule, no reading, no host report can ask "which forces does this map name?"
65
+ * of a `Y.Text` holding one character. The tag is the queryable half, and
66
+ * `WardleyPorterWatcher` keeps the two saying the same thing in both
67
+ * directions, so neither is the master and the author can work in whichever
68
+ * one is in front of them.
69
+ *
70
+ * `cardinality: 'single'` for the reason the nature gives, and one the drawing
71
+ * gives too: there is exactly one letter in the circle, so a second value would
72
+ * be a qualification the map could not show.
73
+ */
74
+ export const WARDLEY_COMPETITION_TAG_ID = 'wardley:competition';
75
+ /** The three forces, as value ids. Ids are forever; a def is only deprecated. */
76
+ export const WARDLEY_COMPETITION = {
77
+ relative: `${WARDLEY_COMPETITION_TAG_ID}/relative`,
78
+ struggle: `${WARDLEY_COMPETITION_TAG_ID}/struggle`,
79
+ establish: `${WARDLEY_COMPETITION_TAG_ID}/establish`,
80
+ };
50
81
  export const WARDLEY_TAG_DEFS = {
51
82
  formatVersion: 1,
52
83
  // The id of this PACK, not of the framework: several packs may extend
@@ -62,10 +93,12 @@ export const WARDLEY_TAG_DEFS = {
62
93
  description: 'What kind of thing this component is: an activity, data, a practice or knowledge.',
63
94
  cardinality: 'single',
64
95
  appliesTo: [WARDLEY_ROLE.component],
65
- // No `order`: it is the only tag this pack declares, and an absent order
66
- // sorts by SEED order, which is what puts the library's pack ahead of a
67
- // client's extension without either having to know about the other. A
68
- // number here would be a claim about packs that do not exist yet.
96
+ // No `order`, on either tag of this pack: an absent order sorts by SEED
97
+ // order, which is what puts the library's pack ahead of a client's
98
+ // extension without either having to know about the other. A number here
99
+ // would be a claim about packs that do not exist yet — and it would say
100
+ // nothing anyway, since the two tags below apply to disjoint roles and
101
+ // are therefore never offered in the same dropdown.
69
102
  values: [
70
103
  {
71
104
  id: WARDLEY_NATURE.activity,
@@ -89,5 +122,32 @@ export const WARDLEY_TAG_DEFS = {
89
122
  },
90
123
  ],
91
124
  },
125
+ {
126
+ id: WARDLEY_COMPETITION_TAG_ID,
127
+ label: 'Competition',
128
+ description: "Which of Porter's forces this glyph marks: relative competition, a struggle for survival, or a struggle to establish.",
129
+ cardinality: 'single',
130
+ appliesTo: [WARDLEY_ROLE.porter],
131
+ values: [
132
+ {
133
+ id: WARDLEY_COMPETITION.relative,
134
+ // The label carries the letter, because the letter is what the map
135
+ // shows: a reader picking a value here is choosing what the circle
136
+ // will say, not filling in a form about it.
137
+ label: 'Relative competition (R)',
138
+ description: 'Established players competing against one another on a market that exists.',
139
+ },
140
+ {
141
+ id: WARDLEY_COMPETITION.struggle,
142
+ label: 'Struggle for survival (L)',
143
+ description: 'Pressure on a player to stay in a market it is already in.',
144
+ },
145
+ {
146
+ id: WARDLEY_COMPETITION.establish,
147
+ label: 'Struggle to establish (E)',
148
+ description: 'Pressure on a newcomer trying to get into a market.',
149
+ },
150
+ ],
151
+ },
92
152
  ],
93
153
  };
@@ -104,3 +104,126 @@ export declare const METHOD: {
104
104
  centerRatio: number;
105
105
  };
106
106
  export declare const METHOD_LABEL = "Component";
107
+ /**
108
+ * Porter's forces (composite): a large white circle carrying ONE letter, pushed
109
+ * on from the four cardinal directions by solid red arrows.
110
+ *
111
+ * Twice the market's diameter, at the PO's recette of #210: this is not a link
112
+ * in the value chain drawn a little differently, it is a pressure bearing on the
113
+ * whole map, and it has to read as one at the zoom an architect actually works
114
+ * at. Every other number here is proportional to it.
115
+ *
116
+ * The letter IS the notation — **R** relative competition, **L** struggle for
117
+ * survival, **E** struggle to establish — which is why it is written as the
118
+ * shape's own inner text rather than as a label beside it: a double-click opens
119
+ * the native shape editor on the circle, and typing another letter is the whole
120
+ * of the edit. It is also why this glyph carries no label at all: the three
121
+ * words behind the three letters are the map's vocabulary, not a name the
122
+ * author gives one force.
123
+ */
124
+ export declare const PORTER_SIZE = 60;
125
+ /**
126
+ * The four arrows, read by `wardleyPorterArrows`: each starts `gap` outside the
127
+ * circle's rim and runs `length` further out, a `width`-wide shaft carrying a
128
+ * head `headLength` long and `headWidth` across.
129
+ *
130
+ * They are drawn as filled POLYGONS rather than as connectors with a triangle
131
+ * endpoint, and the recette of #210 is why: a connector sizes its head off its
132
+ * STROKE WIDTH (`DEFAULT_ARROW_SIZE` × strokeWidth / 2), which is a number about
133
+ * a line rather than about this notation — the head came out longer than the
134
+ * arrow, swallowed the circle, the letter and the double-click, and the glyph
135
+ * rendered as a solid red star. A polygon's head is the number written here and
136
+ * nothing else, at any size.
137
+ */
138
+ export declare const PORTER_ARROW: {
139
+ gap: number;
140
+ length: number;
141
+ /** Shaft width. */
142
+ width: number;
143
+ headLength: number;
144
+ headWidth: number;
145
+ };
146
+ /** The letter, sized to sit inside the circle without touching the rim. */
147
+ export declare const PORTER_LETTER_FONT_SIZE = 28;
148
+ /** The letter a fresh glyph opens on: relative competition, the commonest read. */
149
+ export declare const PORTER_DEFAULT_LETTER = "R";
150
+ /**
151
+ * Accelerators and decelerators — the climate annotations.
152
+ *
153
+ * A fat solid arrow with a flat grey fill and a thick dark border: as close as
154
+ * a native polygon gets to the reference's solid arrow, because this canvas has
155
+ * no gradient fill. The two are the same drawing mirrored — an accelerator
156
+ * points RIGHT, towards the commodity end of the evolution axis, a decelerator
157
+ * points LEFT, back towards genesis — and that direction IS the notation, which
158
+ * is why the outlines are written out below rather than expressed as one shape
159
+ * plus a `rotate`: a rotated element is one the selection, the resize handles
160
+ * and every bounding-box reader then have to de-rotate.
161
+ *
162
+ * Neither is a link in the value chain, so neither carries a component role
163
+ * (see `roles.ts`): they say something about the CLIMATE the map is drawn in.
164
+ */
165
+ export declare const ACCELERATOR_SIZE: {
166
+ w: number;
167
+ h: number;
168
+ };
169
+ /** Flat grey: the arrow is a annotation, and must not out-shout the chain. */
170
+ export declare const ACCELERATOR_FILL = "#bfbfbf";
171
+ /** A thick dark border — `NODE_STROKE`, like every other artefact's rim. */
172
+ export declare const ACCELERATOR_STROKE_WIDTH = 2;
173
+ /**
174
+ * The accelerator's outline, normalized to its box, pointing RIGHT.
175
+ *
176
+ * Seven points: the shaft's two left corners, the head's upper barb, the tip,
177
+ * the head's lower barb, and back along the shaft. `0.55` is where the head
178
+ * starts, `0.28 / 0.72` the shaft's own edges.
179
+ */
180
+ export declare const ACCELERATOR_VERTICES: readonly (readonly [number, number])[];
181
+ /** The decelerator: the same outline mirrored `x → 1 − x`, pointing LEFT. */
182
+ export declare const DECELERATOR_VERTICES: readonly (readonly [number, number])[];
183
+ export declare const ACCELERATOR_LABEL = "Accelerator";
184
+ export declare const DECELERATOR_LABEL = "Decelerator";
185
+ /**
186
+ * Areas — the zones of the map.
187
+ *
188
+ * A ring you draw AROUND components to say "all of this is one thing": a
189
+ * business unit, a team's territory, a scope under discussion. It is not a link
190
+ * in the value chain and not an annotation of the climate either, which is why
191
+ * it carries a role with no parent and sits in a category of its own in the
192
+ * toolbox.
193
+ *
194
+ * Two shapes, one kind: a RECT for the common case and a POLYGON for a zone
195
+ * whose outline has to follow the components it groups (its corners are then
196
+ * moved from the shape toolbar's vertex editor). The `shapeType` is what says
197
+ * which, so nothing about an area is a second kind to keep in step.
198
+ *
199
+ * A zone is drawn OVER the map it groups, so the fill is the load-bearing
200
+ * number here: Peace light at ~25 % opacity, which is a wash the components
201
+ * underneath still read through. The border is the saturated Peace of the same
202
+ * swatch, thin like every other artefact's rim — the zone must frame the map,
203
+ * never compete with it.
204
+ */
205
+ export declare const AREA_RECT_SIZE: {
206
+ w: number;
207
+ h: number;
208
+ };
209
+ /** The polygon is born square: its default outline is a regular pentagon. */
210
+ export declare const AREA_POLYGON_SIZE: {
211
+ w: number;
212
+ h: number;
213
+ };
214
+ /** Peace light at ~25 % opacity — an 8-digit hex, exactly as `PIPELINE_FILL`. */
215
+ export declare const AREA_FILL = "#c6dbfc40";
216
+ /** Peace, the saturated shade of the same swatch. */
217
+ export declare const AREA_STROKE = "#5b9cf6";
218
+ export declare const AREA_STROKE_WIDTH = 1;
219
+ /**
220
+ * The word the legend describes an area by.
221
+ *
222
+ * NOT written into the document: an area is created NAMELESS, so the editor
223
+ * that a double-click opens starts on an empty line and the first thing typed
224
+ * is the zone's own name. The other artefacts carry a placeholder because their
225
+ * name is a text element beside them and an empty one would be invisible; an
226
+ * area's name is its own inner text, and a placeholder there would be a word
227
+ * the author has to delete before writing theirs.
228
+ */
229
+ export declare const AREA_LABEL = "Area";