@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/LICENSE +384 -0
- package/dist/actions.d.ts +80 -13
- package/dist/actions.js +167 -87
- package/dist/commands.js +52 -31
- package/dist/element-view.d.ts +20 -34
- package/dist/element-view.js +23 -139
- package/dist/import.d.ts +28 -0
- package/dist/import.js +66 -7
- package/dist/index.d.ts +1 -0
- package/dist/index.js +4 -0
- package/dist/legend.d.ts +107 -12
- package/dist/legend.js +485 -471
- package/dist/morph.d.ts +15 -3
- package/dist/morph.js +90 -10
- package/dist/natures.js +19 -0
- package/dist/node/consts.d.ts +15 -13
- package/dist/node/consts.js +15 -13
- package/dist/node/node-renderer.js +12 -1
- package/dist/presets.d.ts +71 -1
- package/dist/presets.js +83 -1
- package/dist/templates/index.d.ts +10 -0
- package/dist/templates/index.js +40 -8
- package/dist/templates/maps.d.ts +94 -0
- package/dist/templates/maps.js +148 -27
- package/dist/toolbar/config.d.ts +34 -7
- package/dist/toolbar/config.js +109 -34
- package/dist/toolbar/node-config.d.ts +34 -2
- package/dist/toolbar/node-config.js +112 -9
- package/dist/toolbar/wardley-senior-button.d.ts +8 -0
- package/dist/toolbar/wardley-senior-button.js +12 -2
- package/dist/translations.js +81 -1
- package/dist/view.js +19 -15
- package/package.json +2 -2
package/dist/legend.js
CHANGED
|
@@ -1,58 +1,412 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
8
|
-
import { wardleyPorterArrowProps, wardleyPorterArrows } from './presets.js';
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
/**
|
|
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:
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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 =
|
|
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
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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(
|
|
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.
|
|
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
|
-
//
|
|
585
|
-
|
|
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(
|
|
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(
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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
|
+
];
|