@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,669 @@
|
|
|
1
|
+
import { Bound } from '@formicoidea/labre-core/global/gfx';
|
|
2
|
+
import { WARDLEY_BACKGROUND } from './background.js';
|
|
3
|
+
import { matchLabels, owmDefaultPlot, owmPlotOf } from './export.js';
|
|
4
|
+
import { wardleyMethodParts } from './method.js';
|
|
5
|
+
import { WARDLEY_METHOD_TAG_ID, WARDLEY_NATURE_TAG_ID } from './natures.js';
|
|
6
|
+
import { HANDLE_SIZE, PIPELINE_HEIGHT } from './node/consts.js';
|
|
7
|
+
import { wardleyLabelBoxFor, WARDLEY_NODE_SIZE, wardleyNodeProps, } from './presets.js';
|
|
8
|
+
import { descriptionSlot, EVOLVE_TYPES, isDefaultLabelOffset, isRangeShape, jsonDecoratorCentre, jsonLabelOffset, jsonLabelUnshift, jsonStepCentre, LABRE_NATURES, relationOf, rendererColourName, rendererCoordsOf, round3, sameColour, } from './renderer-json.js';
|
|
9
|
+
import { WARDLEY_ROLE } from './roles.js';
|
|
10
|
+
/** How far a piece may sit from its slot and still belong to the node. */
|
|
11
|
+
const SLOT_TOLERANCE = 24;
|
|
12
|
+
/** How far an inertia bar may sit off an evolution arrow and still be on it. */
|
|
13
|
+
const ARROW_TOLERANCE = 12;
|
|
14
|
+
/** How far above or under the map a title or a context may sit. */
|
|
15
|
+
const MARGIN_REACH = 120;
|
|
16
|
+
const COMPONENT_KINDS = [
|
|
17
|
+
'anchor',
|
|
18
|
+
'component',
|
|
19
|
+
'method',
|
|
20
|
+
'market',
|
|
21
|
+
'ecosystem',
|
|
22
|
+
];
|
|
23
|
+
export function exportWardleyRendererJson(input, options = {}) {
|
|
24
|
+
const elements = input;
|
|
25
|
+
const warnings = [];
|
|
26
|
+
/* ── The map ──────────────────────────────────────────────────────── */
|
|
27
|
+
const maps = elements.filter(e => e.type === WARDLEY_BACKGROUND.type);
|
|
28
|
+
const map = maps[0];
|
|
29
|
+
if (maps.length > 1) {
|
|
30
|
+
warnings.push(`This board holds ${maps.length} Wardley maps and a renderer map is one. Everything was measured against the first map in the document.`);
|
|
31
|
+
}
|
|
32
|
+
if (!map) {
|
|
33
|
+
warnings.push('No Wardley map background was found, so coordinates were read against a default map at the origin.');
|
|
34
|
+
}
|
|
35
|
+
const plot = map ? owmPlotOf(boxOf(map)) : owmDefaultPlot();
|
|
36
|
+
/* ── What is on the board ─────────────────────────────────────────── */
|
|
37
|
+
const nodes = elements.filter(e => e.type === 'wardleyNode' && e.role !== undefined);
|
|
38
|
+
const nodeById = new Map(nodes.map(node => [node.id, node]));
|
|
39
|
+
const labels = elements.filter(e => e.type === 'text' && e.role === WARDLEY_ROLE.label);
|
|
40
|
+
const plainTexts = elements.filter(e => e.type === 'text' && e.role === undefined);
|
|
41
|
+
const plainShapes = elements.filter(e => e.type === 'shape' && e.role === undefined);
|
|
42
|
+
const connectors = elements.filter(e => e.type === 'connector');
|
|
43
|
+
const bars = elements.filter(e => e.role === WARDLEY_ROLE.inertia);
|
|
44
|
+
const groups = elements.filter(e => e.type === 'group');
|
|
45
|
+
const byId = new Map(elements.map(e => [e.id, e]));
|
|
46
|
+
const written = nodes.filter(n => COMPONENT_KINDS.includes(n.kind ?? '') || n.kind === 'pipeline');
|
|
47
|
+
const pipelines = written.filter(n => n.kind === 'pipeline');
|
|
48
|
+
const climate = nodes.filter(n => n.kind === 'accelerator' || n.kind === 'decelerator');
|
|
49
|
+
const handles = nodes.filter(n => n.kind === 'handle');
|
|
50
|
+
const claimed = new Set();
|
|
51
|
+
/* ── Title and context: the plain texts just above and under the map ─ */
|
|
52
|
+
let title;
|
|
53
|
+
let mapContext;
|
|
54
|
+
if (map) {
|
|
55
|
+
const box = boxOf(map);
|
|
56
|
+
const beside = (text) => {
|
|
57
|
+
const b = boxOf(text);
|
|
58
|
+
return b.x < box.x + box.w && b.x + b.w > box.x;
|
|
59
|
+
};
|
|
60
|
+
const above = nearest(plainTexts.filter(t => beside(t) && boxOf(t).y + boxOf(t).h <= box.y + 1), t => box.y - (boxOf(t).y + boxOf(t).h));
|
|
61
|
+
if (above && above.distance <= MARGIN_REACH) {
|
|
62
|
+
title = textOf(above.item);
|
|
63
|
+
claimed.add(above.item);
|
|
64
|
+
}
|
|
65
|
+
const under = nearest(plainTexts.filter(t => beside(t) && boxOf(t).y >= box.y + box.h - 1), t => boxOf(t).y - (box.y + box.h));
|
|
66
|
+
if (under && under.distance <= MARGIN_REACH) {
|
|
67
|
+
mapContext = textOf(under.item);
|
|
68
|
+
claimed.add(under.item);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/* ── The pipeline a handle sits on: astride its body's top edge ───── */
|
|
72
|
+
const ownerOfHandle = new Map();
|
|
73
|
+
const handleOf = new Map();
|
|
74
|
+
for (const handle of handles) {
|
|
75
|
+
const h = centreOf(boxOf(handle));
|
|
76
|
+
const body = pipelines.find(p => {
|
|
77
|
+
const b = boxOf(p);
|
|
78
|
+
return (h.x >= b.x - 1 &&
|
|
79
|
+
h.x <= b.x + b.w + 1 &&
|
|
80
|
+
Math.abs(h.y - b.y) <= HANDLE_SIZE / 2 + 1);
|
|
81
|
+
});
|
|
82
|
+
if (body && !handleOf.has(body.id)) {
|
|
83
|
+
ownerOfHandle.set(handle.id, body);
|
|
84
|
+
handleOf.set(body.id, handle);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/* ── A component's own pieces: its group first ────────────────────── */
|
|
88
|
+
const parentOf = new Map();
|
|
89
|
+
for (const group of groups) {
|
|
90
|
+
for (const child of childIdsOf(group)) {
|
|
91
|
+
if (!parentOf.has(child))
|
|
92
|
+
parentOf.set(child, group);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Everything in the node's group, when that group is the node's own. */
|
|
96
|
+
const ownGroup = (node) => {
|
|
97
|
+
const group = parentOf.get(node.id);
|
|
98
|
+
if (!group)
|
|
99
|
+
return [];
|
|
100
|
+
const members = [];
|
|
101
|
+
const walk = (g) => {
|
|
102
|
+
for (const id of childIdsOf(g)) {
|
|
103
|
+
const child = byId.get(id);
|
|
104
|
+
if (!child)
|
|
105
|
+
continue;
|
|
106
|
+
if (child.type === 'group')
|
|
107
|
+
walk(child);
|
|
108
|
+
else
|
|
109
|
+
members.push(child);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
walk(group);
|
|
113
|
+
// A group a user made around several components is not one component's.
|
|
114
|
+
const artefacts = members.filter(m => m.type === 'wardleyNode' && m.role !== undefined && m.kind !== 'handle');
|
|
115
|
+
return artefacts.length === 1 ? members : [];
|
|
116
|
+
};
|
|
117
|
+
const piecesOf = new Map();
|
|
118
|
+
for (const node of [...written, ...climate]) {
|
|
119
|
+
const pieces = {};
|
|
120
|
+
for (const member of ownGroup(node)) {
|
|
121
|
+
if (member === node || claimed.has(member))
|
|
122
|
+
continue;
|
|
123
|
+
if (member.type === 'text' && member.role === WARDLEY_ROLE.label) {
|
|
124
|
+
pieces.label ??= member;
|
|
125
|
+
}
|
|
126
|
+
else if (member.type === 'text' && member.role === undefined) {
|
|
127
|
+
pieces.description ??= member;
|
|
128
|
+
}
|
|
129
|
+
else if (isStep(member)) {
|
|
130
|
+
pieces.step ??= member;
|
|
131
|
+
}
|
|
132
|
+
else if (isRange(member)) {
|
|
133
|
+
pieces.range ??= member;
|
|
134
|
+
}
|
|
135
|
+
else
|
|
136
|
+
continue;
|
|
137
|
+
claimed.add(member);
|
|
138
|
+
}
|
|
139
|
+
piecesOf.set(node.id, pieces);
|
|
140
|
+
}
|
|
141
|
+
/* ── Names left ungrouped: by place ───────────────────────────────── */
|
|
142
|
+
for (const pipeline of pipelines) {
|
|
143
|
+
const pieces = piecesOf.get(pipeline.id);
|
|
144
|
+
if (pieces.label)
|
|
145
|
+
continue;
|
|
146
|
+
const slot = pipelineLabelSlot(pipeline, handleOf.get(pipeline.id));
|
|
147
|
+
const found = nearest(labels.filter(l => !claimed.has(l)), l => Math.hypot(boxOf(l).x - slot.x, boxOf(l).y - slot.y));
|
|
148
|
+
if (found && found.distance <= SLOT_TOLERANCE) {
|
|
149
|
+
pieces.label = found.item;
|
|
150
|
+
claimed.add(found.item);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const unnamedNodes = [...written, ...climate].filter(n => n.kind !== 'pipeline' && !piecesOf.get(n.id)?.label);
|
|
154
|
+
const freeLabels = labels.filter(l => !claimed.has(l));
|
|
155
|
+
const matched = matchLabels(unnamedNodes.map(node => ({
|
|
156
|
+
id: node.id,
|
|
157
|
+
kind: node.kind ?? '',
|
|
158
|
+
role: node.role,
|
|
159
|
+
elementBound: boxOf(node),
|
|
160
|
+
})),
|
|
161
|
+
// The matcher answers with each label's text; handing it the label's id
|
|
162
|
+
// as its text gets the element back.
|
|
163
|
+
freeLabels.map(label => ({ text: label.id, elementBound: boxOf(label) })));
|
|
164
|
+
for (const [nodeId, labelId] of matched) {
|
|
165
|
+
const label = byId.get(labelId);
|
|
166
|
+
if (!label)
|
|
167
|
+
continue;
|
|
168
|
+
piecesOf.get(nodeId).label = label;
|
|
169
|
+
claimed.add(label);
|
|
170
|
+
}
|
|
171
|
+
/* ── The other pieces left ungrouped: by place ────────────────────── */
|
|
172
|
+
for (const node of written) {
|
|
173
|
+
if (node.kind === 'pipeline')
|
|
174
|
+
continue;
|
|
175
|
+
const pieces = piecesOf.get(node.id);
|
|
176
|
+
const c = centreOf(boxOf(node));
|
|
177
|
+
const kind = node.kind;
|
|
178
|
+
if (!pieces.description && pieces.label) {
|
|
179
|
+
const slot = descriptionSlot(boxOf(pieces.label));
|
|
180
|
+
const found = nearest(plainTexts.filter(t => !claimed.has(t)), t => Math.hypot(boxOf(t).x - slot.x, boxOf(t).y - slot.y));
|
|
181
|
+
if (found && found.distance <= SLOT_TOLERANCE / 2) {
|
|
182
|
+
pieces.description = found.item;
|
|
183
|
+
claimed.add(found.item);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (!pieces.step) {
|
|
187
|
+
const slot = jsonStepCentre(kind, c.x, c.y);
|
|
188
|
+
const found = nearest(plainShapes.filter(s => !claimed.has(s) && isStep(s)), s => distanceTo(centreOf(boxOf(s)), slot));
|
|
189
|
+
if (found && found.distance <= SLOT_TOLERANCE / 2) {
|
|
190
|
+
pieces.step = found.item;
|
|
191
|
+
claimed.add(found.item);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (!pieces.range) {
|
|
195
|
+
const found = plainShapes.find(s => {
|
|
196
|
+
if (claimed.has(s) || !isRange(s))
|
|
197
|
+
return false;
|
|
198
|
+
const b = boxOf(s);
|
|
199
|
+
return (Math.abs(b.y + b.h / 2 - c.y) <= 2 && b.x <= c.x && c.x <= b.x + b.w);
|
|
200
|
+
});
|
|
201
|
+
if (found) {
|
|
202
|
+
pieces.range = found;
|
|
203
|
+
claimed.add(found);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/* ── Ids: Labre's, minted from the names ──────────────────────────── */
|
|
208
|
+
const nameOf = (node) => {
|
|
209
|
+
const label = piecesOf.get(node.id)?.label;
|
|
210
|
+
return label ? textOf(label) : '';
|
|
211
|
+
};
|
|
212
|
+
const used = new Set();
|
|
213
|
+
const idOf = new Map();
|
|
214
|
+
for (const node of written) {
|
|
215
|
+
const stem = slug(nameOf(node)) || node.id;
|
|
216
|
+
let id = stem;
|
|
217
|
+
for (let n = 2; used.has(id); n += 1)
|
|
218
|
+
id = `${stem}-${n}`;
|
|
219
|
+
used.add(id);
|
|
220
|
+
idOf.set(node.id, id);
|
|
221
|
+
}
|
|
222
|
+
/** The component an edge end names: a node, or the pipeline of a handle. */
|
|
223
|
+
const componentAt = (end) => {
|
|
224
|
+
const node = end?.id === undefined ? undefined : nodeById.get(end.id);
|
|
225
|
+
if (!node)
|
|
226
|
+
return undefined;
|
|
227
|
+
if (node.kind === 'handle')
|
|
228
|
+
return ownerOfHandle.get(node.id);
|
|
229
|
+
return idOf.has(node.id) ? node : undefined;
|
|
230
|
+
};
|
|
231
|
+
const arrows = [];
|
|
232
|
+
let looseArrows = 0;
|
|
233
|
+
for (const connector of connectors) {
|
|
234
|
+
if (connector.role !== WARDLEY_ROLE.changeArrow)
|
|
235
|
+
continue;
|
|
236
|
+
const from = componentAt(connector.source);
|
|
237
|
+
const sourceNode = connector.source?.id === undefined
|
|
238
|
+
? undefined
|
|
239
|
+
: nodeById.get(connector.source.id);
|
|
240
|
+
const end = pointOf(connector.target, nodeById);
|
|
241
|
+
if (!from || !sourceNode || !end) {
|
|
242
|
+
looseArrows += 1;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const label = textOf(connector).trim().toLowerCase();
|
|
246
|
+
const evolveType = EVOLVE_TYPES.find(type => type === label);
|
|
247
|
+
arrows.push({
|
|
248
|
+
from,
|
|
249
|
+
start: centreOf(boxOf(sourceNode)),
|
|
250
|
+
end,
|
|
251
|
+
...(evolveType === undefined ? {} : { evolveType }),
|
|
252
|
+
inertia: false,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
/* ── Decorators: which slot each one is in ────────────────────────── */
|
|
256
|
+
const decorated = new Map();
|
|
257
|
+
let looseClimate = 0;
|
|
258
|
+
for (const arrow of climate) {
|
|
259
|
+
const which = arrow.kind;
|
|
260
|
+
const at = centreOf(boxOf(arrow));
|
|
261
|
+
const owner = nearest(written.filter(n => n.kind !== 'pipeline'), node => {
|
|
262
|
+
const c = centreOf(boxOf(node));
|
|
263
|
+
const slot = jsonDecoratorCentre(node.kind, which, c.x, c.y);
|
|
264
|
+
return distanceTo(at, slot);
|
|
265
|
+
});
|
|
266
|
+
if (owner && owner.distance <= SLOT_TOLERANCE) {
|
|
267
|
+
const set = decorated.get(owner.item.id) ?? new Set();
|
|
268
|
+
set.add(which === 'accelerator' ? 'accelerator' : 'deaccelerator');
|
|
269
|
+
decorated.set(owner.item.id, set);
|
|
270
|
+
}
|
|
271
|
+
else
|
|
272
|
+
looseClimate += 1;
|
|
273
|
+
}
|
|
274
|
+
let looseBars = 0;
|
|
275
|
+
for (const bar of bars) {
|
|
276
|
+
const at = centreOf(boxOf(bar));
|
|
277
|
+
const onArrow = nearest(arrows, arrow => segmentDistance(at, arrow.start, arrow.end));
|
|
278
|
+
if (onArrow && onArrow.distance <= ARROW_TOLERANCE) {
|
|
279
|
+
onArrow.item.inertia = true;
|
|
280
|
+
}
|
|
281
|
+
else
|
|
282
|
+
looseBars += 1;
|
|
283
|
+
}
|
|
284
|
+
/* ── Components ───────────────────────────────────────────────────── */
|
|
285
|
+
let clamped = 0;
|
|
286
|
+
const clamp = (value) => {
|
|
287
|
+
const bounded = Math.min(1, Math.max(0, value));
|
|
288
|
+
if (round3(bounded) !== round3(value))
|
|
289
|
+
clamped += 1;
|
|
290
|
+
return round3(bounded);
|
|
291
|
+
};
|
|
292
|
+
const coords = (x, y) => {
|
|
293
|
+
const { evolution, visibility } = rendererCoordsOf(plot, x, y);
|
|
294
|
+
return { evolution: clamp(evolution), visibility: clamp(visibility) };
|
|
295
|
+
};
|
|
296
|
+
const unnamed = [];
|
|
297
|
+
const unqualified = [];
|
|
298
|
+
const components = written.map(node => {
|
|
299
|
+
const id = idOf.get(node.id);
|
|
300
|
+
const pieces = piecesOf.get(node.id);
|
|
301
|
+
const name = nameOf(node);
|
|
302
|
+
if (name.length === 0)
|
|
303
|
+
unnamed.push(id);
|
|
304
|
+
const box = boxOf(node);
|
|
305
|
+
const centre = centreOf(box);
|
|
306
|
+
const at = coords(centre.x, centre.y);
|
|
307
|
+
const kind = node.kind ?? 'component';
|
|
308
|
+
const out = { id, label: { name } };
|
|
309
|
+
/*
|
|
310
|
+
* The name's offset, when it is not where the toolbar puts it. In the
|
|
311
|
+
* pixels of the renderer's DEFAULT canvas: the file written declares none,
|
|
312
|
+
* so that is the canvas it is drawn on, and the name lands where it is
|
|
313
|
+
* here, relative to the plot.
|
|
314
|
+
*/
|
|
315
|
+
if (pieces.label) {
|
|
316
|
+
const offset = kind === 'pipeline'
|
|
317
|
+
? pipelineLabelOffset(plot, node, handleOf.get(node.id), pieces.label)
|
|
318
|
+
: jsonLabelOffset(plot, WARDLEY_NODE_SIZE[kind].w, centre.x, centre.y, boxOf(pieces.label), pieces.label.textAlign);
|
|
319
|
+
if (!isDefaultLabelOffset(offset)) {
|
|
320
|
+
out.label.position = offset;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/* Type, subtype, nature. */
|
|
324
|
+
const nature = natureOf(node);
|
|
325
|
+
if (kind === 'anchor')
|
|
326
|
+
out.type = 'anchor';
|
|
327
|
+
else if (kind === 'pipeline')
|
|
328
|
+
out.type = 'pipeline';
|
|
329
|
+
else
|
|
330
|
+
out.type = 'component';
|
|
331
|
+
if (kind === 'market' || kind === 'ecosystem')
|
|
332
|
+
out.subtype = kind;
|
|
333
|
+
if (nature !== undefined) {
|
|
334
|
+
// The renderer accepts these four natures on a `functional` component
|
|
335
|
+
// only, so the subtype is written with them.
|
|
336
|
+
if (kind === 'component' || kind === 'method') {
|
|
337
|
+
out.subtype = 'functional';
|
|
338
|
+
out.nature = nature;
|
|
339
|
+
}
|
|
340
|
+
else
|
|
341
|
+
unqualified.push(id);
|
|
342
|
+
}
|
|
343
|
+
out.position = {
|
|
344
|
+
evolution: { scalar: at.evolution },
|
|
345
|
+
visibility: { scalar: at.visibility },
|
|
346
|
+
};
|
|
347
|
+
if (pieces.range) {
|
|
348
|
+
const r = boxOf(pieces.range);
|
|
349
|
+
out.position.evolution = {
|
|
350
|
+
scalar: at.evolution,
|
|
351
|
+
range: [coords(r.x, r.y).evolution, coords(r.x + r.w, r.y).evolution],
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
if (pieces.description) {
|
|
355
|
+
const text = textOf(pieces.description);
|
|
356
|
+
if (text.trim())
|
|
357
|
+
out.description = text;
|
|
358
|
+
}
|
|
359
|
+
const evolutions = arrows
|
|
360
|
+
.filter(arrow => arrow.from === node)
|
|
361
|
+
.map(arrow => {
|
|
362
|
+
const target = coords(arrow.end.x, arrow.end.y);
|
|
363
|
+
return {
|
|
364
|
+
position: {
|
|
365
|
+
evolution: { scalar: target.evolution },
|
|
366
|
+
visibility: { scalar: target.visibility },
|
|
367
|
+
},
|
|
368
|
+
...(arrow.evolveType === undefined
|
|
369
|
+
? {}
|
|
370
|
+
: { evolveType: arrow.evolveType }),
|
|
371
|
+
...(arrow.inertia ? { inertia: true } : {}),
|
|
372
|
+
};
|
|
373
|
+
});
|
|
374
|
+
if (evolutions.length > 0)
|
|
375
|
+
out.evolvesTo = evolutions;
|
|
376
|
+
if (kind === 'pipeline') {
|
|
377
|
+
const topLeft = coords(box.x, box.y);
|
|
378
|
+
const bottomRight = coords(box.x + box.w, box.y + box.h);
|
|
379
|
+
const handle = handleOf.get(node.id);
|
|
380
|
+
const handleEvolution = handle
|
|
381
|
+
? coords(centreOf(boxOf(handle)).x, box.y).evolution
|
|
382
|
+
: undefined;
|
|
383
|
+
const middle = round3((topLeft.evolution + bottomRight.evolution) / 2);
|
|
384
|
+
out.pipelineGeometry = {
|
|
385
|
+
evoStart: topLeft.evolution,
|
|
386
|
+
evoEnd: bottomRight.evolution,
|
|
387
|
+
visStart: topLeft.visibility,
|
|
388
|
+
visEnd: bottomRight.visibility,
|
|
389
|
+
...(handleEvolution !== undefined && handleEvolution !== middle
|
|
390
|
+
? { handleEvolution }
|
|
391
|
+
: {}),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
if (kind === 'method') {
|
|
395
|
+
out.method = methodOf(node);
|
|
396
|
+
}
|
|
397
|
+
else {
|
|
398
|
+
const colour = colourOf(node);
|
|
399
|
+
if (colour !== undefined)
|
|
400
|
+
out.color = colour;
|
|
401
|
+
}
|
|
402
|
+
for (const flag of decorated.get(node.id) ?? [])
|
|
403
|
+
out[flag] = true;
|
|
404
|
+
if (pieces.step) {
|
|
405
|
+
const number = Number.parseInt(textOf(pieces.step), 10);
|
|
406
|
+
const fill = pieces.step.fillColor;
|
|
407
|
+
out.step = {
|
|
408
|
+
number,
|
|
409
|
+
...(typeof fill === 'string' && !sameColour(fill, '#cc0000')
|
|
410
|
+
? { color: rendererColourName(fill) }
|
|
411
|
+
: {}),
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
return out;
|
|
415
|
+
});
|
|
416
|
+
/* ── Relations ────────────────────────────────────────────────────── */
|
|
417
|
+
let looseLinks = 0;
|
|
418
|
+
const relations = [];
|
|
419
|
+
for (const connector of connectors) {
|
|
420
|
+
if (connector.role !== WARDLEY_ROLE.dependency)
|
|
421
|
+
continue;
|
|
422
|
+
const consumer = componentAt(connector.source);
|
|
423
|
+
const supplier = componentAt(connector.target);
|
|
424
|
+
if (!consumer || !supplier) {
|
|
425
|
+
looseLinks += 1;
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
const { type, style } = relationOf(connector);
|
|
429
|
+
const label = textOf(connector).trim();
|
|
430
|
+
relations.push({
|
|
431
|
+
id: `r${relations.length + 1}`,
|
|
432
|
+
consumer: idOf.get(consumer.id),
|
|
433
|
+
supplier: idOf.get(supplier.id),
|
|
434
|
+
type,
|
|
435
|
+
// The renderer's `flow` needs a label, so a style alone is not written.
|
|
436
|
+
...(label.length > 0
|
|
437
|
+
? { flow: { label, ...(style === undefined ? {} : { style }) } }
|
|
438
|
+
: {}),
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
/* ── The document ─────────────────────────────────────────────────── */
|
|
442
|
+
const renderConfig = map ? renderConfigOf(map) : undefined;
|
|
443
|
+
const document = {
|
|
444
|
+
title: title ?? options.name?.trim() ?? '',
|
|
445
|
+
...(mapContext === undefined ? {} : { context: mapContext }),
|
|
446
|
+
components,
|
|
447
|
+
relations,
|
|
448
|
+
...(renderConfig === undefined ? {} : { renderConfig }),
|
|
449
|
+
};
|
|
450
|
+
/* ── What the format could not say ───────────────────────────────── */
|
|
451
|
+
const count = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
452
|
+
if (unnamed.length > 0) {
|
|
453
|
+
warnings.push(`${count(unnamed.length, 'component has', 'components have')} no name on the map and ${unnamed.length === 1 ? 'was' : 'were'} written with an empty one: ${unnamed.slice(0, 5).join(', ')}.`);
|
|
454
|
+
}
|
|
455
|
+
if (unqualified.length > 0) {
|
|
456
|
+
warnings.push(`The renderer gives a nature to a plain component only, so the nature of ${unqualified.slice(0, 5).join(', ')} was not written.`);
|
|
457
|
+
}
|
|
458
|
+
if (clamped > 0) {
|
|
459
|
+
warnings.push(`${count(clamped, 'coordinate falls', 'coordinates fall')} outside the map's plot and ${clamped === 1 ? 'was' : 'were'} written at its edge: the renderer reads 0…1 only.`);
|
|
460
|
+
}
|
|
461
|
+
for (const [kind, what] of [
|
|
462
|
+
['porter', "Porter's forces glyph"],
|
|
463
|
+
['area', 'area'],
|
|
464
|
+
]) {
|
|
465
|
+
const n = nodes.filter(node => node.kind === kind).length;
|
|
466
|
+
if (n > 0) {
|
|
467
|
+
warnings.push(`${count(n, what, `${what}s`)} could not be written: the renderer JSON has no word for ${n === 1 ? 'it' : 'them'}.`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
const strays = map
|
|
471
|
+
? [...plainTexts, ...plainShapes].filter(e => !claimed.has(e) && inside(centreOf(boxOf(e)), boxOf(map))).length
|
|
472
|
+
: 0;
|
|
473
|
+
if (strays > 0) {
|
|
474
|
+
warnings.push(`${count(strays, 'free text or shape', 'free texts or shapes')} on the map belong to no component, so ${strays === 1 ? 'it was' : 'they were'} not written.`);
|
|
475
|
+
}
|
|
476
|
+
if (looseLinks > 0) {
|
|
477
|
+
warnings.push(`${count(looseLinks, 'link has an end', 'links have ends')} that is loose or not on a component, so ${looseLinks === 1 ? 'it was' : 'they were'} left out.`);
|
|
478
|
+
}
|
|
479
|
+
if (looseArrows > 0) {
|
|
480
|
+
warnings.push(`${count(looseArrows, 'evolution arrow does', 'evolution arrows do')} not leave a component, so ${looseArrows === 1 ? 'it was' : 'they were'} left out.`);
|
|
481
|
+
}
|
|
482
|
+
if (looseClimate > 0) {
|
|
483
|
+
warnings.push(`${count(looseClimate, 'accelerator or decelerator sits', 'accelerators or decelerators sit')} beside no component, and the renderer draws one only on a component, so ${looseClimate === 1 ? 'it was' : 'they were'} left out.`);
|
|
484
|
+
}
|
|
485
|
+
if (looseBars > 0) {
|
|
486
|
+
warnings.push(`${count(looseBars, 'inertia bar sits', 'inertia bars sit')} on no evolution arrow, so ${looseBars === 1 ? 'it was' : 'they were'} left out.`);
|
|
487
|
+
}
|
|
488
|
+
const text = JSON.stringify(document, null, 2) + '\n';
|
|
489
|
+
return { text, warnings };
|
|
490
|
+
}
|
|
491
|
+
/* ── What the map says of itself ──────────────────────────────────────── */
|
|
492
|
+
/** The map's own labels and toggles, as the `renderConfig` that sets them. */
|
|
493
|
+
function renderConfigOf(map) {
|
|
494
|
+
const display = {};
|
|
495
|
+
if (map.showXAxis === false)
|
|
496
|
+
display.axisEvolution = false;
|
|
497
|
+
if (map.showYAxis === false)
|
|
498
|
+
display.axisValueChain = false;
|
|
499
|
+
if (map.showColumnDividers === false && map.showColumnLabels === false) {
|
|
500
|
+
display.phases = false;
|
|
501
|
+
}
|
|
502
|
+
const background = {};
|
|
503
|
+
const axis = (text) => typeof text === 'string' ? { default: { label: { text } } } : undefined;
|
|
504
|
+
const x = axis(map.xAxisTitle);
|
|
505
|
+
const y = axis(map.yAxisTitle);
|
|
506
|
+
if (x)
|
|
507
|
+
background.axisEvolution = x;
|
|
508
|
+
if (y)
|
|
509
|
+
background.axisValueChain = y;
|
|
510
|
+
const phases = [0, 1, 2, 3].map(index => map[`phase${index}`]);
|
|
511
|
+
let last = -1;
|
|
512
|
+
phases.forEach((text, index) => {
|
|
513
|
+
if (typeof text === 'string')
|
|
514
|
+
last = index;
|
|
515
|
+
});
|
|
516
|
+
if (last >= 0) {
|
|
517
|
+
background.phases = {
|
|
518
|
+
default: {
|
|
519
|
+
labels: phases
|
|
520
|
+
.slice(0, last + 1)
|
|
521
|
+
.map(text => (typeof text === 'string' ? { text } : {})),
|
|
522
|
+
},
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
const config = {};
|
|
526
|
+
if (Object.keys(display).length > 0)
|
|
527
|
+
config.display = display;
|
|
528
|
+
if (Object.keys(background).length > 0)
|
|
529
|
+
config.style = { background };
|
|
530
|
+
return Object.keys(config).length > 0 ? config : undefined;
|
|
531
|
+
}
|
|
532
|
+
/* ── A node's parameters ──────────────────────────────────────────────── */
|
|
533
|
+
/** The values a node carries for one tag, from a live `Y.Map` or stored props. */
|
|
534
|
+
function tagValues(node, tagId) {
|
|
535
|
+
const tags = node.tags;
|
|
536
|
+
if (!tags)
|
|
537
|
+
return [];
|
|
538
|
+
const values = typeof tags.get === 'function' ? tags.get(tagId) : tags[tagId];
|
|
539
|
+
return Array.isArray(values) ? values : [];
|
|
540
|
+
}
|
|
541
|
+
/** The `wardley:nature` value a node carries, if it is one of the four. */
|
|
542
|
+
function natureOf(node) {
|
|
543
|
+
const values = tagValues(node, WARDLEY_NATURE_TAG_ID);
|
|
544
|
+
const prefix = `${WARDLEY_NATURE_TAG_ID}/`;
|
|
545
|
+
return LABRE_NATURES.find(nature => values.includes(`${prefix}${nature}`));
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* A method node's `wardley:method` tag as the renderer's pair (ADR 0046), the
|
|
549
|
+
* recommendation spelled as the renderer's legend keys are (`Uncharted`). An
|
|
550
|
+
* untagged node, which paints as Make, says there IS a method but not which:
|
|
551
|
+
* it is written as `buying-policy` with no recommendation (PO, 2026-10-28).
|
|
552
|
+
*/
|
|
553
|
+
function methodOf(node) {
|
|
554
|
+
for (const value of tagValues(node, WARDLEY_METHOD_TAG_ID)) {
|
|
555
|
+
const parts = wardleyMethodParts(typeof value === 'string' ? value : null);
|
|
556
|
+
if (parts) {
|
|
557
|
+
return {
|
|
558
|
+
category: parts.family,
|
|
559
|
+
recommendation: parts.phase.charAt(0).toUpperCase() + parts.phase.slice(1),
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return { category: 'buying-policy', recommendation: '' };
|
|
564
|
+
}
|
|
565
|
+
/** The renderer `color` of a node: its stroke, unless it is the toolbar's. */
|
|
566
|
+
function colourOf(node) {
|
|
567
|
+
const stroke = node.strokeColor;
|
|
568
|
+
if (typeof stroke !== 'string' || !stroke.startsWith('#'))
|
|
569
|
+
return undefined;
|
|
570
|
+
const preset = wardleyNodeProps(node.kind, {
|
|
571
|
+
xywh: '[0,0,0,0]',
|
|
572
|
+
}).strokeColor;
|
|
573
|
+
return sameColour(stroke, preset) ? undefined : rendererColourName(stroke);
|
|
574
|
+
}
|
|
575
|
+
/** A pipeline's name slot: centred above its handle, as the toolbar writes it. */
|
|
576
|
+
function pipelineLabelSlot(pipeline, handle) {
|
|
577
|
+
const body = boxOf(pipeline);
|
|
578
|
+
const cx = handle ? centreOf(boxOf(handle)).x : body.x + body.w / 2;
|
|
579
|
+
return wardleyLabelBoxFor('pipeline', cx, body.y + PIPELINE_HEIGHT / 2);
|
|
580
|
+
}
|
|
581
|
+
function pipelineLabelOffset(plot, pipeline, handle, label) {
|
|
582
|
+
const slot = pipelineLabelSlot(pipeline, handle);
|
|
583
|
+
const box = boxOf(label);
|
|
584
|
+
return jsonLabelUnshift(plot, { x: box.x - slot.x, y: box.y - slot.y });
|
|
585
|
+
}
|
|
586
|
+
const isStep = (element) => element.type === 'shape' &&
|
|
587
|
+
element.role === undefined &&
|
|
588
|
+
element.shapeType === 'ellipse' &&
|
|
589
|
+
/^\d+$/.test(textOf(element).trim());
|
|
590
|
+
const isRange = (element) => element.type === 'shape' &&
|
|
591
|
+
element.role === undefined &&
|
|
592
|
+
isRangeShape(element.shapeType, boxOf(element));
|
|
593
|
+
/** A name of the renderer's id alphabet: lower case, ascii, dashes. */
|
|
594
|
+
function slug(name) {
|
|
595
|
+
return name
|
|
596
|
+
.normalize('NFD')
|
|
597
|
+
.replace(/[̀-ͯ]/g, '')
|
|
598
|
+
.toLowerCase()
|
|
599
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
600
|
+
.replace(/^-+|-+$/g, '');
|
|
601
|
+
}
|
|
602
|
+
/* ── The pieces ───────────────────────────────────────────────────────── */
|
|
603
|
+
/** A group's member ids, from a live model (`childIds`) or stored props. */
|
|
604
|
+
function childIdsOf(group) {
|
|
605
|
+
if (Array.isArray(group.childIds))
|
|
606
|
+
return group.childIds;
|
|
607
|
+
const children = group.children;
|
|
608
|
+
if (!children)
|
|
609
|
+
return [];
|
|
610
|
+
if (typeof children.keys === 'function') {
|
|
611
|
+
return [...children.keys()];
|
|
612
|
+
}
|
|
613
|
+
return Object.keys(children);
|
|
614
|
+
}
|
|
615
|
+
function boxOf(element) {
|
|
616
|
+
if (element.elementBound) {
|
|
617
|
+
const { x, y, w, h } = element.elementBound;
|
|
618
|
+
return { x, y, w, h };
|
|
619
|
+
}
|
|
620
|
+
if (typeof element.xywh === 'string') {
|
|
621
|
+
const { x, y, w, h } = Bound.deserialize(element.xywh);
|
|
622
|
+
return { x, y, w, h };
|
|
623
|
+
}
|
|
624
|
+
return { x: 0, y: 0, w: 0, h: 0 };
|
|
625
|
+
}
|
|
626
|
+
const centreOf = (box) => ({ x: box.x + box.w / 2, y: box.y + box.h / 2 });
|
|
627
|
+
const distanceTo = (a, b) => Math.hypot(a.x - b.cx, a.y - b.cy);
|
|
628
|
+
const inside = (point, box) => point.x >= box.x &&
|
|
629
|
+
point.x <= box.x + box.w &&
|
|
630
|
+
point.y >= box.y &&
|
|
631
|
+
point.y <= box.y + box.h;
|
|
632
|
+
/** A text's words, whether a `Y.Text` or a plain string. */
|
|
633
|
+
function textOf(element) {
|
|
634
|
+
const text = element.text;
|
|
635
|
+
return text === undefined || text === null ? '' : String(text);
|
|
636
|
+
}
|
|
637
|
+
/** Where an edge end is: the centre of the node it names, or its free point. */
|
|
638
|
+
function pointOf(end, nodeById) {
|
|
639
|
+
if (end?.id !== undefined) {
|
|
640
|
+
const node = nodeById.get(end.id);
|
|
641
|
+
return node ? centreOf(boxOf(node)) : undefined;
|
|
642
|
+
}
|
|
643
|
+
const position = end?.position;
|
|
644
|
+
if (Array.isArray(position) &&
|
|
645
|
+
typeof position[0] === 'number' &&
|
|
646
|
+
typeof position[1] === 'number') {
|
|
647
|
+
return { x: position[0], y: position[1] };
|
|
648
|
+
}
|
|
649
|
+
return undefined;
|
|
650
|
+
}
|
|
651
|
+
function nearest(items, distance) {
|
|
652
|
+
let best;
|
|
653
|
+
for (const item of items) {
|
|
654
|
+
const d = distance(item);
|
|
655
|
+
if (!best || d < best.distance)
|
|
656
|
+
best = { item, distance: d };
|
|
657
|
+
}
|
|
658
|
+
return best;
|
|
659
|
+
}
|
|
660
|
+
/** The distance from `p` to the segment `a`–`b`. */
|
|
661
|
+
function segmentDistance(p, a, b) {
|
|
662
|
+
const dx = b.x - a.x;
|
|
663
|
+
const dy = b.y - a.y;
|
|
664
|
+
const length = dx * dx + dy * dy;
|
|
665
|
+
const t = length === 0
|
|
666
|
+
? 0
|
|
667
|
+
: Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / length));
|
|
668
|
+
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
|
|
669
|
+
}
|