@formicoidea/labre-framework-wardley 0.40.0 → 0.42.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/legend.js CHANGED
@@ -1,58 +1,412 @@
1
- import { createGroupCommand } from '@formicoidea/labre-core/gfx/group';
2
- import { ConnectorElementModel, ConnectorMode, FontFamily, PointStyle, ShapeElementModel, ShapeStyle, StrokeStyle, WardleyNodeElementModel, } from '@formicoidea/labre-core/model';
1
+ import { ConnectorMode, FontFamily, ShapeStyle, } from '@formicoidea/labre-core/model';
3
2
  import { NOTATION_NEUTRALS } from '@formicoidea/labre-core/shared/consts';
3
+ import { translateKey, } from '@formicoidea/labre-core/shared/services';
4
4
  import { Bound } from '@formicoidea/labre-core/global/gfx';
5
- import { GfxControllerIdentifier } from '@formicoidea/labre-core/std/gfx';
6
5
  import { GRADIENT_GREEN, GRADIENT_RED } from './gradient.js';
7
- import { ACCELERATOR_FILL, ACCELERATOR_STROKE_WIDTH, ACCELERATOR_VERTICES, AREA_FILL, AREA_STROKE, AREA_STROKE_WIDTH, DECELERATOR_VERTICES, INERTIA_COLOR, LINK_GREY, LINK_STROKE_WIDTH, MARKET_DOT_STROKE_WIDTH, MARKET_LINK_COLOR, MARKET_LINK_WIDTH, METHOD_FILL, NODE_FILL, NODE_STROKE, NODE_STROKE_WIDTH, PIPELINE_FILL, PORTER_DEFAULT_LETTER, WARDLEY_RED, } from './node/consts.js';
8
- import { wardleyPorterArrowProps, wardleyPorterArrows } from './presets.js';
9
- const LEGEND_ORDER = [
10
- 'component',
11
- 'anchor',
12
- 'market',
13
- 'ecosystem',
14
- 'method',
15
- 'pipeline',
16
- 'link',
17
- 'arrow',
18
- 'inertia',
19
- 'porter',
20
- 'accelerator',
21
- 'decelerator',
22
- 'area',
6
+ import { NODE_FILL, NODE_STROKE, PIPELINE_FILL, PORTER_DEFAULT_LETTER, } from './node/consts.js';
7
+ import { WARDLEY_EDGE_STYLE, wardleyHandleProps, wardleyInertiaProps, wardleyMarketDotProps, wardleyMarketLinkPairs, wardleyMarketLinkProps, wardleyNodeProps, wardleyPorterArrowProps, wardleyPorterArrows, } from './presets.js';
8
+ import { WARDLEY_ROLE } from './roles.js';
9
+ /**
10
+ * What a Wardley legend ROW is, and the two blocks that are not rows.
11
+ *
12
+ * The scan, the box and the placement all live in the platform
13
+ * (`@labre/affine-block-surface`'s `legend.ts`): a row is SUBSCRIBED by the
14
+ * command that draws the artefact (`CommandDescriptor.legend`, see
15
+ * `commands.ts`), so the notation has one description instead of a table kept
16
+ * in step with the palette by hand. What is left here is what only Wardley can
17
+ * say: the swatch each role is pictured by, the prose each row reads, and the
18
+ * gradient and Porter panels, which document the SHEET rather than anything
19
+ * drawn on it and therefore hang off the board's own `legendBox`.
20
+ */
21
+ /* ── The box's own numbers ────────────────────────────────────────────── */
22
+ /**
23
+ * The legend box's width, and the inset the extras are drawn at.
24
+ *
25
+ * Wardley's, and the reason the box declares a `legendBox` at all: the rows
26
+ * explain the notation in a sentence, so a 260-unit default box would wrap
27
+ * every one of them. `BOX_PAD` mirrors the platform's own padding — the extras
28
+ * are drawn with the full width in hand and have to line their content up with
29
+ * the rows above them.
30
+ */
31
+ const LEGEND_WIDTH = 450;
32
+ const BOX_PAD = 16;
33
+ /** The swatch column, wide enough for the market and the porter composites. */
34
+ const SWATCH_W = 46;
35
+ const SWATCH_H = 30;
36
+ /** Gap between the swatch column and the text the extras write beside it. */
37
+ const EXTRA_GAP = 12;
38
+ /** The text column the extras' captions are laid out in. */
39
+ const EXTRA_TEXT_W = LEGEND_WIDTH - BOX_PAD * 2 - SWATCH_W - EXTRA_GAP;
40
+ const EXTRA_TEXT_FS = 15;
41
+ /** The legend box's own title. */
42
+ export const WARDLEY_LEGEND_TITLE = [
43
+ 'com.labre.wardley.legend.title',
44
+ 'Legend',
23
45
  ];
24
- /** Default (editable) descriptions for each legend row. */
46
+ /**
47
+ * A free text line, as the extras write them: the artefact ink, the notation
48
+ * face, and NO role — a legend documents the map and is not part of it.
49
+ */
50
+ function addText(surface, std, wording, x, y, w, h, fontSize, textAlign = 'left') {
51
+ return surface.addElement({
52
+ type: 'text',
53
+ text: typeof wording === 'string' ? wording : translateKey(std, ...wording),
54
+ fontFamily: FontFamily.Inter,
55
+ fontSize,
56
+ color: NODE_STROKE,
57
+ textAlign,
58
+ xywh: new Bound(x, y, w, h).serialize(),
59
+ });
60
+ }
61
+ /* ── The thirteen rows ────────────────────────────────────────────────── */
62
+ /**
63
+ * A preset's props with its ROLE taken off, and nothing else changed.
64
+ *
65
+ * Every element of this box goes through here. A legend is drawn ON the map it
66
+ * describes and the scan detects by role, so a swatch carrying one would list
67
+ * itself the next time a legend was generated and would be counted by every
68
+ * validation rule. The platform strips a `glyph` row's; a `custom` swatch draws
69
+ * straight onto the surface, so it strips its own.
70
+ */
71
+ function neutral(props) {
72
+ const { role: _role, ...rest } = props;
73
+ return rest;
74
+ }
75
+ const NO_BOX = { xywh: '[0,0,0,0]' };
76
+ const boxAt = (x, y, w, h) => new Bound(x, y, w, h).serialize();
77
+ /**
78
+ * The props of a real artefact, at swatch size — geometry and role removed.
79
+ *
80
+ * Derived from {@link wardleyNodeProps} rather than restated, which is the
81
+ * whole point of subscribing: restyling a kind restyles its legend row, and the
82
+ * two can no longer disagree. `xywh` is the layout's to fill in.
83
+ */
84
+ function glyphProps(kind) {
85
+ const { xywh: _xywh, ...props } = neutral(wardleyNodeProps(kind, NO_BOX));
86
+ return props;
87
+ }
88
+ /** One real artefact, drawn at an explicit box — the composites' building block. */
89
+ function addGlyph(surface, kind, x, y, w, h) {
90
+ return surface.addElement({ ...glyphProps(kind), xywh: boxAt(x, y, w, h) });
91
+ }
92
+ /** A box centred on a point, as every composite below places its pieces. */
93
+ const centred = (cx, cy, w, h) => boxAt(cx - w / 2, cy - h / 2, w, h);
94
+ /**
95
+ * A node row at an EXPLICIT size, which short-circuits the platform's
96
+ * fit-to-box.
97
+ *
98
+ * The gradation is notation here rather than decoration — a component is 16
99
+ * across, a method 18, an ecosystem 20 and a market 22 — and a legend that
100
+ * fitted every pastille to the same box would say they were the same thing.
101
+ */
102
+ function glyphRow(kind, size) {
103
+ return {
104
+ swatch: 'glyph',
105
+ color: String(glyphProps(kind)['fillColor'] ?? NODE_FILL),
106
+ props: glyphProps(kind),
107
+ size,
108
+ };
109
+ }
110
+ /**
111
+ * The connector a Wardley tool arms, as a swatch — the very style it arms, at
112
+ * the very endpoints this legend has always drawn it between.
113
+ *
114
+ * `custom` rather than the platform's `edge`, for one reason: that swatch spans
115
+ * the column horizontally, and a dependency has been drawn RISING across its
116
+ * row since the legend shipped — the slope is what tells it apart from the
117
+ * evolution arrow beside it at a glance, before the eye reaches the colour.
118
+ * Six lines to keep the two samples identical, and the style itself is still
119
+ * {@link WARDLEY_EDGE_STYLE}, so the row pictures the line the tool draws.
120
+ */
121
+ function edgeRow(kind, from, to) {
122
+ return {
123
+ swatch: 'custom',
124
+ color: WARDLEY_EDGE_STYLE[kind].stroke,
125
+ draw: (surface, box) => {
126
+ const cx = box.x + box.w / 2;
127
+ const cy = box.y + box.h / 2;
128
+ return [
129
+ surface.addElement({
130
+ type: 'connector',
131
+ mode: ConnectorMode.Straight,
132
+ source: { position: [cx + from[0], cy + from[1]] },
133
+ target: { position: [cx + to[0], cy + to[1]] },
134
+ ...WARDLEY_EDGE_STYLE[kind],
135
+ }),
136
+ ];
137
+ },
138
+ };
139
+ }
140
+ /**
141
+ * The inertia bar: a plain filled rect, and the whole of its semantics is the
142
+ * role — so the swatch is the preset's own props at the bar's own proportions.
143
+ */
144
+ function inertiaRow() {
145
+ const { xywh: _xywh, ...props } = neutral(wardleyInertiaProps(NO_BOX));
146
+ return {
147
+ swatch: 'glyph',
148
+ color: String(props['fillColor']),
149
+ props,
150
+ size: [5, 22],
151
+ };
152
+ }
153
+ /**
154
+ * The market: a circle, three neutral dots and the triangle wiring them.
155
+ *
156
+ * A `custom` swatch and not a `glyph`, for the reason the pipeline and the
157
+ * porter below give: the drawing is SEVERAL elements, and the three connectors
158
+ * are anchored by id so the triangle follows its dots.
159
+ */
160
+ function drawMarket(surface, box) {
161
+ const cx = box.x + box.w / 2;
162
+ const cy = box.y + box.h / 2;
163
+ // The row's own scale: a 22-unit circle carrying three 6-unit dots on a
164
+ // 12-unit ring. Smaller than the map's 30, and a ratio of it rather than a
165
+ // reduction of the canvas geometry, because the dots must stay readable.
166
+ const R = 11;
167
+ const dr = 3;
168
+ const rho = 6;
169
+ const sin60 = Math.sqrt(3) / 2;
170
+ const circle = addGlyph(surface, 'market', cx - R, cy - R, R * 2, R * 2);
171
+ const dots = [
172
+ [0, -rho],
173
+ [rho * sin60, rho / 2],
174
+ [-rho * sin60, rho / 2],
175
+ ].map(([vx, vy]) => surface.addElement(neutral(wardleyMarketDotProps({
176
+ xywh: centred(cx + vx, cy + vy, dr * 2, dr * 2),
177
+ }))));
178
+ const links = wardleyMarketLinkPairs(dots).map(([a, b]) => surface.addElement(neutral(wardleyMarketLinkProps(a, b))));
179
+ return [circle, ...dots, ...links];
180
+ }
181
+ /** The pipeline: the body, and the handle astride its top edge. */
182
+ function drawPipeline(surface, box) {
183
+ const cx = box.x + box.w / 2;
184
+ const cy = box.y + box.h / 2;
185
+ const bw = 34;
186
+ const bh = 12;
187
+ const hd = 10;
188
+ const top = cy - bh / 2;
189
+ return [
190
+ addGlyph(surface, 'pipeline', cx - bw / 2, top, bw, bh),
191
+ surface.addElement(neutral(wardleyHandleProps({ xywh: centred(cx, top, hd, hd) }))),
192
+ ];
193
+ }
194
+ /**
195
+ * The Porter rose at the row's scale: the circle, the four DERIVED arrows and
196
+ * the notation letter.
197
+ *
198
+ * Radius 6 and not the 8 the other circles get, because this glyph is the only
199
+ * one wider than its own circle: arrows included it spans
200
+ * `2 * (R + (gap + length) * R / 30)`, which at 8 would overrun a 30-unit row.
201
+ * It is a RATIO, so the PO's doubling of the map glyph left this row where it
202
+ * was.
203
+ *
204
+ * The letter is a SEPARATE text and not the circle's inner text (recette v2): a
205
+ * shape lays its text out inside a padding larger than this 12-unit box, so at
206
+ * font size 8 the character was pushed out under the circle.
207
+ */
208
+ function drawPorter(surface, box, std) {
209
+ const cx = box.x + box.w / 2;
210
+ const cy = box.y + box.h / 2;
211
+ const R = 6;
212
+ const FS = 8;
213
+ const circle = addGlyph(surface, 'porter', cx - R, cy - R, R * 2, R * 2);
214
+ const arrows = wardleyPorterArrows(cx, cy, R).map(arrow => surface.addElement(neutral(wardleyPorterArrowProps(arrow))));
215
+ const letter = addText(surface, std, PORTER_DEFAULT_LETTER, cx - R, cy - FS / 2 - 1, R * 2, FS + 2, FS, 'center');
216
+ return [circle, ...arrows, letter];
217
+ }
218
+ /**
219
+ * The swatch each of the thirteen rows is pictured by, keyed by the ROLE it
220
+ * stands for — eight real artefacts at their notation size, and five samples
221
+ * the framework draws itself: the two typed connectors, at the endpoints that
222
+ * tell them apart, and the three composites that are several elements.
223
+ */
224
+ export const WARDLEY_LEGEND_ROWS = {
225
+ [WARDLEY_ROLE.component]: glyphRow('component', [16, 16]),
226
+ [WARDLEY_ROLE.anchor]: glyphRow('anchor', [16, 16]),
227
+ [WARDLEY_ROLE.market]: {
228
+ swatch: 'custom',
229
+ color: NODE_FILL,
230
+ draw: drawMarket,
231
+ },
232
+ [WARDLEY_ROLE.ecosystem]: glyphRow('ecosystem', [20, 20]),
233
+ [WARDLEY_ROLE.method]: glyphRow('method', [18, 18]),
234
+ [WARDLEY_ROLE.pipeline]: {
235
+ swatch: 'custom',
236
+ color: PIPELINE_FILL,
237
+ draw: drawPipeline,
238
+ },
239
+ // The dependency RISES across its row, the evolution arrow runs flat: the
240
+ // two endpoint pairs the legend has drawn since it shipped.
241
+ [WARDLEY_ROLE.dependency]: edgeRow('link', [-18, 6], [18, -6]),
242
+ [WARDLEY_ROLE.changeArrow]: edgeRow('arrow', [-18, 0], [16, 0]),
243
+ [WARDLEY_ROLE.inertia]: inertiaRow(),
244
+ [WARDLEY_ROLE.porter]: {
245
+ swatch: 'custom',
246
+ color: NODE_FILL,
247
+ draw: drawPorter,
248
+ },
249
+ [WARDLEY_ROLE.accelerator]: glyphRow('accelerator', [30, 18]),
250
+ [WARDLEY_ROLE.decelerator]: glyphRow('decelerator', [30, 18]),
251
+ // The rect even for a map full of polygons: the row says what a ZONE is, and
252
+ // the number of corners is the author's choice rather than notation.
253
+ [WARDLEY_ROLE.area]: glyphRow('area', [34, 20]),
254
+ };
255
+ /**
256
+ * What each row SAYS — prose rather than the role's bare name.
257
+ *
258
+ * The role vocabulary calls `wardley:component` a "Component"; the legend has
259
+ * to say what one IS to somebody reading their first map. So each row declares
260
+ * its own wording, which the platform reads ahead of the role's, and the
261
+ * thirteen keys the host catalogue already carries are unchanged.
262
+ */
25
263
  const LEGEND_DESC = {
26
- component: 'Need / capability (activity, practice, data…)',
27
- anchor: 'Stakeholder (customer, user…)',
28
- market: 'Market (set of actors)',
29
- ecosystem: 'Ecosystem',
30
- method: 'Component + method (color = phase)',
31
- pipeline: 'Pipeline (possible choices for a capability)',
32
- link: 'Need relation (parent → child)',
33
- arrow: 'Evolution / movement (red = future)',
34
- inertia: 'Inertia to change',
35
- porter: "Porter's forces (external competition: R relative, L survival, E establish)",
36
- accelerator: 'Accelerator (speeds evolution up)',
37
- decelerator: 'Decelerator (slows evolution down)',
38
- area: 'Area (zone of the map)',
264
+ [WARDLEY_ROLE.component]: [
265
+ 'com.labre.wardley.legend.desc.component',
266
+ 'Need / capability (activity, practice, data…)',
267
+ ],
268
+ [WARDLEY_ROLE.anchor]: [
269
+ 'com.labre.wardley.legend.desc.anchor',
270
+ 'Stakeholder (customer, user…)',
271
+ ],
272
+ [WARDLEY_ROLE.market]: [
273
+ 'com.labre.wardley.legend.desc.market',
274
+ 'Market (set of actors)',
275
+ ],
276
+ [WARDLEY_ROLE.ecosystem]: [
277
+ 'com.labre.wardley.legend.desc.ecosystem',
278
+ 'Ecosystem',
279
+ ],
280
+ [WARDLEY_ROLE.method]: [
281
+ 'com.labre.wardley.legend.desc.method',
282
+ 'Component + method (color = phase)',
283
+ ],
284
+ [WARDLEY_ROLE.pipeline]: [
285
+ 'com.labre.wardley.legend.desc.pipeline',
286
+ 'Pipeline (possible choices for a capability)',
287
+ ],
288
+ [WARDLEY_ROLE.dependency]: [
289
+ 'com.labre.wardley.legend.desc.link',
290
+ 'Need relation (parent → child)',
291
+ ],
292
+ [WARDLEY_ROLE.changeArrow]: [
293
+ 'com.labre.wardley.legend.desc.arrow',
294
+ 'Evolution / movement (red = future)',
295
+ ],
296
+ [WARDLEY_ROLE.inertia]: [
297
+ 'com.labre.wardley.legend.desc.inertia',
298
+ 'Inertia to change',
299
+ ],
300
+ [WARDLEY_ROLE.porter]: [
301
+ 'com.labre.wardley.legend.desc.porter',
302
+ "Porter's forces (external competition: R relative, L survival, E establish)",
303
+ ],
304
+ [WARDLEY_ROLE.accelerator]: [
305
+ 'com.labre.wardley.legend.desc.accelerator',
306
+ 'Accelerator (speeds evolution up)',
307
+ ],
308
+ [WARDLEY_ROLE.decelerator]: [
309
+ 'com.labre.wardley.legend.desc.decelerator',
310
+ 'Decelerator (slows evolution down)',
311
+ ],
312
+ [WARDLEY_ROLE.area]: [
313
+ 'com.labre.wardley.legend.desc.area',
314
+ 'Area (zone of the map)',
315
+ ],
39
316
  };
317
+ /** Every {@link LEGEND_DESC} wording, for `translations.ts`'s manifest. */
318
+ export const WARDLEY_LEGEND_DESC_WORDINGS = Object.values(LEGEND_DESC);
319
+ /**
320
+ * The legend line one command subscribes — the swatch of the role it stamps,
321
+ * and the sentence that explains it. Its sub-title comes from the command's own
322
+ * catalogue category (Nodes, Connectors, Areas), so a section is declared by
323
+ * filing a command rather than by a second list.
324
+ */
325
+ export function wardleyLegendEntry(role) {
326
+ return {
327
+ role,
328
+ row: WARDLEY_LEGEND_ROWS[role],
329
+ labelWording: LEGEND_DESC[role],
330
+ };
331
+ }
40
332
  /** Gradient-meaning block, keyed by variant (caption + 2-colour swatch). */
41
333
  const LEGEND_GRADIENT = {
42
334
  opportunity: {
43
- caption: 'Opportunity gradient: differential value (green) vs operational value (red).',
335
+ caption: [
336
+ 'com.labre.wardley.legend.gradient.opportunity',
337
+ 'Opportunity gradient: differential value (green) vs operational value (red).',
338
+ ],
44
339
  swatch: [GRADIENT_GREEN, GRADIENT_RED],
45
340
  },
46
341
  benefit: {
47
- caption: 'Gradient: investment (red) then benefit (green).',
342
+ caption: [
343
+ 'com.labre.wardley.legend.gradient.benefit',
344
+ 'Gradient: investment (red) then benefit (green).',
345
+ ],
48
346
  swatch: [GRADIENT_RED, GRADIENT_GREEN],
49
347
  },
50
348
  'evolution-gradient': {
51
- caption: "Gradient representing the growth of Wardley's evolution function.",
349
+ caption: [
350
+ 'com.labre.wardley.legend.gradient.evolution',
351
+ "Gradient representing the growth of Wardley's evolution function.",
352
+ ],
52
353
  swatch: [NOTATION_NEUTRALS.divider, NOTATION_NEUTRALS.legendBorder],
53
354
  },
54
355
  };
55
- /* ── Porter's five forces: the panel under the rows ───────────────────── */
356
+ /** Every {@link LEGEND_GRADIENT} caption, for `translations.ts`'s manifest. */
357
+ export const WARDLEY_LEGEND_GRADIENT_WORDINGS = Object.values(LEGEND_GRADIENT).map(g => g.caption);
358
+ /** The gradient row's own height — taller than a row, as its caption may wrap. */
359
+ const GRAD_ROW_H = 40;
360
+ /**
361
+ * The gradient block: a separator, then [2-colour swatch | caption].
362
+ *
363
+ * An EXTRA and not a row, because it says nothing about anything drawn on the
364
+ * map: it is a property of the SHEET, read off the background's variant, so no
365
+ * command could subscribe it.
366
+ */
367
+ function gradientExtra(variant) {
368
+ const grad = LEGEND_GRADIENT[variant];
369
+ return {
370
+ height: 12 + GRAD_ROW_H,
371
+ draw(surface, std, x, y, width) {
372
+ const ids = [];
373
+ const sepY = y + 4;
374
+ ids.push(surface.addElement({
375
+ type: 'shape',
376
+ shapeType: 'rect',
377
+ filled: true,
378
+ fillColor: NOTATION_NEUTRALS.legendBorder,
379
+ strokeColor: NOTATION_NEUTRALS.legendBorder,
380
+ strokeWidth: 0,
381
+ shapeStyle: ShapeStyle.General,
382
+ roughness: 0,
383
+ radius: 0,
384
+ xywh: new Bound(x + BOX_PAD, sepY, width - BOX_PAD * 2, 1).serialize(),
385
+ }));
386
+ const cy = sepY + 8 + GRAD_ROW_H / 2;
387
+ const sw = 14;
388
+ const sgap = 2;
389
+ const sx = x + BOX_PAD + SWATCH_W / 2 - (sw * 2 + sgap) / 2;
390
+ grad.swatch.forEach((col, i) => {
391
+ ids.push(surface.addElement({
392
+ type: 'shape',
393
+ shapeType: 'rect',
394
+ filled: true,
395
+ fillColor: col,
396
+ strokeColor: NOTATION_NEUTRALS.legendBorder,
397
+ strokeWidth: 0.5,
398
+ shapeStyle: ShapeStyle.General,
399
+ roughness: 0,
400
+ radius: 1,
401
+ xywh: new Bound(sx + i * (sw + sgap), cy - sw / 2, sw, sw).serialize(),
402
+ }));
403
+ });
404
+ ids.push(addText(surface, std, grad.caption, x + BOX_PAD + SWATCH_W + EXTRA_GAP, cy - GRAD_ROW_H / 2, EXTRA_TEXT_W, GRAD_ROW_H, EXTRA_TEXT_FS));
405
+ return ids;
406
+ },
407
+ };
408
+ }
409
+ /* ── Extra 2: Porter's five forces ────────────────────────────────────── */
56
410
  /** The panel's own numbers. Model units, like every other measure here. */
57
411
  const PORTER_PANEL = {
58
412
  pad: 12,
@@ -79,15 +433,41 @@ const PORTER_PANEL = {
79
433
  };
80
434
  /** The four forces the boxes name, north first and then clockwise. */
81
435
  const PORTER_FORCES = [
82
- 'Threat of new entrants',
83
- 'Bargaining power of customers',
84
- 'Threat of substitutes',
85
- 'Bargaining power of suppliers',
436
+ [
437
+ 'com.labre.wardley.legend.porter.force.new-entrants',
438
+ 'Threat of new entrants',
439
+ ],
440
+ [
441
+ 'com.labre.wardley.legend.porter.force.customers',
442
+ 'Bargaining power of customers',
443
+ ],
444
+ [
445
+ 'com.labre.wardley.legend.porter.force.substitutes',
446
+ 'Threat of substitutes',
447
+ ],
448
+ [
449
+ 'com.labre.wardley.legend.porter.force.suppliers',
450
+ 'Bargaining power of suppliers',
451
+ ],
86
452
  ];
87
453
  /** What the panel's own glyph reads: the notation, not one force. */
88
454
  const PORTER_PANEL_LETTERS = 'R/L/E';
89
455
  /** What the letters mean, spelled out under the diagram. */
90
- const PORTER_CAPTION = 'R/L/E = Relative competition, or struggLe for survival, or struggle to Establish';
456
+ const PORTER_CAPTION = [
457
+ 'com.labre.wardley.legend.porter.caption',
458
+ 'R/L/E = Relative competition, or struggLe for survival, or struggle to Establish',
459
+ ];
460
+ /** The five-forces panel's own title. */
461
+ export const WARDLEY_LEGEND_PORTER_TITLE = [
462
+ 'com.labre.wardley.legend.porter.title',
463
+ "Porter's five forces",
464
+ ];
465
+ /** Every {@link PORTER_FORCES} wording, plus the caption, for the manifest. */
466
+ export const WARDLEY_LEGEND_PORTER_WORDINGS = [
467
+ WARDLEY_LEGEND_PORTER_TITLE,
468
+ ...PORTER_FORCES,
469
+ PORTER_CAPTION,
470
+ ];
91
471
  /**
92
472
  * How far a porter glyph of the given radius reaches, arrow tips included.
93
473
  *
@@ -145,406 +525,24 @@ export function porterPanelLayout(w) {
145
525
  caption: [p.pad, captionY, inner, p.captionH],
146
526
  };
147
527
  }
528
+ /** The panel, at the only width this box is ever drawn at. */
529
+ const PORTER_PANEL_LAYOUT = porterPanelLayout(LEGEND_WIDTH - BOX_PAD * 2);
148
530
  /**
149
- * Build a "Legend" group from real, editable elements (white rect frame +
150
- * "Legend" text + one row of [real component glyph + description text] per
151
- * Wardley component TYPE present inside the background's perimeter + a
152
- * gradient-meaning block when the background is a gradient variant + the
153
- * five-forces panel when a Porter's-forces glyph is on the map). A snapshot
154
- * is created on each call; everything is grouped so it can be moved / resized /
155
- * edited and is dropped bottom-left of the background.
531
+ * The five-forces panel, under everything else.
532
+ *
533
+ * It is not a row — a row says what a glyph IS, and this says what the notation
534
+ * MEANS: four named pressures around one circle, and the three letters spelled
535
+ * out. Only present when a force is on the map, so a legend without one is the
536
+ * legend it always was.
156
537
  */
157
- export function createWardleyLegend(std, bg) {
158
- const gfx = std.get(GfxControllerIdentifier);
159
- const surface = gfx.surface;
160
- if (!surface)
161
- return;
162
- const [bx, by, , bh] = bg.deserializedXYWH;
163
- // 1. Detect which component types are present inside the perimeter.
164
- const present = new Set();
165
- for (const el of gfx.getElementsByBound(Bound.deserialize(bg.xywh), {
166
- type: 'canvas',
167
- })) {
168
- // Note: WardleyNodeElementModel extends ShapeElementModel, so the order of
169
- // these instanceof checks matters.
170
- if (el instanceof WardleyNodeElementModel) {
171
- if (el.kind !== 'handle')
172
- present.add(el.kind);
173
- }
174
- else if (el instanceof ConnectorElementModel) {
175
- if (el.strokeStyle === StrokeStyle.Dash || el.stroke === WARDLEY_RED) {
176
- present.add('arrow');
177
- }
178
- else if (el.stroke === LINK_GREY) {
179
- present.add('link');
180
- }
181
- // market triangle connectors (NODE_STROKE) are ignored.
182
- }
183
- else if (el instanceof ShapeElementModel) {
184
- // The inertia bar is the only plain shape this legend describes, and it
185
- // is recognised by its FILL. A Porter's-forces arrow is a plain shape too
186
- // — a filled red polygon, the glyph's own wiring — and it is `WARDLEY_RED`
187
- // rather than `INERTIA_COLOR`, so it falls through here and is described
188
- // by the porter circle it belongs to. Asserted in `porter.unit.spec.ts`:
189
- // an arrow that started answering this test would put an "Inertia to
190
- // change" row in the legend of a map with no inertia bar on it.
191
- if (el.fillColor === INERTIA_COLOR)
192
- present.add('inertia');
193
- }
194
- }
195
- const rows = LEGEND_ORDER.filter(t => present.has(t));
196
- // 2. Layout (model units). The text column is wide enough for one-line
197
- // descriptions; the gradient row is taller as its caption may wrap.
198
- const PAD = 16;
199
- const TITLE_H = 28;
200
- const ROW_H = 30;
201
- const GLYPH_W = 46;
202
- const GAP = 12;
203
- const TEXT_FS = 15;
204
- const TITLE_FS = 18;
205
- const TEXT_W = 360;
206
- const GRAD_ROW_H = 40;
207
- const W = PAD * 2 + GLYPH_W + GAP + TEXT_W;
208
- const variant = bg.variant;
209
- const grad = variant !== 'classic' ? LEGEND_GRADIENT[variant] : null;
210
- const gradH = grad ? 12 + GRAD_ROW_H : 0;
211
- // The five-forces panel, and ONLY when a force is actually on the map: a
212
- // legend of a map with no porter on it has to come out byte-identical to the
213
- // one it came out as before this panel existed. Pinned in
214
- // `porter.unit.spec.ts`.
215
- const panel = present.has('porter') ? porterPanelLayout(W - PAD * 2) : null;
216
- const panelH = panel ? 12 + panel.h : 0;
217
- const H = PAD * 2 + TITLE_H + rows.length * ROW_H + gradH + panelH;
218
- const x0 = bx + 50;
219
- const y0 = by + bh - 56 - H;
220
- const text = (t, x, y, w, h, fontSize, align = 'left') => surface.addElement({
221
- type: 'text',
222
- text: t,
223
- fontFamily: FontFamily.Inter,
224
- fontSize,
225
- color: NODE_STROKE,
226
- textAlign: align,
227
- xywh: new Bound(x, y, w, h).serialize(),
228
- });
229
- // ── glyph builders (real, editable elements), centred on (cx, cy) ─────
230
- //
231
- // DELIBERATELY ROLE-LESS. These are real `wardleyNode` elements, but a
232
- // legend documents the map — it is not part of it. Giving its glyphs
233
- // `wardley:component` & co. would make every legend entry count as an
234
- // artefact and skew any rule written against roles (a legend would add a
235
- // phantom component, anchor, market…). Neutral is the semantics we want;
236
- // `kind` still drives their rendering. Frozen by a test in
237
- // `__tests__/roles.unit.spec.ts`.
238
- const ellipse = (kind, d, fill, sw, cx, cy) => surface.addElement({
239
- type: 'wardleyNode',
240
- kind,
241
- shapeType: 'ellipse',
242
- filled: true,
243
- fillColor: fill,
244
- strokeColor: NODE_STROKE,
245
- strokeWidth: sw,
246
- shapeStyle: ShapeStyle.General,
247
- roughness: 0,
248
- xywh: new Bound(cx - d / 2, cy - d / 2, d, d).serialize(),
249
- });
250
- const glyph = (type, cx, cy) => {
251
- switch (type) {
252
- case 'component':
253
- return [ellipse('component', 16, NODE_FILL, NODE_STROKE_WIDTH, cx, cy)];
254
- case 'anchor':
255
- return [ellipse('anchor', 16, NODE_FILL, NODE_STROKE_WIDTH, cx, cy)];
256
- case 'ecosystem':
257
- return [ellipse('ecosystem', 20, NODE_FILL, NODE_STROKE_WIDTH, cx, cy)];
258
- case 'method':
259
- return [ellipse('method', 18, METHOD_FILL, NODE_STROKE_WIDTH, cx, cy)];
260
- case 'inertia':
261
- return [
262
- surface.addElement({
263
- type: 'shape',
264
- shapeType: 'rect',
265
- filled: true,
266
- fillColor: INERTIA_COLOR,
267
- strokeColor: INERTIA_COLOR,
268
- strokeWidth: 0,
269
- shapeStyle: ShapeStyle.General,
270
- roughness: 0,
271
- radius: 0,
272
- xywh: new Bound(cx - 2.5, cy - 11, 5, 22).serialize(),
273
- }),
274
- ];
275
- case 'pipeline': {
276
- const bw2 = 34;
277
- const bh2 = 12;
278
- const hd = 10;
279
- const top = cy - bh2 / 2;
280
- return [
281
- surface.addElement({
282
- type: 'wardleyNode',
283
- kind: 'pipeline',
284
- shapeType: 'rect',
285
- filled: true,
286
- fillColor: PIPELINE_FILL,
287
- strokeColor: NODE_STROKE,
288
- strokeWidth: NODE_STROKE_WIDTH,
289
- shapeStyle: ShapeStyle.General,
290
- roughness: 0,
291
- radius: 0,
292
- xywh: new Bound(cx - bw2 / 2, top, bw2, bh2).serialize(),
293
- }),
294
- surface.addElement({
295
- type: 'wardleyNode',
296
- kind: 'handle',
297
- shapeType: 'rect',
298
- filled: true,
299
- fillColor: NODE_FILL,
300
- strokeColor: NODE_STROKE,
301
- strokeWidth: NODE_STROKE_WIDTH,
302
- shapeStyle: ShapeStyle.General,
303
- roughness: 0,
304
- radius: 0,
305
- xywh: new Bound(cx - hd / 2, top - hd / 2, hd, hd).serialize(),
306
- }),
307
- ];
308
- }
309
- case 'market': {
310
- const R = 11;
311
- const dr = 3;
312
- const rho = 6;
313
- const sin60 = Math.sqrt(3) / 2;
314
- const circle = surface.addElement({
315
- type: 'wardleyNode',
316
- kind: 'market',
317
- shapeType: 'ellipse',
318
- filled: true,
319
- fillColor: NODE_FILL,
320
- strokeColor: NODE_STROKE,
321
- strokeWidth: NODE_STROKE_WIDTH,
322
- shapeStyle: ShapeStyle.General,
323
- roughness: 0,
324
- xywh: new Bound(cx - R, cy - R, R * 2, R * 2).serialize(),
325
- });
326
- const verts = [
327
- [0, -rho],
328
- [rho * sin60, rho / 2],
329
- [-rho * sin60, rho / 2],
330
- ];
331
- const dots = verts.map(([vx, vy]) => surface.addElement({
332
- type: 'wardleyNode',
333
- kind: 'component',
334
- shapeType: 'ellipse',
335
- filled: true,
336
- fillColor: NODE_FILL,
337
- strokeColor: NODE_STROKE,
338
- strokeWidth: MARKET_DOT_STROKE_WIDTH,
339
- shapeStyle: ShapeStyle.General,
340
- roughness: 0,
341
- xywh: new Bound(cx + vx - dr, cy + vy - dr, dr * 2, dr * 2).serialize(),
342
- }));
343
- const conns = [
344
- [dots[0], dots[1]],
345
- [dots[1], dots[2]],
346
- [dots[2], dots[0]],
347
- ].map(([a, b]) => surface.addElement({
348
- type: 'connector',
349
- mode: ConnectorMode.Straight,
350
- source: { id: a },
351
- target: { id: b },
352
- stroke: MARKET_LINK_COLOR,
353
- strokeStyle: StrokeStyle.Solid,
354
- strokeWidth: MARKET_LINK_WIDTH,
355
- frontEndpointStyle: PointStyle.None,
356
- rearEndpointStyle: PointStyle.None,
357
- }));
358
- return [circle, ...dots, ...conns];
359
- }
360
- case 'porter': {
361
- // The same drawing as the map's, at the row's scale: the helper scales
362
- // the gap, the shaft and the head with the radius, so what a reader
363
- // sees here is a small Porter and not a circle with four map-sized
364
- // spikes through it.
365
- //
366
- // 6 and not the 8 the other circles get, because this glyph is the only
367
- // one wider than its own circle: arrows included it spans
368
- // `2 * (R + (gap + length) * R / 30)`, which at 8 would overrun a row
369
- // 30 units tall. At 6 it comes to just over `ROW_H` and sits inside its
370
- // line — and it is a RATIO, so the PO's doubling of the map glyph left
371
- // this row exactly where it was.
372
- const R = 6;
373
- const FS = 8;
374
- const circle = surface.addElement({
375
- type: 'wardleyNode',
376
- kind: 'porter',
377
- shapeType: 'ellipse',
378
- filled: true,
379
- fillColor: NODE_FILL,
380
- strokeColor: NODE_STROKE,
381
- strokeWidth: NODE_STROKE_WIDTH,
382
- shapeStyle: ShapeStyle.General,
383
- roughness: 0,
384
- xywh: new Bound(cx - R, cy - R, R * 2, R * 2).serialize(),
385
- });
386
- const arrows = wardleyPorterArrows(cx, cy, R).map(arrow => surface.addElement(wardleyPorterArrowProps(arrow)));
387
- // The letter is the notation, so a legend that dropped it would be
388
- // describing a circle rather than the glyph it stands for — but here it
389
- // is a SEPARATE text element rather than the circle's inner text, which
390
- // is what the map glyph uses. A shape lays its text out inside padding
391
- // (`SHAPE_TEXT_VERTICAL_PADDING`) larger than this 12-unit box, so at
392
- // font size 8 the character was pushed out under the circle (recette
393
- // v2). A free text has no padding to overflow, and its box is placed on
394
- // the circle's own centre. Role-less like every other legend glyph.
395
- const letter = text(PORTER_DEFAULT_LETTER, cx - R, cy - FS / 2 - 1, R * 2, FS + 2, FS, 'center');
396
- return [circle, ...arrows, letter];
397
- }
398
- case 'accelerator':
399
- case 'decelerator': {
400
- // The map's own outline, at the row's scale: `vertices` are normalized
401
- // to the box, so the SAME seven points draw a 30 × 18 legend arrow and
402
- // a 48 × 40 canvas one. Role-less like every glyph in this box — a
403
- // legend documents the map, it is not part of it.
404
- const gw = 30;
405
- const gh = 18;
406
- return [
407
- surface.addElement({
408
- type: 'wardleyNode',
409
- kind: type,
410
- shapeType: 'polygon',
411
- vertices: (type === 'accelerator'
412
- ? ACCELERATOR_VERTICES
413
- : DECELERATOR_VERTICES).map(([vx, vy]) => [vx, vy]),
414
- isClosed: true,
415
- filled: true,
416
- fillColor: ACCELERATOR_FILL,
417
- strokeColor: NODE_STROKE,
418
- strokeWidth: ACCELERATOR_STROKE_WIDTH,
419
- shapeStyle: ShapeStyle.General,
420
- roughness: 0,
421
- xywh: new Bound(cx - gw / 2, cy - gh / 2, gw, gh).serialize(),
422
- }),
423
- ];
424
- }
425
- case 'area': {
426
- // A small translucent rect, and the rect even when the map's zones are
427
- // polygons: the row says what a ZONE is, and the number of corners is
428
- // the author's choice rather than part of the notation. Same wash and
429
- // same rim as the canvas draws, so the swatch is recognisable.
430
- const gw = 34;
431
- const gh = 20;
432
- return [
433
- surface.addElement({
434
- type: 'wardleyNode',
435
- kind: 'area',
436
- shapeType: 'rect',
437
- filled: true,
438
- fillColor: AREA_FILL,
439
- strokeColor: AREA_STROKE,
440
- strokeWidth: AREA_STROKE_WIDTH,
441
- shapeStyle: ShapeStyle.General,
442
- roughness: 0,
443
- radius: 0,
444
- xywh: new Bound(cx - gw / 2, cy - gh / 2, gw, gh).serialize(),
445
- }),
446
- ];
447
- }
448
- case 'link':
449
- return [
450
- surface.addElement({
451
- type: 'connector',
452
- mode: ConnectorMode.Straight,
453
- source: { position: [cx - 18, cy + 6] },
454
- target: { position: [cx + 18, cy - 6] },
455
- stroke: LINK_GREY,
456
- strokeStyle: StrokeStyle.Solid,
457
- strokeWidth: LINK_STROKE_WIDTH,
458
- frontEndpointStyle: PointStyle.None,
459
- rearEndpointStyle: PointStyle.None,
460
- }),
461
- ];
462
- case 'arrow':
463
- return [
464
- surface.addElement({
465
- type: 'connector',
466
- mode: ConnectorMode.Straight,
467
- source: { position: [cx - 18, cy] },
468
- target: { position: [cx + 16, cy] },
469
- stroke: WARDLEY_RED,
470
- strokeStyle: StrokeStyle.Dash,
471
- strokeWidth: LINK_STROKE_WIDTH,
472
- frontEndpointStyle: PointStyle.None,
473
- rearEndpointStyle: PointStyle.Triangle,
474
- }),
475
- ];
476
- }
477
- };
478
- // 3. Create the elements.
479
- std.store.captureSync();
480
- const ids = [];
481
- // White frame.
482
- ids.push(surface.addElement({
483
- type: 'shape',
484
- shapeType: 'rect',
485
- filled: true,
486
- fillColor: NOTATION_NEUTRALS.cardFill,
487
- strokeColor: NOTATION_NEUTRALS.legendBorder,
488
- strokeWidth: 1,
489
- shapeStyle: ShapeStyle.General,
490
- roughness: 0,
491
- radius: 6,
492
- xywh: new Bound(x0, y0, W, H).serialize(),
493
- }));
494
- // Title.
495
- ids.push(text('Legend', x0 + PAD, y0 + PAD, W - PAD * 2, TITLE_FS + 6, TITLE_FS));
496
- // Rows.
497
- let ry = y0 + PAD + TITLE_H;
498
- for (const t of rows) {
499
- const cyRow = ry + ROW_H / 2;
500
- ids.push(...glyph(t, x0 + PAD + GLYPH_W / 2, cyRow));
501
- ids.push(text(LEGEND_DESC[t], x0 + PAD + GLYPH_W + GAP, cyRow - (TEXT_FS + 8) / 2, TEXT_W, TEXT_FS + 8, TEXT_FS));
502
- ry += ROW_H;
503
- }
504
- // Gradient meaning block: a separator, then [2-colour swatch | caption].
505
- if (grad) {
506
- const sepY = ry + 4;
507
- ids.push(surface.addElement({
508
- type: 'shape',
509
- shapeType: 'rect',
510
- filled: true,
511
- fillColor: NOTATION_NEUTRALS.legendBorder,
512
- strokeColor: NOTATION_NEUTRALS.legendBorder,
513
- strokeWidth: 0,
514
- shapeStyle: ShapeStyle.General,
515
- roughness: 0,
516
- radius: 0,
517
- xywh: new Bound(x0 + PAD, sepY, W - PAD * 2, 1).serialize(),
518
- }));
519
- const cyRow = sepY + 8 + GRAD_ROW_H / 2;
520
- const sw = 14;
521
- const sgap = 2;
522
- const sx = x0 + PAD + GLYPH_W / 2 - (sw * 2 + sgap) / 2;
523
- grad.swatch.forEach((col, i) => {
524
- ids.push(surface.addElement({
525
- type: 'shape',
526
- shapeType: 'rect',
527
- filled: true,
528
- fillColor: col,
529
- strokeColor: NOTATION_NEUTRALS.legendBorder,
530
- strokeWidth: 0.5,
531
- shapeStyle: ShapeStyle.General,
532
- roughness: 0,
533
- radius: 1,
534
- xywh: new Bound(sx + i * (sw + sgap), cyRow - sw / 2, sw, sw).serialize(),
535
- }));
536
- });
537
- ids.push(text(grad.caption, x0 + PAD + GLYPH_W + GAP, cyRow - GRAD_ROW_H / 2, TEXT_W, GRAD_ROW_H, TEXT_FS));
538
- }
539
- // Porter's five forces: the panel the PO's reference draws, under everything
540
- // else. It is not a row — a row says what a glyph IS, and this says what the
541
- // notation MEANS: four named pressures around one circle, and the three
542
- // letters spelled out. Only drawn when a force is on the map, so a legend
543
- // without one is the legend it always was.
544
- if (panel) {
545
- const px = x0 + PAD;
546
- const py = ry + gradH + 12;
538
+ const PORTER_EXTRA = {
539
+ height: 12 + PORTER_PANEL_LAYOUT.h,
540
+ draw(surface, std, x, y) {
541
+ const panel = PORTER_PANEL_LAYOUT;
547
542
  const p = PORTER_PANEL;
543
+ const px = x + BOX_PAD;
544
+ const py = y + 12;
545
+ const ids = [];
548
546
  // The backing: square-cornered and grey, so the panel reads as a figure
549
547
  // set into the legend rather than as one more entry in it.
550
548
  ids.push(surface.addElement({
@@ -560,33 +558,18 @@ export function createWardleyLegend(std, bg) {
560
558
  xywh: new Bound(px, py, panel.w, panel.h).serialize(),
561
559
  }));
562
560
  const [tx, ty, tw, th] = panel.title;
563
- ids.push(text("Porter's five forces", px + tx, py + ty, tw, th, p.titleFs));
561
+ ids.push(addText(surface, std, WARDLEY_LEGEND_PORTER_TITLE, px + tx, py + ty, tw, th, p.titleFs));
564
562
  const [cx, cy] = panel.center;
565
- ids.push(surface.addElement({
566
- type: 'wardleyNode',
567
- kind: 'porter',
568
- shapeType: 'ellipse',
569
- filled: true,
570
- fillColor: NODE_FILL,
571
- strokeColor: NODE_STROKE,
572
- strokeWidth: NODE_STROKE_WIDTH,
573
- shapeStyle: ShapeStyle.General,
574
- roughness: 0,
575
- xywh: new Bound(px + cx - panel.radius, py + cy - panel.radius, panel.radius * 2, panel.radius * 2).serialize(),
576
- }));
563
+ ids.push(addGlyph(surface, 'porter', px + cx - panel.radius, py + cy - panel.radius, panel.radius * 2, panel.radius * 2));
577
564
  for (const arrow of wardleyPorterArrows(px + cx, py + cy, panel.radius)) {
578
- ids.push(surface.addElement(wardleyPorterArrowProps(arrow)));
565
+ ids.push(surface.addElement(neutral(wardleyPorterArrowProps(arrow))));
579
566
  }
580
567
  // All three letters at once: this circle stands for the NOTATION rather
581
568
  // than for one force, so picking one of them would make the panel say that
582
- // a Porter is an R.
583
- //
584
- // A SEPARATE text element, like the porter row above and for the same
585
- // reason (recette v2): a shape lays its text out inside a padding
586
- // (`SHAPE_TEXT_PADDING`) wider than this 30-unit circle, so the characters
587
- // would be pushed outside it. A free text has no padding to overflow, and
588
- // its box is placed on the circle's own centre.
589
- ids.push(text(PORTER_PANEL_LETTERS, px + cx - panel.radius, py + cy - p.letterFs / 2 - 1, panel.radius * 2, p.letterFs + 2, p.letterFs, 'center'));
569
+ // a Porter is an R. A SEPARATE text element, like the row's own letter and
570
+ // for the same reason (recette v2): a shape's text padding is wider than
571
+ // this 30-unit circle.
572
+ ids.push(addText(surface, std, PORTER_PANEL_LETTERS, px + cx - panel.radius, py + cy - p.letterFs / 2 - 1, panel.radius * 2, p.letterFs + 2, p.letterFs, 'center'));
590
573
  for (const box of panel.boxes) {
591
574
  const [bx, by, bw, bh] = box.xywh;
592
575
  ids.push(surface.addElement({
@@ -601,15 +584,46 @@ export function createWardleyLegend(std, bg) {
601
584
  radius: 0,
602
585
  xywh: new Bound(px + bx, py + by, bw, bh).serialize(),
603
586
  }));
604
- ids.push(text(box.label, px + bx + 4, py + by + 3, bw - 8, bh - 6, p.boxFs, 'center'));
587
+ ids.push(addText(surface, std, box.label, px + bx + 4, py + by + 3, bw - 8, bh - 6, p.boxFs, 'center'));
605
588
  }
606
589
  const [capX, capY, capW, capH] = panel.caption;
607
- ids.push(text(PORTER_CAPTION, px + capX, py + capY, capW, capH, p.captionFs, 'center'));
608
- }
609
- // 4. Group everything and select it.
610
- const [, result] = std.command.exec(createGroupCommand, { elements: ids });
611
- gfx.selection.set({
612
- elements: [result.groupId || ids[0]],
613
- editing: false,
614
- });
615
- }
590
+ ids.push(addText(surface, std, PORTER_CAPTION, px + capX, py + capY, capW, capH, p.captionFs, 'center'));
591
+ return ids;
592
+ },
593
+ };
594
+ /* ── The box the board's command declares ─────────────────────────────── */
595
+ /**
596
+ * How a Wardley map's legend box is laid out, and the two blocks it adds under
597
+ * the rows. Declared on the FIRST background command (`commands.ts`), which is
598
+ * the sheet the legend is drawn on.
599
+ */
600
+ export const WARDLEY_LEGEND_BOX = {
601
+ titleWording: WARDLEY_LEGEND_TITLE,
602
+ width: LEGEND_WIDTH,
603
+ rowHeight: SWATCH_H,
604
+ swatchWidth: SWATCH_W,
605
+ swatchHeight: SWATCH_H,
606
+ extras: ({ board, present }) => {
607
+ const extras = [];
608
+ // The variant is a fact about the BOARD, which the platform hands over
609
+ // untyped — it knows a board has a box and nothing else about one.
610
+ const { variant } = board;
611
+ if (variant && variant !== 'classic')
612
+ extras.push(gradientExtra(variant));
613
+ if (present.has(WARDLEY_ROLE.porter))
614
+ extras.push(PORTER_EXTRA);
615
+ return extras;
616
+ },
617
+ };
618
+ /**
619
+ * Every wording this file writes onto the canvas, for `translations.ts`'s
620
+ * manifest contribution — the box's own title, its rows' captions, the gradient
621
+ * blocks and the Porter panel, all as `seed` (written into the document once,
622
+ * at the moment a legend is generated).
623
+ */
624
+ export const WARDLEY_LEGEND_WORDINGS = [
625
+ WARDLEY_LEGEND_TITLE,
626
+ ...WARDLEY_LEGEND_DESC_WORDINGS,
627
+ ...WARDLEY_LEGEND_GRADIENT_WORDINGS,
628
+ ...WARDLEY_LEGEND_PORTER_WORDINGS,
629
+ ];