@formicoidea/labre-framework-wardley 0.41.0 → 0.43.1

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,77 +1,334 @@
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';
4
3
  import { translateKey, } from '@formicoidea/labre-core/shared/services';
5
4
  import { Bound } from '@formicoidea/labre-core/global/gfx';
6
- import { GfxControllerIdentifier } from '@formicoidea/labre-core/std/gfx';
7
5
  import { GRADIENT_GREEN, GRADIENT_RED } from './gradient.js';
8
- 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';
9
- import { wardleyPorterArrowProps, wardleyPorterArrows } from './presets.js';
10
- const LEGEND_ORDER = [
11
- 'component',
12
- 'anchor',
13
- 'market',
14
- 'ecosystem',
15
- 'method',
16
- 'pipeline',
17
- 'link',
18
- 'arrow',
19
- 'inertia',
20
- 'porter',
21
- 'accelerator',
22
- 'decelerator',
23
- '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',
24
45
  ];
25
46
  /**
26
- * Default (editable) descriptions for each legend row — a `ChromeWording` per
27
- * row so the seed a legend writes into the document is resolved through the
28
- * host at BUILD time, like every other text `createWardleyLegend` creates.
29
- * The fallback is the literal that shipped before these keys existed.
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.
30
262
  */
31
263
  const LEGEND_DESC = {
32
- component: [
264
+ [WARDLEY_ROLE.component]: [
33
265
  'com.labre.wardley.legend.desc.component',
34
266
  'Need / capability (activity, practice, data…)',
35
267
  ],
36
- anchor: [
268
+ [WARDLEY_ROLE.anchor]: [
37
269
  'com.labre.wardley.legend.desc.anchor',
38
270
  'Stakeholder (customer, user…)',
39
271
  ],
40
- market: ['com.labre.wardley.legend.desc.market', 'Market (set of actors)'],
41
- ecosystem: ['com.labre.wardley.legend.desc.ecosystem', 'Ecosystem'],
42
- method: [
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]: [
43
281
  'com.labre.wardley.legend.desc.method',
44
282
  'Component + method (color = phase)',
45
283
  ],
46
- pipeline: [
284
+ [WARDLEY_ROLE.pipeline]: [
47
285
  'com.labre.wardley.legend.desc.pipeline',
48
286
  'Pipeline (possible choices for a capability)',
49
287
  ],
50
- link: [
288
+ [WARDLEY_ROLE.dependency]: [
51
289
  'com.labre.wardley.legend.desc.link',
52
290
  'Need relation (parent → child)',
53
291
  ],
54
- arrow: [
292
+ [WARDLEY_ROLE.changeArrow]: [
55
293
  'com.labre.wardley.legend.desc.arrow',
56
294
  'Evolution / movement (red = future)',
57
295
  ],
58
- inertia: ['com.labre.wardley.legend.desc.inertia', 'Inertia to change'],
59
- porter: [
296
+ [WARDLEY_ROLE.inertia]: [
297
+ 'com.labre.wardley.legend.desc.inertia',
298
+ 'Inertia to change',
299
+ ],
300
+ [WARDLEY_ROLE.porter]: [
60
301
  'com.labre.wardley.legend.desc.porter',
61
302
  "Porter's forces (external competition: R relative, L survival, E establish)",
62
303
  ],
63
- accelerator: [
304
+ [WARDLEY_ROLE.accelerator]: [
64
305
  'com.labre.wardley.legend.desc.accelerator',
65
306
  'Accelerator (speeds evolution up)',
66
307
  ],
67
- decelerator: [
308
+ [WARDLEY_ROLE.decelerator]: [
68
309
  'com.labre.wardley.legend.desc.decelerator',
69
310
  'Decelerator (slows evolution down)',
70
311
  ],
71
- area: ['com.labre.wardley.legend.desc.area', 'Area (zone of the map)'],
312
+ [WARDLEY_ROLE.area]: [
313
+ 'com.labre.wardley.legend.desc.area',
314
+ 'Area (zone of the map)',
315
+ ],
72
316
  };
73
317
  /** Every {@link LEGEND_DESC} wording, for `translations.ts`'s manifest. */
74
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
+ }
75
332
  /** Gradient-meaning block, keyed by variant (caption + 2-colour swatch). */
76
333
  const LEGEND_GRADIENT = {
77
334
  opportunity: {
@@ -98,7 +355,58 @@ const LEGEND_GRADIENT = {
98
355
  };
99
356
  /** Every {@link LEGEND_GRADIENT} caption, for `translations.ts`'s manifest. */
100
357
  export const WARDLEY_LEGEND_GRADIENT_WORDINGS = Object.values(LEGEND_GRADIENT).map(g => g.caption);
101
- /* ── Porter's five forces: the panel under the rows ───────────────────── */
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 ────────────────────────────────────── */
102
410
  /** The panel's own numbers. Model units, like every other measure here. */
103
411
  const PORTER_PANEL = {
104
412
  pad: 12,
@@ -142,18 +450,13 @@ const PORTER_FORCES = [
142
450
  'Bargaining power of suppliers',
143
451
  ],
144
452
  ];
145
- /** What the panel's own glyph reads: the notation, not one force — untouched, like the Cynefin A/C letters. */
453
+ /** What the panel's own glyph reads: the notation, not one force. */
146
454
  const PORTER_PANEL_LETTERS = 'R/L/E';
147
455
  /** What the letters mean, spelled out under the diagram. */
148
456
  const PORTER_CAPTION = [
149
457
  'com.labre.wardley.legend.porter.caption',
150
458
  'R/L/E = Relative competition, or struggLe for survival, or struggle to Establish',
151
459
  ];
152
- /** The legend box's own title. */
153
- export const WARDLEY_LEGEND_TITLE = [
154
- 'com.labre.wardley.legend.title',
155
- 'Legend',
156
- ];
157
460
  /** The five-forces panel's own title. */
158
461
  export const WARDLEY_LEGEND_PORTER_TITLE = [
159
462
  'com.labre.wardley.legend.porter.title',
@@ -222,418 +525,24 @@ export function porterPanelLayout(w) {
222
525
  caption: [p.pad, captionY, inner, p.captionH],
223
526
  };
224
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);
225
530
  /**
226
- * Every wording this file writes onto the canvas, for `translations.ts`'s
227
- * manifest contribution — the auto-legend's own title, its rows' captions,
228
- * the gradient blocks and the Porter panel, all as `seed` (written into the
229
- * document once, at the moment a legend is generated).
230
- */
231
- export const WARDLEY_LEGEND_WORDINGS = [
232
- WARDLEY_LEGEND_TITLE,
233
- ...WARDLEY_LEGEND_DESC_WORDINGS,
234
- ...WARDLEY_LEGEND_GRADIENT_WORDINGS,
235
- ...WARDLEY_LEGEND_PORTER_WORDINGS,
236
- ];
237
- /**
238
- * Build a "Legend" group from real, editable elements (white rect frame +
239
- * "Legend" text + one row of [real component glyph + description text] per
240
- * Wardley component TYPE present inside the background's perimeter + a
241
- * gradient-meaning block when the background is a gradient variant + the
242
- * five-forces panel when a Porter's-forces glyph is on the map). A snapshot
243
- * is created on each call; everything is grouped so it can be moved / resized /
244
- * 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.
245
537
  */
246
- export function createWardleyLegend(std, bg) {
247
- const gfx = std.get(GfxControllerIdentifier);
248
- const surface = gfx.surface;
249
- if (!surface)
250
- return;
251
- const [bx, by, , bh] = bg.deserializedXYWH;
252
- // 1. Detect which component types are present inside the perimeter.
253
- const present = new Set();
254
- for (const el of gfx.getElementsByBound(Bound.deserialize(bg.xywh), {
255
- type: 'canvas',
256
- })) {
257
- // Note: WardleyNodeElementModel extends ShapeElementModel, so the order of
258
- // these instanceof checks matters.
259
- if (el instanceof WardleyNodeElementModel) {
260
- if (el.kind !== 'handle')
261
- present.add(el.kind);
262
- }
263
- else if (el instanceof ConnectorElementModel) {
264
- if (el.strokeStyle === StrokeStyle.Dash || el.stroke === WARDLEY_RED) {
265
- present.add('arrow');
266
- }
267
- else if (el.stroke === LINK_GREY) {
268
- present.add('link');
269
- }
270
- // market triangle connectors (NODE_STROKE) are ignored.
271
- }
272
- else if (el instanceof ShapeElementModel) {
273
- // The inertia bar is the only plain shape this legend describes, and it
274
- // is recognised by its FILL. A Porter's-forces arrow is a plain shape too
275
- // — a filled red polygon, the glyph's own wiring — and it is `WARDLEY_RED`
276
- // rather than `INERTIA_COLOR`, so it falls through here and is described
277
- // by the porter circle it belongs to. Asserted in `porter.unit.spec.ts`:
278
- // an arrow that started answering this test would put an "Inertia to
279
- // change" row in the legend of a map with no inertia bar on it.
280
- if (el.fillColor === INERTIA_COLOR)
281
- present.add('inertia');
282
- }
283
- }
284
- const rows = LEGEND_ORDER.filter(t => present.has(t));
285
- // 2. Layout (model units). The text column is wide enough for one-line
286
- // descriptions; the gradient row is taller as its caption may wrap.
287
- const PAD = 16;
288
- const TITLE_H = 28;
289
- const ROW_H = 30;
290
- const GLYPH_W = 46;
291
- const GAP = 12;
292
- const TEXT_FS = 15;
293
- const TITLE_FS = 18;
294
- const TEXT_W = 360;
295
- const GRAD_ROW_H = 40;
296
- const W = PAD * 2 + GLYPH_W + GAP + TEXT_W;
297
- const variant = bg.variant;
298
- const grad = variant !== 'classic' ? LEGEND_GRADIENT[variant] : null;
299
- const gradH = grad ? 12 + GRAD_ROW_H : 0;
300
- // The five-forces panel, and ONLY when a force is actually on the map: a
301
- // legend of a map with no porter on it has to come out byte-identical to the
302
- // one it came out as before this panel existed. Pinned in
303
- // `porter.unit.spec.ts`.
304
- const panel = present.has('porter') ? porterPanelLayout(W - PAD * 2) : null;
305
- const panelH = panel ? 12 + panel.h : 0;
306
- const H = PAD * 2 + TITLE_H + rows.length * ROW_H + gradH + panelH;
307
- const x0 = bx + 50;
308
- const y0 = by + bh - 56 - H;
309
- const text = (t, x, y, w, h, fontSize, align = 'left') => surface.addElement({
310
- type: 'text',
311
- text: t,
312
- fontFamily: FontFamily.Inter,
313
- fontSize,
314
- color: NODE_STROKE,
315
- textAlign: align,
316
- xywh: new Bound(x, y, w, h).serialize(),
317
- });
318
- // ── glyph builders (real, editable elements), centred on (cx, cy) ─────
319
- //
320
- // DELIBERATELY ROLE-LESS. These are real `wardleyNode` elements, but a
321
- // legend documents the map — it is not part of it. Giving its glyphs
322
- // `wardley:component` & co. would make every legend entry count as an
323
- // artefact and skew any rule written against roles (a legend would add a
324
- // phantom component, anchor, market…). Neutral is the semantics we want;
325
- // `kind` still drives their rendering. Frozen by a test in
326
- // `__tests__/roles.unit.spec.ts`.
327
- const ellipse = (kind, d, fill, sw, cx, cy) => surface.addElement({
328
- type: 'wardleyNode',
329
- kind,
330
- shapeType: 'ellipse',
331
- filled: true,
332
- fillColor: fill,
333
- strokeColor: NODE_STROKE,
334
- strokeWidth: sw,
335
- shapeStyle: ShapeStyle.General,
336
- roughness: 0,
337
- xywh: new Bound(cx - d / 2, cy - d / 2, d, d).serialize(),
338
- });
339
- const glyph = (type, cx, cy) => {
340
- switch (type) {
341
- case 'component':
342
- return [ellipse('component', 16, NODE_FILL, NODE_STROKE_WIDTH, cx, cy)];
343
- case 'anchor':
344
- return [ellipse('anchor', 16, NODE_FILL, NODE_STROKE_WIDTH, cx, cy)];
345
- case 'ecosystem':
346
- return [ellipse('ecosystem', 20, NODE_FILL, NODE_STROKE_WIDTH, cx, cy)];
347
- case 'method':
348
- return [ellipse('method', 18, METHOD_FILL, NODE_STROKE_WIDTH, cx, cy)];
349
- case 'inertia':
350
- return [
351
- surface.addElement({
352
- type: 'shape',
353
- shapeType: 'rect',
354
- filled: true,
355
- fillColor: INERTIA_COLOR,
356
- strokeColor: INERTIA_COLOR,
357
- strokeWidth: 0,
358
- shapeStyle: ShapeStyle.General,
359
- roughness: 0,
360
- radius: 0,
361
- xywh: new Bound(cx - 2.5, cy - 11, 5, 22).serialize(),
362
- }),
363
- ];
364
- case 'pipeline': {
365
- const bw2 = 34;
366
- const bh2 = 12;
367
- const hd = 10;
368
- const top = cy - bh2 / 2;
369
- return [
370
- surface.addElement({
371
- type: 'wardleyNode',
372
- kind: 'pipeline',
373
- shapeType: 'rect',
374
- filled: true,
375
- fillColor: PIPELINE_FILL,
376
- strokeColor: NODE_STROKE,
377
- strokeWidth: NODE_STROKE_WIDTH,
378
- shapeStyle: ShapeStyle.General,
379
- roughness: 0,
380
- radius: 0,
381
- xywh: new Bound(cx - bw2 / 2, top, bw2, bh2).serialize(),
382
- }),
383
- surface.addElement({
384
- type: 'wardleyNode',
385
- kind: 'handle',
386
- shapeType: 'rect',
387
- filled: true,
388
- fillColor: NODE_FILL,
389
- strokeColor: NODE_STROKE,
390
- strokeWidth: NODE_STROKE_WIDTH,
391
- shapeStyle: ShapeStyle.General,
392
- roughness: 0,
393
- radius: 0,
394
- xywh: new Bound(cx - hd / 2, top - hd / 2, hd, hd).serialize(),
395
- }),
396
- ];
397
- }
398
- case 'market': {
399
- const R = 11;
400
- const dr = 3;
401
- const rho = 6;
402
- const sin60 = Math.sqrt(3) / 2;
403
- const circle = surface.addElement({
404
- type: 'wardleyNode',
405
- kind: 'market',
406
- shapeType: 'ellipse',
407
- filled: true,
408
- fillColor: NODE_FILL,
409
- strokeColor: NODE_STROKE,
410
- strokeWidth: NODE_STROKE_WIDTH,
411
- shapeStyle: ShapeStyle.General,
412
- roughness: 0,
413
- xywh: new Bound(cx - R, cy - R, R * 2, R * 2).serialize(),
414
- });
415
- const verts = [
416
- [0, -rho],
417
- [rho * sin60, rho / 2],
418
- [-rho * sin60, rho / 2],
419
- ];
420
- const dots = verts.map(([vx, vy]) => surface.addElement({
421
- type: 'wardleyNode',
422
- kind: 'component',
423
- shapeType: 'ellipse',
424
- filled: true,
425
- fillColor: NODE_FILL,
426
- strokeColor: NODE_STROKE,
427
- strokeWidth: MARKET_DOT_STROKE_WIDTH,
428
- shapeStyle: ShapeStyle.General,
429
- roughness: 0,
430
- xywh: new Bound(cx + vx - dr, cy + vy - dr, dr * 2, dr * 2).serialize(),
431
- }));
432
- const conns = [
433
- [dots[0], dots[1]],
434
- [dots[1], dots[2]],
435
- [dots[2], dots[0]],
436
- ].map(([a, b]) => surface.addElement({
437
- type: 'connector',
438
- mode: ConnectorMode.Straight,
439
- source: { id: a },
440
- target: { id: b },
441
- stroke: MARKET_LINK_COLOR,
442
- strokeStyle: StrokeStyle.Solid,
443
- strokeWidth: MARKET_LINK_WIDTH,
444
- frontEndpointStyle: PointStyle.None,
445
- rearEndpointStyle: PointStyle.None,
446
- }));
447
- return [circle, ...dots, ...conns];
448
- }
449
- case 'porter': {
450
- // The same drawing as the map's, at the row's scale: the helper scales
451
- // the gap, the shaft and the head with the radius, so what a reader
452
- // sees here is a small Porter and not a circle with four map-sized
453
- // spikes through it.
454
- //
455
- // 6 and not the 8 the other circles get, because this glyph is the only
456
- // one wider than its own circle: arrows included it spans
457
- // `2 * (R + (gap + length) * R / 30)`, which at 8 would overrun a row
458
- // 30 units tall. At 6 it comes to just over `ROW_H` and sits inside its
459
- // line — and it is a RATIO, so the PO's doubling of the map glyph left
460
- // this row exactly where it was.
461
- const R = 6;
462
- const FS = 8;
463
- const circle = surface.addElement({
464
- type: 'wardleyNode',
465
- kind: 'porter',
466
- shapeType: 'ellipse',
467
- filled: true,
468
- fillColor: NODE_FILL,
469
- strokeColor: NODE_STROKE,
470
- strokeWidth: NODE_STROKE_WIDTH,
471
- shapeStyle: ShapeStyle.General,
472
- roughness: 0,
473
- xywh: new Bound(cx - R, cy - R, R * 2, R * 2).serialize(),
474
- });
475
- const arrows = wardleyPorterArrows(cx, cy, R).map(arrow => surface.addElement(wardleyPorterArrowProps(arrow)));
476
- // The letter is the notation, so a legend that dropped it would be
477
- // describing a circle rather than the glyph it stands for — but here it
478
- // is a SEPARATE text element rather than the circle's inner text, which
479
- // is what the map glyph uses. A shape lays its text out inside padding
480
- // (`SHAPE_TEXT_VERTICAL_PADDING`) larger than this 12-unit box, so at
481
- // font size 8 the character was pushed out under the circle (recette
482
- // v2). A free text has no padding to overflow, and its box is placed on
483
- // the circle's own centre. Role-less like every other legend glyph.
484
- const letter = text(PORTER_DEFAULT_LETTER, cx - R, cy - FS / 2 - 1, R * 2, FS + 2, FS, 'center');
485
- return [circle, ...arrows, letter];
486
- }
487
- case 'accelerator':
488
- case 'decelerator': {
489
- // The map's own outline, at the row's scale: `vertices` are normalized
490
- // to the box, so the SAME seven points draw a 30 × 18 legend arrow and
491
- // a 48 × 40 canvas one. Role-less like every glyph in this box — a
492
- // legend documents the map, it is not part of it.
493
- const gw = 30;
494
- const gh = 18;
495
- return [
496
- surface.addElement({
497
- type: 'wardleyNode',
498
- kind: type,
499
- shapeType: 'polygon',
500
- vertices: (type === 'accelerator'
501
- ? ACCELERATOR_VERTICES
502
- : DECELERATOR_VERTICES).map(([vx, vy]) => [vx, vy]),
503
- isClosed: true,
504
- filled: true,
505
- fillColor: ACCELERATOR_FILL,
506
- strokeColor: NODE_STROKE,
507
- strokeWidth: ACCELERATOR_STROKE_WIDTH,
508
- shapeStyle: ShapeStyle.General,
509
- roughness: 0,
510
- xywh: new Bound(cx - gw / 2, cy - gh / 2, gw, gh).serialize(),
511
- }),
512
- ];
513
- }
514
- case 'area': {
515
- // A small translucent rect, and the rect even when the map's zones are
516
- // polygons: the row says what a ZONE is, and the number of corners is
517
- // the author's choice rather than part of the notation. Same wash and
518
- // same rim as the canvas draws, so the swatch is recognisable.
519
- const gw = 34;
520
- const gh = 20;
521
- return [
522
- surface.addElement({
523
- type: 'wardleyNode',
524
- kind: 'area',
525
- shapeType: 'rect',
526
- filled: true,
527
- fillColor: AREA_FILL,
528
- strokeColor: AREA_STROKE,
529
- strokeWidth: AREA_STROKE_WIDTH,
530
- shapeStyle: ShapeStyle.General,
531
- roughness: 0,
532
- radius: 0,
533
- xywh: new Bound(cx - gw / 2, cy - gh / 2, gw, gh).serialize(),
534
- }),
535
- ];
536
- }
537
- case 'link':
538
- return [
539
- surface.addElement({
540
- type: 'connector',
541
- mode: ConnectorMode.Straight,
542
- source: { position: [cx - 18, cy + 6] },
543
- target: { position: [cx + 18, cy - 6] },
544
- stroke: LINK_GREY,
545
- strokeStyle: StrokeStyle.Solid,
546
- strokeWidth: LINK_STROKE_WIDTH,
547
- frontEndpointStyle: PointStyle.None,
548
- rearEndpointStyle: PointStyle.None,
549
- }),
550
- ];
551
- case 'arrow':
552
- return [
553
- surface.addElement({
554
- type: 'connector',
555
- mode: ConnectorMode.Straight,
556
- source: { position: [cx - 18, cy] },
557
- target: { position: [cx + 16, cy] },
558
- stroke: WARDLEY_RED,
559
- strokeStyle: StrokeStyle.Dash,
560
- strokeWidth: LINK_STROKE_WIDTH,
561
- frontEndpointStyle: PointStyle.None,
562
- rearEndpointStyle: PointStyle.Triangle,
563
- }),
564
- ];
565
- }
566
- };
567
- // 3. Create the elements.
568
- std.store.captureSync();
569
- const ids = [];
570
- // White frame.
571
- ids.push(surface.addElement({
572
- type: 'shape',
573
- shapeType: 'rect',
574
- filled: true,
575
- fillColor: NOTATION_NEUTRALS.cardFill,
576
- strokeColor: NOTATION_NEUTRALS.legendBorder,
577
- strokeWidth: 1,
578
- shapeStyle: ShapeStyle.General,
579
- roughness: 0,
580
- radius: 6,
581
- xywh: new Bound(x0, y0, W, H).serialize(),
582
- }));
583
- // Title.
584
- ids.push(text(translateKey(std, ...WARDLEY_LEGEND_TITLE), x0 + PAD, y0 + PAD, W - PAD * 2, TITLE_FS + 6, TITLE_FS));
585
- // Rows.
586
- let ry = y0 + PAD + TITLE_H;
587
- for (const t of rows) {
588
- const cyRow = ry + ROW_H / 2;
589
- ids.push(...glyph(t, x0 + PAD + GLYPH_W / 2, cyRow));
590
- ids.push(text(translateKey(std, ...LEGEND_DESC[t]), x0 + PAD + GLYPH_W + GAP, cyRow - (TEXT_FS + 8) / 2, TEXT_W, TEXT_FS + 8, TEXT_FS));
591
- ry += ROW_H;
592
- }
593
- // Gradient meaning block: a separator, then [2-colour swatch | caption].
594
- if (grad) {
595
- const sepY = ry + 4;
596
- ids.push(surface.addElement({
597
- type: 'shape',
598
- shapeType: 'rect',
599
- filled: true,
600
- fillColor: NOTATION_NEUTRALS.legendBorder,
601
- strokeColor: NOTATION_NEUTRALS.legendBorder,
602
- strokeWidth: 0,
603
- shapeStyle: ShapeStyle.General,
604
- roughness: 0,
605
- radius: 0,
606
- xywh: new Bound(x0 + PAD, sepY, W - PAD * 2, 1).serialize(),
607
- }));
608
- const cyRow = sepY + 8 + GRAD_ROW_H / 2;
609
- const sw = 14;
610
- const sgap = 2;
611
- const sx = x0 + PAD + GLYPH_W / 2 - (sw * 2 + sgap) / 2;
612
- grad.swatch.forEach((col, i) => {
613
- ids.push(surface.addElement({
614
- type: 'shape',
615
- shapeType: 'rect',
616
- filled: true,
617
- fillColor: col,
618
- strokeColor: NOTATION_NEUTRALS.legendBorder,
619
- strokeWidth: 0.5,
620
- shapeStyle: ShapeStyle.General,
621
- roughness: 0,
622
- radius: 1,
623
- xywh: new Bound(sx + i * (sw + sgap), cyRow - sw / 2, sw, sw).serialize(),
624
- }));
625
- });
626
- ids.push(text(translateKey(std, ...grad.caption), x0 + PAD + GLYPH_W + GAP, cyRow - GRAD_ROW_H / 2, TEXT_W, GRAD_ROW_H, TEXT_FS));
627
- }
628
- // Porter's five forces: the panel the PO's reference draws, under everything
629
- // else. It is not a row — a row says what a glyph IS, and this says what the
630
- // notation MEANS: four named pressures around one circle, and the three
631
- // letters spelled out. Only drawn when a force is on the map, so a legend
632
- // without one is the legend it always was.
633
- if (panel) {
634
- const px = x0 + PAD;
635
- 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;
636
542
  const p = PORTER_PANEL;
543
+ const px = x + BOX_PAD;
544
+ const py = y + 12;
545
+ const ids = [];
637
546
  // The backing: square-cornered and grey, so the panel reads as a figure
638
547
  // set into the legend rather than as one more entry in it.
639
548
  ids.push(surface.addElement({
@@ -649,33 +558,18 @@ export function createWardleyLegend(std, bg) {
649
558
  xywh: new Bound(px, py, panel.w, panel.h).serialize(),
650
559
  }));
651
560
  const [tx, ty, tw, th] = panel.title;
652
- ids.push(text(translateKey(std, ...WARDLEY_LEGEND_PORTER_TITLE), 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));
653
562
  const [cx, cy] = panel.center;
654
- ids.push(surface.addElement({
655
- type: 'wardleyNode',
656
- kind: 'porter',
657
- shapeType: 'ellipse',
658
- filled: true,
659
- fillColor: NODE_FILL,
660
- strokeColor: NODE_STROKE,
661
- strokeWidth: NODE_STROKE_WIDTH,
662
- shapeStyle: ShapeStyle.General,
663
- roughness: 0,
664
- xywh: new Bound(px + cx - panel.radius, py + cy - panel.radius, panel.radius * 2, panel.radius * 2).serialize(),
665
- }));
563
+ ids.push(addGlyph(surface, 'porter', px + cx - panel.radius, py + cy - panel.radius, panel.radius * 2, panel.radius * 2));
666
564
  for (const arrow of wardleyPorterArrows(px + cx, py + cy, panel.radius)) {
667
- ids.push(surface.addElement(wardleyPorterArrowProps(arrow)));
565
+ ids.push(surface.addElement(neutral(wardleyPorterArrowProps(arrow))));
668
566
  }
669
567
  // All three letters at once: this circle stands for the NOTATION rather
670
568
  // than for one force, so picking one of them would make the panel say that
671
- // a Porter is an R.
672
- //
673
- // A SEPARATE text element, like the porter row above and for the same
674
- // reason (recette v2): a shape lays its text out inside a padding
675
- // (`SHAPE_TEXT_PADDING`) wider than this 30-unit circle, so the characters
676
- // would be pushed outside it. A free text has no padding to overflow, and
677
- // its box is placed on the circle's own centre.
678
- 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'));
679
573
  for (const box of panel.boxes) {
680
574
  const [bx, by, bw, bh] = box.xywh;
681
575
  ids.push(surface.addElement({
@@ -690,15 +584,46 @@ export function createWardleyLegend(std, bg) {
690
584
  radius: 0,
691
585
  xywh: new Bound(px + bx, py + by, bw, bh).serialize(),
692
586
  }));
693
- ids.push(text(translateKey(std, ...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'));
694
588
  }
695
589
  const [capX, capY, capW, capH] = panel.caption;
696
- ids.push(text(translateKey(std, ...PORTER_CAPTION), px + capX, py + capY, capW, capH, p.captionFs, 'center'));
697
- }
698
- // 4. Group everything and select it.
699
- const [, result] = std.command.exec(createGroupCommand, { elements: ids });
700
- gfx.selection.set({
701
- elements: [result.groupId || ids[0]],
702
- editing: false,
703
- });
704
- }
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
+ ];