@formicoidea/labre-framework-wardley 0.60.0 → 1.0.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/commands.js +2 -2
- package/dist/dark-palette.d.ts +4 -1
- package/dist/dark-palette.js +7 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.js +9 -2
- package/dist/interchange-headless.d.ts +32 -6
- package/dist/interchange-headless.js +65 -6
- package/dist/interchange.d.ts +1 -1
- package/dist/interchange.js +8 -3
- package/dist/legend.d.ts +1 -0
- package/dist/legend.js +66 -1
- package/dist/method.d.ts +53 -0
- package/dist/method.js +88 -0
- package/dist/natures.d.ts +64 -2
- package/dist/natures.js +154 -6
- package/dist/node/consts.d.ts +133 -1
- package/dist/node/consts.js +219 -1
- package/dist/node/node-renderer.d.ts +2 -2
- package/dist/node/node-renderer.js +42 -1
- package/dist/renderer-json-export.d.ts +40 -0
- package/dist/renderer-json-export.js +669 -0
- package/dist/renderer-json-import.d.ts +71 -0
- package/dist/renderer-json-import.js +759 -0
- package/dist/renderer-json.d.ts +202 -0
- package/dist/renderer-json.js +366 -0
- package/dist/roles.d.ts +2 -2
- package/dist/roles.js +13 -0
- package/dist/toolbar/node-config.d.ts +0 -23
- package/dist/toolbar/node-config.js +1 -114
- package/dist/translations.js +6 -3
- package/package.json +2 -2
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
import { owmDefaultPlot } from './export.js';
|
|
2
|
+
import { wardleyMethodOf } from './method.js';
|
|
3
|
+
import { WARDLEY_METHOD_TAG_ID, WARDLEY_NATURE_TAG_ID } from './natures.js';
|
|
4
|
+
import { INERTIA_SIZE, PIPELINE_WIDTH } from './node/consts.js';
|
|
5
|
+
import { WARDLEY_NODE_SIZE } from './presets.js';
|
|
6
|
+
import { descriptionSlot, EVOLVE_TYPES, jsonContextElement, jsonDecoratorCentre, jsonDescriptionElement, jsonLabelBox, jsonLabelShift, jsonRangeElement, jsonStepCentre, jsonStepElement, jsonTitleElement, LABRE_NATURES, PHASE_LINES, relationLook, rendererCanvasOf, rendererColour, rendererPointOf, } from './renderer-json.js';
|
|
7
|
+
import { wardleyChangeArrowElement, wardleyDependencyElement, wardleyInertiaElement, wardleyMapElement, wardleyMarketElements, wardleyNodeElements, wardleyPipelineElements, } from './toolbox.js';
|
|
8
|
+
/**
|
|
9
|
+
* The renderer JSON reader's remarks whose wording is FIXED, as
|
|
10
|
+
* `[key, English]` pairs — the shape and the reason of
|
|
11
|
+
* `WARDLEY_OWM_IMPORT_REMARKS`: a reader has no `std`, so it hands the key
|
|
12
|
+
* over and `reportInterchangeImport` resolves it. Contributed to the
|
|
13
|
+
* manifest by `./translations.ts`.
|
|
14
|
+
*/
|
|
15
|
+
export const WARDLEY_JSON_IMPORT_REMARKS = {
|
|
16
|
+
notJson: [
|
|
17
|
+
'com.labre.wardley.import.json.remark.not-json',
|
|
18
|
+
'This file is not JSON, so nothing was read from it.',
|
|
19
|
+
],
|
|
20
|
+
notAMap: [
|
|
21
|
+
'com.labre.wardley.import.json.remark.not-a-map',
|
|
22
|
+
'This JSON is not a wardley-map-renderer map: it has no `components` list, so nothing was read from it.',
|
|
23
|
+
],
|
|
24
|
+
invalidComponent: [
|
|
25
|
+
'com.labre.wardley.import.json.remark.invalid-component',
|
|
26
|
+
'this component lacks an id, a name, a type or a numeric position, so it was not imported.',
|
|
27
|
+
],
|
|
28
|
+
invalidRelation: [
|
|
29
|
+
'com.labre.wardley.import.json.remark.invalid-relation',
|
|
30
|
+
'a relation lacks its consumer or its supplier, so it was not imported.',
|
|
31
|
+
],
|
|
32
|
+
undeclaredEnd: [
|
|
33
|
+
'com.labre.wardley.import.json.remark.undeclared-end',
|
|
34
|
+
'a relation names this, and no component of the file has this id, so the relation was not imported.',
|
|
35
|
+
],
|
|
36
|
+
duplicateId: [
|
|
37
|
+
'com.labre.wardley.import.json.remark.duplicate-id',
|
|
38
|
+
'this id is used by more than one component. Both are on the map, and every relation naming it means the first.',
|
|
39
|
+
],
|
|
40
|
+
invalidEvolution: [
|
|
41
|
+
'com.labre.wardley.import.json.remark.invalid-evolution',
|
|
42
|
+
'an evolution of this component has no numeric target position, so it was not imported.',
|
|
43
|
+
],
|
|
44
|
+
outOfRange: [
|
|
45
|
+
'com.labre.wardley.import.json.remark.out-of-range',
|
|
46
|
+
'its position falls outside 0…1, so it is drawn off the map’s plot.',
|
|
47
|
+
],
|
|
48
|
+
noGeometry: [
|
|
49
|
+
'com.labre.wardley.import.json.remark.no-geometry',
|
|
50
|
+
'this pipeline has no usable `pipelineGeometry`, so it was drawn at the toolbar’s size around its position. The file did not say how wide it is.',
|
|
51
|
+
],
|
|
52
|
+
droppedField: [
|
|
53
|
+
'com.labre.wardley.import.json.remark.dropped-field',
|
|
54
|
+
'{{count}} `{{kind}}` field(s) have nothing on this canvas to show them, so they were not imported.',
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
const R = WARDLEY_JSON_IMPORT_REMARKS;
|
|
58
|
+
/**
|
|
59
|
+
* A `@formicoidea/wardley-map-renderer` JSON map, read as a Wardley map made
|
|
60
|
+
* of what a user places from the TOOLBAR (ADR 0012 P3, ADR 0045, PO decisions
|
|
61
|
+
* of 2026-10-28).
|
|
62
|
+
*
|
|
63
|
+
* ## Only what shows, made of the toolbar's own artefacts
|
|
64
|
+
*
|
|
65
|
+
* The OWM path the connector used drew a second component at the target of
|
|
66
|
+
* every evolution, a pipeline's name twice with its square centred, and lost
|
|
67
|
+
* the source's squares and colours (the connector's bench). Here every
|
|
68
|
+
* Wardley element is a `WARDLEY_TOOLBOX` builder's, given its real
|
|
69
|
+
* parameters; what the toolbox has no item for is a plain shape or a free
|
|
70
|
+
* text, grouped with the component it belongs to; and what nothing can show
|
|
71
|
+
* is dropped and named in the report. No id and no field of the file is
|
|
72
|
+
* stored on any element.
|
|
73
|
+
*
|
|
74
|
+
* ## The mapping
|
|
75
|
+
*
|
|
76
|
+
* | renderer field | on the canvas |
|
|
77
|
+
* | ------------------------------------------------ | ----------------------------------------------------------------------------- |
|
|
78
|
+
* | `type` anchor / component / pipeline; `subtype` market / ecosystem; a `method` | the toolbox anchor, component, pipeline, market, ecosystem, method item |
|
|
79
|
+
* | `subtype` userNeed / solution / supplier / functional | a plain component (the subtype has no look) |
|
|
80
|
+
* | `label.name` / `label.position` | the toolbox name, moved by the offset (`jsonLabelBox`) |
|
|
81
|
+
* | `position.*.scalar` | the node's centre on the plot (visibility 0 is the TOP) |
|
|
82
|
+
* | `position.evolution.range` | a thin plain rect at the node's height, under it, in its group |
|
|
83
|
+
* | `nature` activity / data / practice / knowledge | the `wardley:nature` tag; the other five are dropped |
|
|
84
|
+
* | `description` | a free text under the name, in the component's group |
|
|
85
|
+
* | `color` | the node's stroke (an unknown name paints black, as in the renderer) |
|
|
86
|
+
* | `method` (category, recommendation) | the method item and its `wardley:method` tag; a pair naming none of the nine leaves it untagged (it paints as Make) |
|
|
87
|
+
* | `accelerator` / `deaccelerator` | the toolbox climate arrow beside the node, where the renderer draws it |
|
|
88
|
+
* | `step` | a numbered plain dot at the node's upper left, in its group |
|
|
89
|
+
* | `evolvesTo[].position` / `evolveType` / `inertia` | the toolbox change arrow to that point, `evolveType` as its label, an inertia item on each phase line it crosses (at its middle when it crosses none) |
|
|
90
|
+
* | `pipelineGeometry` | the pipeline's body stretched to the box, its handle at `handleEvolution` |
|
|
91
|
+
* | `relations[]` and their `type`, `flow` | the toolbox dependency link, consumer first; Flow blue and thicker, Constraint black and as thick; `flow.label` its text, `flow.style` its dash or double width |
|
|
92
|
+
* | `title` / `context` | free texts above / under the map |
|
|
93
|
+
* | `renderConfig` axis and phase labels and toggles | the map's own fields |
|
|
94
|
+
* | `id`s, component `inertia`, a pipeline's `position`, subtype / nature on a pipeline, the rest of `renderConfig`, unknown fields | **dropped**, named in the report |
|
|
95
|
+
*
|
|
96
|
+
* ## An evolution is an arrow to a point, never a twin
|
|
97
|
+
*
|
|
98
|
+
* The toolbar's Arrow tool, dragged from a component and dropped on the empty
|
|
99
|
+
* map, leaves the change-arrow connector with a free end
|
|
100
|
+
* (`ConnectorTool.dragEnd` keeps `target: { position }`), and the renderer
|
|
101
|
+
* draws an evolution as an arrow and no second node. So it is that.
|
|
102
|
+
*
|
|
103
|
+
* ## A bad document is a report
|
|
104
|
+
*
|
|
105
|
+
* Text that is not JSON, or JSON with no `components` list, imports nothing
|
|
106
|
+
* and the report says why; the reader never throws.
|
|
107
|
+
*/
|
|
108
|
+
export function importWardleyRendererJson(source,
|
|
109
|
+
// The caller's name is no title: the map's name is the page's, and only a
|
|
110
|
+
// title the file wrote becomes a text on the canvas.
|
|
111
|
+
_context = {}) {
|
|
112
|
+
let root;
|
|
113
|
+
try {
|
|
114
|
+
root = JSON.parse(source);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return refused(R.notJson);
|
|
118
|
+
}
|
|
119
|
+
if (!isRecord(root) || !Array.isArray(root.components)) {
|
|
120
|
+
return refused(R.notAMap);
|
|
121
|
+
}
|
|
122
|
+
const notes = [];
|
|
123
|
+
const dropped = new Map();
|
|
124
|
+
const drop = (kind) => dropped.set(kind, (dropped.get(kind) ?? 0) + 1);
|
|
125
|
+
const warn = ([key, message], sourceId, element) => notes.push({
|
|
126
|
+
kind: 'warning',
|
|
127
|
+
messageKey: key,
|
|
128
|
+
message,
|
|
129
|
+
...(sourceId === undefined ? {} : { sourceId }),
|
|
130
|
+
...(element === undefined ? {} : { element }),
|
|
131
|
+
});
|
|
132
|
+
/* ── The map, its title and its context ───────────────────────────── */
|
|
133
|
+
const plot = owmDefaultPlot();
|
|
134
|
+
const canvas = rendererCanvasOf(root.renderConfig);
|
|
135
|
+
const map = wardleyMapElement('classic', { x: 0, y: 0 });
|
|
136
|
+
Object.assign(map, mapFieldsOf(root.renderConfig, drop));
|
|
137
|
+
const mapBox = boxOf(map);
|
|
138
|
+
const texts = [];
|
|
139
|
+
const display = isRecord(root.renderConfig)
|
|
140
|
+
? root.renderConfig.display
|
|
141
|
+
: undefined;
|
|
142
|
+
const titleShown = !isRecord(display) || display.title !== false;
|
|
143
|
+
if (typeof root.title === 'string' && root.title.trim() && titleShown) {
|
|
144
|
+
texts.push(jsonTitleElement(root.title, mapBox, 'title'));
|
|
145
|
+
}
|
|
146
|
+
if (typeof root.context === 'string' && root.context.trim()) {
|
|
147
|
+
texts.push(jsonContextElement(root.context, mapBox, 'context'));
|
|
148
|
+
}
|
|
149
|
+
for (const key of Object.keys(root)) {
|
|
150
|
+
if (!TOP_LEVEL.includes(key))
|
|
151
|
+
drop(key);
|
|
152
|
+
}
|
|
153
|
+
/* ── Components: which are drawable, and under which local name ───── */
|
|
154
|
+
const components = [];
|
|
155
|
+
const byId = new Map();
|
|
156
|
+
const seen = new Map();
|
|
157
|
+
for (const raw of root.components) {
|
|
158
|
+
const component = drawable(raw);
|
|
159
|
+
if (!component) {
|
|
160
|
+
warn(R.invalidComponent, isRecord(raw) && typeof raw.id === 'string' ? raw.id : undefined, 'component');
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const count = (seen.get(component.id) ?? 0) + 1;
|
|
164
|
+
seen.set(component.id, count);
|
|
165
|
+
if (count > 1)
|
|
166
|
+
warn(R.duplicateId, component.id, 'component');
|
|
167
|
+
// A second component under one id gets a local name of its own, so its
|
|
168
|
+
// group and its arrows still find IT; a relation naming the id means the
|
|
169
|
+
// first.
|
|
170
|
+
component.local = count === 1 ? component.id : `${component.id}#${count}`;
|
|
171
|
+
components.push(component);
|
|
172
|
+
if (!byId.has(component.id))
|
|
173
|
+
byId.set(component.id, component);
|
|
174
|
+
}
|
|
175
|
+
/* ── Laying out ───────────────────────────────────────────────────── */
|
|
176
|
+
const pieces = [];
|
|
177
|
+
const decorations = [];
|
|
178
|
+
const arrows = [];
|
|
179
|
+
// Pipelines first: a body is drawn under the components it holds.
|
|
180
|
+
const ordered = [
|
|
181
|
+
...components.filter(c => c.kind === 'pipeline'),
|
|
182
|
+
...components.filter(c => c.kind !== 'pipeline'),
|
|
183
|
+
];
|
|
184
|
+
for (const component of ordered) {
|
|
185
|
+
const { raw, evolution, visibility } = component;
|
|
186
|
+
if ([evolution, visibility].some(value => value < 0 || value > 1)) {
|
|
187
|
+
warn(R.outOfRange, component.id, String(raw.type));
|
|
188
|
+
}
|
|
189
|
+
for (const key of Object.keys(raw)) {
|
|
190
|
+
if (!COMPONENT_FIELDS.includes(key))
|
|
191
|
+
drop(key);
|
|
192
|
+
}
|
|
193
|
+
const [cx, cy] = rendererPointOf(plot, evolution, visibility);
|
|
194
|
+
const local = component.local;
|
|
195
|
+
const extras = [];
|
|
196
|
+
const under = [];
|
|
197
|
+
let built;
|
|
198
|
+
if (component.kind === 'pipeline') {
|
|
199
|
+
for (const key of ['subtype', 'nature', 'method', 'step']) {
|
|
200
|
+
if (raw[key] !== undefined)
|
|
201
|
+
drop(`pipeline ${key}`);
|
|
202
|
+
}
|
|
203
|
+
for (const key of ['accelerator', 'deaccelerator', 'inertia']) {
|
|
204
|
+
if (raw[key] === true)
|
|
205
|
+
drop(`pipeline ${key}`);
|
|
206
|
+
}
|
|
207
|
+
built = pipelineOf(component, plot, cx, cy, () => notes.push({
|
|
208
|
+
kind: 'invented-layout',
|
|
209
|
+
sourceId: component.id,
|
|
210
|
+
element: 'pipeline',
|
|
211
|
+
messageKey: R.noGeometry[0],
|
|
212
|
+
message: R.noGeometry[1],
|
|
213
|
+
}));
|
|
214
|
+
const handle = centreOf(boxOf(byLocal(built, `${local}:handle`)));
|
|
215
|
+
component.end = `${local}:handle`;
|
|
216
|
+
component.at = handle;
|
|
217
|
+
const label = byLocal(built, `${local}:label`);
|
|
218
|
+
const offset = labelOffsetOf(raw);
|
|
219
|
+
if (offset) {
|
|
220
|
+
// A pipeline's name is centred above its handle; an offset moves it
|
|
221
|
+
// by its difference from the renderer's default, as for any name.
|
|
222
|
+
const box = boxOf(label);
|
|
223
|
+
const shift = jsonLabelShift(plot, offset, canvas);
|
|
224
|
+
label.xywh = boxString({
|
|
225
|
+
...box,
|
|
226
|
+
x: box.x + shift.x,
|
|
227
|
+
y: box.y + shift.y,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
if (typeof raw.description === 'string' && raw.description.trim()) {
|
|
231
|
+
extras.push(jsonDescriptionElement(raw.description, descriptionSlot(boxOf(label)), `${local}:description`));
|
|
232
|
+
}
|
|
233
|
+
if (raw.color !== undefined) {
|
|
234
|
+
if (typeof raw.color === 'string') {
|
|
235
|
+
byLocal(built, `${local}:node`).strokeColor = rendererColour(raw.color);
|
|
236
|
+
}
|
|
237
|
+
else
|
|
238
|
+
drop('color');
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
const kind = component.kind;
|
|
243
|
+
const { w, h } = WARDLEY_NODE_SIZE[kind];
|
|
244
|
+
const at = { x: cx - w / 2, y: cy - h / 2 };
|
|
245
|
+
built =
|
|
246
|
+
kind === 'market'
|
|
247
|
+
? wardleyMarketElements(at, component.name, local)
|
|
248
|
+
: wardleyNodeElements(kind, at, component.name, local);
|
|
249
|
+
const node = byLocal(built, `${local}:node`);
|
|
250
|
+
const label = byLocal(built, `${local}:label`);
|
|
251
|
+
component.end = `${local}:node`;
|
|
252
|
+
component.at = { x: cx, y: cy };
|
|
253
|
+
/* The name, where the file moved it. */
|
|
254
|
+
const offset = labelOffsetOf(raw);
|
|
255
|
+
if (offset) {
|
|
256
|
+
const placed = jsonLabelBox(plot, w, cx, cy, offset, canvas);
|
|
257
|
+
const box = boxOf(label);
|
|
258
|
+
label.xywh = boxString({ ...box, x: placed.x, y: placed.y });
|
|
259
|
+
label.textAlign = placed.textAlign;
|
|
260
|
+
}
|
|
261
|
+
/* The node's own parameters. */
|
|
262
|
+
applyColour(component, node, drop);
|
|
263
|
+
applyNature(raw, kind, node, drop);
|
|
264
|
+
if (kind === 'method')
|
|
265
|
+
applyMethod(raw.method, node, drop);
|
|
266
|
+
else if (raw.method !== undefined)
|
|
267
|
+
drop('method');
|
|
268
|
+
if (raw.subtype !== undefined && !DRAWN_SUBTYPES.includes(raw.subtype)) {
|
|
269
|
+
// userNeed, solution, supplier, functional: a plain component, which
|
|
270
|
+
// is what each of them looks like here (PO, 2026-10-28).
|
|
271
|
+
if (raw.subtype !== 'functional')
|
|
272
|
+
drop('subtype');
|
|
273
|
+
}
|
|
274
|
+
if (raw.inertia === true)
|
|
275
|
+
drop('inertia');
|
|
276
|
+
/* What the toolbox has no item for: plain pieces in the group. */
|
|
277
|
+
if (typeof raw.description === 'string' && raw.description.trim()) {
|
|
278
|
+
extras.push(jsonDescriptionElement(raw.description, descriptionSlot(boxOf(label)), `${local}:description`));
|
|
279
|
+
}
|
|
280
|
+
const step = stepOf(raw.step);
|
|
281
|
+
if (step) {
|
|
282
|
+
extras.push(jsonStepElement(jsonStepCentre(kind, cx, cy), step.number, step.color === undefined ? undefined : rendererColour(step.color), `${local}:step`));
|
|
283
|
+
}
|
|
284
|
+
else if (raw.step !== undefined)
|
|
285
|
+
drop('step');
|
|
286
|
+
const range = rangeOf(raw);
|
|
287
|
+
if (range) {
|
|
288
|
+
const [left] = rendererPointOf(plot, range[0], visibility);
|
|
289
|
+
const [right] = rendererPointOf(plot, range[1], visibility);
|
|
290
|
+
under.push(jsonRangeElement(left, right, cy, `${local}:range`));
|
|
291
|
+
}
|
|
292
|
+
/* The climate arrows, where the renderer draws them. */
|
|
293
|
+
for (const which of ['accelerator', 'decelerator']) {
|
|
294
|
+
const flag = which === 'accelerator' ? 'accelerator' : 'deaccelerator';
|
|
295
|
+
if (raw[flag] !== true)
|
|
296
|
+
continue;
|
|
297
|
+
const centre = jsonDecoratorCentre(kind, which, cx, cy);
|
|
298
|
+
const size = WARDLEY_NODE_SIZE[which];
|
|
299
|
+
decorations.push(...wardleyNodeElements(which, { x: centre.cx - size.w / 2, y: centre.cy - size.h / 2 }, undefined, `${local}:${which}`));
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
pieces.push(...withExtras(built, `${local}:group`, under, extras));
|
|
303
|
+
/* Each evolution: the toolbar's arrow, dropped on the target point. */
|
|
304
|
+
const from = component.at;
|
|
305
|
+
component.evolutions.forEach((target, index) => {
|
|
306
|
+
const [tx, ty] = rendererPointOf(plot, target.evolution, target.visibility);
|
|
307
|
+
arrows.push({
|
|
308
|
+
...wardleyChangeArrowElement(component.end, component.end, target.evolveType),
|
|
309
|
+
target: { position: [tx, ty] },
|
|
310
|
+
});
|
|
311
|
+
if (target.inertia) {
|
|
312
|
+
inertiaCentres(plot, from, { x: tx, y: ty }, component.evolution, target.evolution).forEach((centre, n) => decorations.push(barAt(centre, `${local}:evolve-${index + 1}:inertia-${n + 1}`)));
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
if (component.invalidEvolutions > 0) {
|
|
316
|
+
warn(R.invalidEvolution, component.id, 'evolvesTo');
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
/* ── Relations ────────────────────────────────────────────────────── */
|
|
320
|
+
const relations = Array.isArray(root.relations)
|
|
321
|
+
? root.relations
|
|
322
|
+
: [];
|
|
323
|
+
const links = [];
|
|
324
|
+
for (const raw of relations) {
|
|
325
|
+
if (!isRecord(raw) ||
|
|
326
|
+
typeof raw.consumer !== 'string' ||
|
|
327
|
+
typeof raw.supplier !== 'string') {
|
|
328
|
+
warn(R.invalidRelation, undefined, 'relation');
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
const consumer = byId.get(raw.consumer);
|
|
332
|
+
const supplier = byId.get(raw.supplier);
|
|
333
|
+
if (!consumer || !supplier) {
|
|
334
|
+
for (const end of [raw.consumer, raw.supplier]) {
|
|
335
|
+
if (!byId.has(end))
|
|
336
|
+
warn(R.undeclaredEnd, end, 'relation');
|
|
337
|
+
}
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const type = raw.type === 'Flow' || raw.type === 'Constraint' ? raw.type : 'DependsOn';
|
|
341
|
+
const flow = isRecord(raw.flow) ? raw.flow : undefined;
|
|
342
|
+
const label = typeof flow?.label === 'string' ? flow.label : undefined;
|
|
343
|
+
links.push({
|
|
344
|
+
// `consumer` is the one that depends, so it is the SOURCE: the verb of
|
|
345
|
+
// `wardley:dependency` (ADR 0010), never inverted.
|
|
346
|
+
...wardleyDependencyElement(consumer.end, supplier.end, label),
|
|
347
|
+
...relationLook(type, flow?.style),
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
for (const [kind, count] of dropped) {
|
|
351
|
+
notes.push({
|
|
352
|
+
kind: 'warning',
|
|
353
|
+
element: kind,
|
|
354
|
+
messageKey: R.droppedField[0],
|
|
355
|
+
// Filled from the declared wording, so the sentence a host translates
|
|
356
|
+
// and the one a host with no catalogue reads cannot drift apart.
|
|
357
|
+
message: R.droppedField[1]
|
|
358
|
+
.replace('{{count}}', String(count))
|
|
359
|
+
.replace('{{kind}}', kind),
|
|
360
|
+
messageParams: { count, kind },
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
const elements = [
|
|
364
|
+
map,
|
|
365
|
+
...pieces,
|
|
366
|
+
...decorations,
|
|
367
|
+
...links,
|
|
368
|
+
...arrows,
|
|
369
|
+
...texts,
|
|
370
|
+
];
|
|
371
|
+
return {
|
|
372
|
+
elements,
|
|
373
|
+
report: {
|
|
374
|
+
mapped: texts.length + components.length + links.length + arrows.length,
|
|
375
|
+
// Nothing is kept that the canvas does not show (PO, 2026-10-28).
|
|
376
|
+
carried: 0,
|
|
377
|
+
quarantined: 0,
|
|
378
|
+
notes,
|
|
379
|
+
},
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
383
|
+
const finite = (value) => typeof value === 'number' && Number.isFinite(value);
|
|
384
|
+
const TOP_LEVEL = [
|
|
385
|
+
'title',
|
|
386
|
+
'context',
|
|
387
|
+
'components',
|
|
388
|
+
'relations',
|
|
389
|
+
'renderConfig',
|
|
390
|
+
];
|
|
391
|
+
/** The component fields something on the canvas shows (`id` and `position`
|
|
392
|
+
* place it; the pipeline's own position is dropped on its branch). */
|
|
393
|
+
const COMPONENT_FIELDS = [
|
|
394
|
+
'id',
|
|
395
|
+
'label',
|
|
396
|
+
'type',
|
|
397
|
+
'subtype',
|
|
398
|
+
'nature',
|
|
399
|
+
'position',
|
|
400
|
+
'description',
|
|
401
|
+
'evolvesTo',
|
|
402
|
+
'pipelineGeometry',
|
|
403
|
+
'color',
|
|
404
|
+
'method',
|
|
405
|
+
'inertia',
|
|
406
|
+
'accelerator',
|
|
407
|
+
'deaccelerator',
|
|
408
|
+
'step',
|
|
409
|
+
];
|
|
410
|
+
const DRAWN_SUBTYPES = ['market', 'ecosystem'];
|
|
411
|
+
/** `raw` as a drawable component, or `undefined` when it is not one. */
|
|
412
|
+
function drawable(raw) {
|
|
413
|
+
if (!isRecord(raw) || typeof raw.id !== 'string' || raw.id.length === 0) {
|
|
414
|
+
return undefined;
|
|
415
|
+
}
|
|
416
|
+
const label = raw.label;
|
|
417
|
+
if (!isRecord(label) || typeof label.name !== 'string')
|
|
418
|
+
return undefined;
|
|
419
|
+
const position = raw.position;
|
|
420
|
+
if (!isRecord(position))
|
|
421
|
+
return undefined;
|
|
422
|
+
const evolution = isRecord(position.evolution)
|
|
423
|
+
? position.evolution.scalar
|
|
424
|
+
: undefined;
|
|
425
|
+
const visibility = isRecord(position.visibility)
|
|
426
|
+
? position.visibility.scalar
|
|
427
|
+
: undefined;
|
|
428
|
+
if (!finite(evolution) || !finite(visibility))
|
|
429
|
+
return undefined;
|
|
430
|
+
const kind = kindOf(raw);
|
|
431
|
+
if (!kind)
|
|
432
|
+
return undefined;
|
|
433
|
+
const evolutions = [];
|
|
434
|
+
let invalidEvolutions = 0;
|
|
435
|
+
for (const entry of Array.isArray(raw.evolvesTo) ? raw.evolvesTo : []) {
|
|
436
|
+
const target = targetOf(entry);
|
|
437
|
+
if (target)
|
|
438
|
+
evolutions.push(target);
|
|
439
|
+
else
|
|
440
|
+
invalidEvolutions += 1;
|
|
441
|
+
}
|
|
442
|
+
return {
|
|
443
|
+
raw,
|
|
444
|
+
id: raw.id,
|
|
445
|
+
name: label.name,
|
|
446
|
+
kind,
|
|
447
|
+
evolution,
|
|
448
|
+
visibility,
|
|
449
|
+
evolutions,
|
|
450
|
+
invalidEvolutions,
|
|
451
|
+
local: raw.id,
|
|
452
|
+
end: '',
|
|
453
|
+
at: { x: 0, y: 0 },
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
/** The toolbar item a component is drawn as, or `undefined` for a foreign type. */
|
|
457
|
+
function kindOf(raw) {
|
|
458
|
+
switch (raw.type) {
|
|
459
|
+
case 'anchor':
|
|
460
|
+
return 'anchor';
|
|
461
|
+
case 'pipeline':
|
|
462
|
+
return 'pipeline';
|
|
463
|
+
case 'component':
|
|
464
|
+
if (raw.subtype === 'market')
|
|
465
|
+
return 'market';
|
|
466
|
+
if (raw.subtype === 'ecosystem')
|
|
467
|
+
return 'ecosystem';
|
|
468
|
+
return raw.method === undefined ? 'component' : 'method';
|
|
469
|
+
default:
|
|
470
|
+
return undefined;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
/** One `evolvesTo` entry as a target point, or `undefined` when it is not one. */
|
|
474
|
+
function targetOf(entry) {
|
|
475
|
+
if (!isRecord(entry) || !isRecord(entry.position))
|
|
476
|
+
return undefined;
|
|
477
|
+
const { evolution, visibility } = entry.position;
|
|
478
|
+
const e = isRecord(evolution) ? evolution.scalar : undefined;
|
|
479
|
+
const v = isRecord(visibility) ? visibility.scalar : undefined;
|
|
480
|
+
if (!finite(e) || !finite(v))
|
|
481
|
+
return undefined;
|
|
482
|
+
const evolveType = EVOLVE_TYPES.find(type => type === entry.evolveType);
|
|
483
|
+
return {
|
|
484
|
+
evolution: e,
|
|
485
|
+
visibility: v,
|
|
486
|
+
inertia: entry.inertia === true,
|
|
487
|
+
...(evolveType === undefined ? {} : { evolveType }),
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
const labelOffsetOf = (raw) => {
|
|
491
|
+
const position = isRecord(raw.label) ? raw.label.position : undefined;
|
|
492
|
+
if (!isRecord(position) || !finite(position.dx) || !finite(position.dy)) {
|
|
493
|
+
return undefined;
|
|
494
|
+
}
|
|
495
|
+
return { dx: position.dx, dy: position.dy };
|
|
496
|
+
};
|
|
497
|
+
const stepOf = (step) => {
|
|
498
|
+
if (!isRecord(step) || !finite(step.number))
|
|
499
|
+
return undefined;
|
|
500
|
+
return {
|
|
501
|
+
number: step.number,
|
|
502
|
+
...(typeof step.color === 'string' ? { color: step.color } : {}),
|
|
503
|
+
};
|
|
504
|
+
};
|
|
505
|
+
const rangeOf = (raw) => {
|
|
506
|
+
const position = isRecord(raw.position) ? raw.position : undefined;
|
|
507
|
+
const range = isRecord(position?.evolution)
|
|
508
|
+
? position.evolution.range
|
|
509
|
+
: undefined;
|
|
510
|
+
if (!Array.isArray(range) || !finite(range[0]) || !finite(range[1])) {
|
|
511
|
+
return undefined;
|
|
512
|
+
}
|
|
513
|
+
return [range[0], range[1]];
|
|
514
|
+
};
|
|
515
|
+
/* ── A node's parameters ──────────────────────────────────────────────── */
|
|
516
|
+
function applyColour(component, node, drop) {
|
|
517
|
+
const color = component.raw.color;
|
|
518
|
+
if (color === undefined)
|
|
519
|
+
return;
|
|
520
|
+
// A method node's ring is painted from its method tag (ADR 0046), so a
|
|
521
|
+
// `color` on one has no paint left to take.
|
|
522
|
+
if (typeof color !== 'string' || component.kind === 'method') {
|
|
523
|
+
drop('color');
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
node.strokeColor = rendererColour(color);
|
|
527
|
+
}
|
|
528
|
+
function applyNature(raw, kind, node, drop) {
|
|
529
|
+
if (raw.nature === undefined)
|
|
530
|
+
return;
|
|
531
|
+
const nature = LABRE_NATURES.find(value => value === raw.nature);
|
|
532
|
+
// The tag qualifies a component; an anchor has no nature here, and the
|
|
533
|
+
// renderer's five other natures have no value (PO, 2026-10-28).
|
|
534
|
+
if (nature === undefined || kind === 'anchor') {
|
|
535
|
+
drop('nature');
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
tag(node, WARDLEY_NATURE_TAG_ID, `${WARDLEY_NATURE_TAG_ID}/${nature}`);
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* The file's `method` as the method node's `wardley:method` tag (ADR 0046).
|
|
542
|
+
* Nothing about its look is written: the ring is derived from the tag when it
|
|
543
|
+
* is painted. A pair that names none of the nine (a category a renderer config
|
|
544
|
+
* made up, a recommendation outside its three) leaves the node untagged, which
|
|
545
|
+
* paints as Make, and the report says the method was dropped.
|
|
546
|
+
*/
|
|
547
|
+
function applyMethod(method, node, drop) {
|
|
548
|
+
const { category, recommendation } = isRecord(method) ? method : {};
|
|
549
|
+
const value = wardleyMethodOf(typeof category === 'string' ? category : null, typeof recommendation === 'string' ? recommendation : null);
|
|
550
|
+
if (value === null)
|
|
551
|
+
drop('method');
|
|
552
|
+
else
|
|
553
|
+
tag(node, WARDLEY_METHOD_TAG_ID, value);
|
|
554
|
+
}
|
|
555
|
+
/** One more tag value on a node's `tags`, as `surface.addElement` takes them. */
|
|
556
|
+
function tag(node, tagId, value) {
|
|
557
|
+
node.tags = { ...node.tags, [tagId]: [value] };
|
|
558
|
+
}
|
|
559
|
+
/** The map's own fields a `renderConfig` sets; the rest of it is dropped. */
|
|
560
|
+
function mapFieldsOf(config, drop) {
|
|
561
|
+
if (config === undefined)
|
|
562
|
+
return {};
|
|
563
|
+
if (!isRecord(config)) {
|
|
564
|
+
drop('renderConfig');
|
|
565
|
+
return {};
|
|
566
|
+
}
|
|
567
|
+
const out = {};
|
|
568
|
+
let used = 0;
|
|
569
|
+
const display = isRecord(config.display) ? config.display : {};
|
|
570
|
+
if (display.axisEvolution === false)
|
|
571
|
+
out.showXAxis = false;
|
|
572
|
+
if (display.axisValueChain === false)
|
|
573
|
+
out.showYAxis = false;
|
|
574
|
+
if (display.phases === false) {
|
|
575
|
+
out.showColumnDividers = false;
|
|
576
|
+
out.showColumnLabels = false;
|
|
577
|
+
}
|
|
578
|
+
for (const key of ['axisEvolution', 'axisValueChain', 'phases', 'title']) {
|
|
579
|
+
if (display[key] !== undefined)
|
|
580
|
+
used += 1;
|
|
581
|
+
}
|
|
582
|
+
const background = isRecord(config.style)
|
|
583
|
+
? config.style.background
|
|
584
|
+
: undefined;
|
|
585
|
+
const facet = (name) => {
|
|
586
|
+
const element = isRecord(background) ? background[name] : undefined;
|
|
587
|
+
if (!isRecord(element))
|
|
588
|
+
return undefined;
|
|
589
|
+
const pick = (side) => (isRecord(side) ? side : {});
|
|
590
|
+
return { ...pick(element.default), ...pick(element.override) };
|
|
591
|
+
};
|
|
592
|
+
const axisText = (name) => {
|
|
593
|
+
const label = facet(name)?.label;
|
|
594
|
+
return isRecord(label) && typeof label.text === 'string'
|
|
595
|
+
? label.text
|
|
596
|
+
: undefined;
|
|
597
|
+
};
|
|
598
|
+
const x = axisText('axisEvolution');
|
|
599
|
+
const y = axisText('axisValueChain');
|
|
600
|
+
if (x !== undefined)
|
|
601
|
+
(out.xAxisTitle = x), (used += 1);
|
|
602
|
+
if (y !== undefined)
|
|
603
|
+
(out.yAxisTitle = y), (used += 1);
|
|
604
|
+
const phases = facet('phases')?.labels;
|
|
605
|
+
if (Array.isArray(phases)) {
|
|
606
|
+
phases.slice(0, 4).forEach((label, index) => {
|
|
607
|
+
if (isRecord(label) && typeof label.text === 'string') {
|
|
608
|
+
out[`phase${index}`] = label.text;
|
|
609
|
+
used += 1;
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
// The canvas size is read too: it is what a name's offset is measured on
|
|
614
|
+
// (`rendererCanvasOf`).
|
|
615
|
+
const style = isRecord(config.style) ? config.style : {};
|
|
616
|
+
const canvas = isRecord(background) ? background.canvas : undefined;
|
|
617
|
+
for (const element of [style.view, canvas]) {
|
|
618
|
+
if (!isRecord(element))
|
|
619
|
+
continue;
|
|
620
|
+
for (const side of [element.default, element.override]) {
|
|
621
|
+
if (!isRecord(side))
|
|
622
|
+
continue;
|
|
623
|
+
for (const key of ['width', 'height']) {
|
|
624
|
+
if (side[key] !== undefined)
|
|
625
|
+
used += 1;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
if (leafCount(config) > used)
|
|
630
|
+
drop('renderConfig');
|
|
631
|
+
return out;
|
|
632
|
+
}
|
|
633
|
+
const leafCount = (value) => isRecord(value)
|
|
634
|
+
? Object.values(value).reduce((n, v) => n + leafCount(v), 0)
|
|
635
|
+
: Array.isArray(value)
|
|
636
|
+
? value.reduce((n, v) => n + leafCount(v), 0)
|
|
637
|
+
: 1;
|
|
638
|
+
/* ── The pieces ───────────────────────────────────────────────────────── */
|
|
639
|
+
/** A usable `pipelineGeometry`, or `undefined`. */
|
|
640
|
+
function geometryOf(raw) {
|
|
641
|
+
const geometry = raw.pipelineGeometry;
|
|
642
|
+
if (!isRecord(geometry))
|
|
643
|
+
return undefined;
|
|
644
|
+
const { evoStart, evoEnd, visStart, visEnd, handleEvolution } = geometry;
|
|
645
|
+
if (![evoStart, evoEnd, visStart, visEnd].every(finite))
|
|
646
|
+
return undefined;
|
|
647
|
+
if (handleEvolution !== undefined && !finite(handleEvolution)) {
|
|
648
|
+
return undefined;
|
|
649
|
+
}
|
|
650
|
+
return {
|
|
651
|
+
evoStart: evoStart,
|
|
652
|
+
evoEnd: evoEnd,
|
|
653
|
+
visStart: visStart,
|
|
654
|
+
visEnd: visEnd,
|
|
655
|
+
...(handleEvolution === undefined ? {} : { handleEvolution }),
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* The pipeline item, as the toolbar builds it, around the handle the file
|
|
660
|
+
* places; its body then takes the geometry's box, the one gesture a user makes
|
|
661
|
+
* after placing one. A pipeline with no usable geometry is the toolbar's item
|
|
662
|
+
* centred on the component's position, and the report says so. Its own
|
|
663
|
+
* `position` places nothing else: the renderer draws the geometry.
|
|
664
|
+
*/
|
|
665
|
+
function pipelineOf(component, plot, cx, cy, onInvented) {
|
|
666
|
+
const geometry = geometryOf(component.raw);
|
|
667
|
+
const { w, h } = WARDLEY_NODE_SIZE.pipeline;
|
|
668
|
+
if (!geometry) {
|
|
669
|
+
onInvented();
|
|
670
|
+
return wardleyPipelineElements({ x: cx - w / 2, y: cy - h / 2 }, component.name, component.local);
|
|
671
|
+
}
|
|
672
|
+
const [x1, y1] = rendererPointOf(plot, geometry.evoStart, geometry.visStart);
|
|
673
|
+
const [x2, y2] = rendererPointOf(plot, geometry.evoEnd, geometry.visEnd);
|
|
674
|
+
const [handleX] = rendererPointOf(plot, geometry.handleEvolution ?? (geometry.evoStart + geometry.evoEnd) / 2, 0);
|
|
675
|
+
const top = Math.min(y1, y2);
|
|
676
|
+
const built = wardleyPipelineElements({ x: handleX - PIPELINE_WIDTH / 2, y: top }, component.name, component.local);
|
|
677
|
+
byLocal(built, `${component.local}:node`).xywh = boxString({
|
|
678
|
+
x: Math.min(x1, x2),
|
|
679
|
+
y: top,
|
|
680
|
+
w: Math.max(Math.abs(x2 - x1), 1),
|
|
681
|
+
h: Math.max(Math.abs(y2 - y1), 1),
|
|
682
|
+
});
|
|
683
|
+
return built;
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Where an evolution's inertia goes: on every phase line the arrow crosses,
|
|
687
|
+
* as the renderer draws it, and at the arrow's middle when it crosses none —
|
|
688
|
+
* "if there is an inertia bar, draw it" (PO, 2026-10-28).
|
|
689
|
+
*/
|
|
690
|
+
function inertiaCentres(plot, from, to, fromEvolution, toEvolution) {
|
|
691
|
+
const low = Math.min(fromEvolution, toEvolution);
|
|
692
|
+
const high = Math.max(fromEvolution, toEvolution);
|
|
693
|
+
const crossed = PHASE_LINES.filter(line => line > low && line < high);
|
|
694
|
+
if (crossed.length === 0) {
|
|
695
|
+
return [{ cx: (from.x + to.x) / 2, cy: (from.y + to.y) / 2 }];
|
|
696
|
+
}
|
|
697
|
+
return crossed.map(line => {
|
|
698
|
+
const t = (line - fromEvolution) / (toEvolution - fromEvolution);
|
|
699
|
+
const [x] = rendererPointOf(plot, line, 0);
|
|
700
|
+
return { cx: x, cy: from.y + t * (to.y - from.y) };
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
/** The toolbar's inertia bar, centred on a point. */
|
|
704
|
+
function barAt(centre, local) {
|
|
705
|
+
return wardleyInertiaElement({
|
|
706
|
+
x: centre.cx - INERTIA_SIZE.w / 2,
|
|
707
|
+
y: centre.cy - INERTIA_SIZE.h / 2,
|
|
708
|
+
}, local);
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* A builder's elements with a component's own plain pieces joining its OUTER
|
|
712
|
+
* group, so its description, step dot and range bar move with it. Members
|
|
713
|
+
* come before their group (ADR 0012 D3); `under` comes first of all, so the
|
|
714
|
+
* range bar is painted beneath the node.
|
|
715
|
+
*/
|
|
716
|
+
function withExtras(built, groupId, under, extras) {
|
|
717
|
+
const group = byLocal(built, groupId);
|
|
718
|
+
const joined = [...under, ...extras].map(piece => [piece.id, true]);
|
|
719
|
+
return [
|
|
720
|
+
...under,
|
|
721
|
+
...built.filter(element => element !== group),
|
|
722
|
+
...extras,
|
|
723
|
+
{
|
|
724
|
+
...group,
|
|
725
|
+
children: {
|
|
726
|
+
...group.children,
|
|
727
|
+
...Object.fromEntries(joined),
|
|
728
|
+
},
|
|
729
|
+
},
|
|
730
|
+
];
|
|
731
|
+
}
|
|
732
|
+
function byLocal(built, id) {
|
|
733
|
+
const found = built.find(element => element.id === id);
|
|
734
|
+
// The builders name their pieces; a missing one is this file's bug.
|
|
735
|
+
if (!found)
|
|
736
|
+
throw new Error(`toolbox built no ${id}`);
|
|
737
|
+
return found;
|
|
738
|
+
}
|
|
739
|
+
function boxOf(element) {
|
|
740
|
+
const [x, y, w, h] = JSON.parse(String(element.xywh));
|
|
741
|
+
return { x, y, w, h };
|
|
742
|
+
}
|
|
743
|
+
const centreOf = (box) => ({
|
|
744
|
+
x: box.x + box.w / 2,
|
|
745
|
+
y: box.y + box.h / 2,
|
|
746
|
+
});
|
|
747
|
+
const boxString = (box) => `[${box.x},${box.y},${box.w},${box.h}]`;
|
|
748
|
+
/** The empty import a refusal is, with its one sentence. */
|
|
749
|
+
function refused([key, message]) {
|
|
750
|
+
return {
|
|
751
|
+
elements: [],
|
|
752
|
+
report: {
|
|
753
|
+
mapped: 0,
|
|
754
|
+
carried: 0,
|
|
755
|
+
quarantined: 0,
|
|
756
|
+
notes: [{ kind: 'warning', messageKey: key, message }],
|
|
757
|
+
},
|
|
758
|
+
};
|
|
759
|
+
}
|