@formicoidea/labre-framework-wardley 0.32.0 → 0.33.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/actions.d.ts +49 -2
- package/dist/actions.js +113 -21
- package/dist/commands.d.ts +4 -0
- package/dist/commands.js +166 -4
- package/dist/export.d.ts +211 -0
- package/dist/export.js +655 -0
- package/dist/gradient.js +1 -1
- package/dist/import.d.ts +116 -0
- package/dist/import.js +905 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +22 -0
- package/dist/interchange.d.ts +80 -0
- package/dist/interchange.js +138 -0
- package/dist/node/node-renderer.js +1 -1
- package/dist/rules.js +16 -0
- package/dist/templates/index.js +15 -3
- package/dist/templates/maps.js +26 -6
- package/dist/toolbar/config.js +3 -1
- package/dist/toolbar/icons.d.ts +20 -0
- package/dist/toolbar/icons.js +34 -0
- package/dist/toolbar/senior-tool.js +1 -0
- package/dist/toolbar/wardley-senior-button.js +12 -6
- package/dist/view.js +13 -2
- package/package.json +2 -2
package/dist/export.js
ADDED
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
import { backgroundPlot } from '@formicoidea/labre-core/blocks/surface';
|
|
2
|
+
import { ConnectorElementModel, TextElementModel, WardleyBackgroundElementModel, WardleyNodeElementModel, } from '@formicoidea/labre-core/model';
|
|
3
|
+
import { WARDLEY_BACKGROUND } from './background.js';
|
|
4
|
+
import { LABEL_FONT_SIZE, LABEL_GAP } from './node/consts.js';
|
|
5
|
+
import { WARDLEY_ROLE } from './roles.js';
|
|
6
|
+
/**
|
|
7
|
+
* A Wardley map as an OnlineWardleyMaps (OWM) DSL document — models in, text
|
|
8
|
+
* out (`docs/adr/0012`, P3).
|
|
9
|
+
*
|
|
10
|
+
* This is the function ADR 0012 records as owed: the Wardley serializer that
|
|
11
|
+
* exists today in **labre-mcp**, outside this repo, and is the ADR's one named
|
|
12
|
+
* violation of P3. It lands here so that both consumers — the editor command
|
|
13
|
+
* and the MCP tool — call one implementation, tested once, and so that the
|
|
14
|
+
* reader next door (`import.ts`) has a writer it agrees with about every
|
|
15
|
+
* coordinate, name and carried line.
|
|
16
|
+
*
|
|
17
|
+
* ## Pure, like its BPMN sibling
|
|
18
|
+
*
|
|
19
|
+
* Element models in, a string out. No `BlockStdScope`, no surface, no DOM, no
|
|
20
|
+
* clock, no randomness. `interchange.ts` is the thin adapter that names the
|
|
21
|
+
* file; `actions.ts` is the thinner one that downloads it.
|
|
22
|
+
*
|
|
23
|
+
* ## The plot IS the coordinate
|
|
24
|
+
*
|
|
25
|
+
* A Wardley node carries **no** `visibility` and **no** `evolution` prop — its
|
|
26
|
+
* position on the map's plot is the whole of what the map says about it. So the
|
|
27
|
+
* writer inverts the projection the reader applied: a node's centre, measured
|
|
28
|
+
* against the plot of the background it sits on, is the `[visibility,
|
|
29
|
+
* evolution]` pair OWM spells. Both numbers are written to exactly **two
|
|
30
|
+
* decimals**, and that stability is load-bearing rather than cosmetic: the
|
|
31
|
+
* fixed point `export(import(export(board)))` is byte-identical only because a
|
|
32
|
+
* value that survives one rounding survives every one after it. The reader
|
|
33
|
+
* tolerates any precision a foreign file happens to use.
|
|
34
|
+
*
|
|
35
|
+
* ## A name is a separate element, so it has to be found
|
|
36
|
+
*
|
|
37
|
+
* On this canvas the name of an artefact is a free text element beside it, not
|
|
38
|
+
* a prop on it (`roles.ts`, `WARDLEY_ROLE.label`). The writer therefore matches
|
|
39
|
+
* each label to the node it names by comparing where the label IS with where a
|
|
40
|
+
* label for that node WOULD be — see {@link matchLabels}, which is the one
|
|
41
|
+
* heuristic in this module and is documented as one.
|
|
42
|
+
*
|
|
43
|
+
* ## v1 reads one map
|
|
44
|
+
*
|
|
45
|
+
* An OWM document is one map. A surface holding several Wardley backgrounds is
|
|
46
|
+
* serialized against the FIRST in document order, and the export warns; the
|
|
47
|
+
* other maps' artefacts are written against that first plot, which is the
|
|
48
|
+
* honest behaviour (nothing is dropped) and is named in the warning so nobody
|
|
49
|
+
* discovers it from a file.
|
|
50
|
+
*/
|
|
51
|
+
/* ── The format's own vocabulary ──────────────────────────────────────── */
|
|
52
|
+
/**
|
|
53
|
+
* The format id, and therefore THE KEY foreign matter rides under on an element
|
|
54
|
+
* (ADR 0012, D2) — `interchange.owm`. Declared here and re-exported by
|
|
55
|
+
* `import.ts`, so a reader filing a fragment and a writer looking one up cannot
|
|
56
|
+
* disagree about where it went.
|
|
57
|
+
*/
|
|
58
|
+
export const WARDLEY_OWM_FORMAT_ID = 'owm';
|
|
59
|
+
/**
|
|
60
|
+
* OWM's scope vocabulary — where a carried line came off (D2).
|
|
61
|
+
*
|
|
62
|
+
* The DSL is a flat list of statements with no nesting and no ids, so it needs
|
|
63
|
+
* exactly two `@`-prefixed role keys and never an element id:
|
|
64
|
+
*
|
|
65
|
+
* - `@document` — the whole file: the lines this reader has no artefact for,
|
|
66
|
+
* and the `title` it consumed. They ride on the map's background element,
|
|
67
|
+
* which is D6's stated asymmetry (delete the map and the residue goes with
|
|
68
|
+
* it) and is where `profileId` already lives for the same reason.
|
|
69
|
+
* - `@self` — the line an element WAS. Used for the verbatim tail of a mapped
|
|
70
|
+
* line, i.e. everything the writer would otherwise drop: `label [x, y]`,
|
|
71
|
+
* `(build)`, `inertia`, a trailing comment.
|
|
72
|
+
*/
|
|
73
|
+
export const OWM_SCOPE = {
|
|
74
|
+
document: '@document',
|
|
75
|
+
self: '@self',
|
|
76
|
+
};
|
|
77
|
+
/** Where a mapped line's un-modelled tail is filed, under `attrs['@self']`. */
|
|
78
|
+
export const OWM_TAIL_ATTR = 'tail';
|
|
79
|
+
/** Where the file's own `title` is filed, under `attrs['@document']`. */
|
|
80
|
+
export const OWM_TITLE_ATTR = 'title';
|
|
81
|
+
/**
|
|
82
|
+
* Keywords a line may open on, none of which can be a bare component name.
|
|
83
|
+
*
|
|
84
|
+
* Two different parsers care. `BaseStrategyRunner` claims a line for a keyword
|
|
85
|
+
* when the TRIMMED line opens on `"<keyword> "`, and `LinksExtractionStrategy`
|
|
86
|
+
* refuses to read a line as a link when it opens on any of these — so a
|
|
87
|
+
* component genuinely called `style` would silently stop being linkable. The
|
|
88
|
+
* writer quotes such a name rather than betting nobody ever picks one.
|
|
89
|
+
*/
|
|
90
|
+
export const OWM_KEYWORDS = new Set([
|
|
91
|
+
'accelerator',
|
|
92
|
+
'anchor',
|
|
93
|
+
'annotation',
|
|
94
|
+
'annotations',
|
|
95
|
+
'build',
|
|
96
|
+
'buy',
|
|
97
|
+
'component',
|
|
98
|
+
'deaccelerator',
|
|
99
|
+
'ecosystem',
|
|
100
|
+
'evolution',
|
|
101
|
+
'evolve',
|
|
102
|
+
'market',
|
|
103
|
+
'note',
|
|
104
|
+
'outsource',
|
|
105
|
+
'pioneers',
|
|
106
|
+
'pipeline',
|
|
107
|
+
'presentation',
|
|
108
|
+
'settlers',
|
|
109
|
+
'size',
|
|
110
|
+
'style',
|
|
111
|
+
'submap',
|
|
112
|
+
'title',
|
|
113
|
+
'townplanners',
|
|
114
|
+
'url',
|
|
115
|
+
]);
|
|
116
|
+
/** A name that needs no quoting: one word of the characters OWM reads bare. */
|
|
117
|
+
const BARE_NAME = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/;
|
|
118
|
+
/**
|
|
119
|
+
* A name as the DSL spells it — bare when it can be, quoted and escaped
|
|
120
|
+
* otherwise.
|
|
121
|
+
*
|
|
122
|
+
* The escaping mirrors OWM's own `escapeComponentNameForMapText` /
|
|
123
|
+
* `unescapeComponentNameFromMapText` pair character for character, which is
|
|
124
|
+
* what makes `"Vente retail thés, accessoires, coffrets"` come back with its
|
|
125
|
+
* commas and its accents intact.
|
|
126
|
+
*/
|
|
127
|
+
export function owmName(raw) {
|
|
128
|
+
return BARE_NAME.test(raw) && !opensOnKeyword(raw) ? raw : owmQuote(raw);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Whether a bare name would be mistaken for a statement of another kind.
|
|
132
|
+
*
|
|
133
|
+
* A PREFIX test, not an equality one, because that is what the reference reader
|
|
134
|
+
* does: `LinksExtractionStrategy.canProcessLine` refuses a line whose trimmed
|
|
135
|
+
* text merely BEGINS with one of these (`element.trim().indexOf(keyword) === 0`),
|
|
136
|
+
* and `BaseStrategyRunner` claims one the same way. So `urlShortener->Cache` is
|
|
137
|
+
* not a link there, and a component genuinely called `urlShortener` silently
|
|
138
|
+
* stops being linkable unless the writer quotes it.
|
|
139
|
+
*/
|
|
140
|
+
function opensOnKeyword(raw) {
|
|
141
|
+
for (const keyword of OWM_KEYWORDS) {
|
|
142
|
+
if (raw.startsWith(keyword))
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
/** A name in quotes, escaped as OWM's `escapeComponentNameForMapText` does. */
|
|
148
|
+
export function owmQuote(raw) {
|
|
149
|
+
const escaped = raw
|
|
150
|
+
.replaceAll('\\', '\\\\')
|
|
151
|
+
.replaceAll('"', '\\"')
|
|
152
|
+
.replaceAll('\n', '\\n')
|
|
153
|
+
.replaceAll('\r', '\\r')
|
|
154
|
+
.replaceAll('\t', '\\t')
|
|
155
|
+
.replaceAll('[', '\\[')
|
|
156
|
+
.replaceAll(']', '\\]');
|
|
157
|
+
return `"${escaped}"`;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* The `X -> Y` half of an `evolve` line, with BOTH names quoted whenever
|
|
161
|
+
* either needs it.
|
|
162
|
+
*
|
|
163
|
+
* Not cosmetic, and not symmetry for its own sake — it is the only spelling the
|
|
164
|
+
* reference reader understands. `setNameWithMaturity` has two branches, and
|
|
165
|
+
* only the QUOTED one (`nameSection.startsWith('"')`) ever unquotes an override:
|
|
166
|
+
* the legacy branch splits on `->` and takes `parts[1].trim()` verbatim, so
|
|
167
|
+
* `evolve Kettle -> "Electric kettle" 0.75` gives an evolved component whose
|
|
168
|
+
* name still has the quote characters in it, and onlinewardleymaps draws them.
|
|
169
|
+
* Quoting the source name is what puts the reader in the branch that strips
|
|
170
|
+
* them off the target.
|
|
171
|
+
*/
|
|
172
|
+
function evolvePair(was, becomes) {
|
|
173
|
+
const quoted = `${owmName(was)} -> ${owmName(becomes)}`;
|
|
174
|
+
if (!quoted.includes('"'))
|
|
175
|
+
return quoted;
|
|
176
|
+
return `${owmQuote(was)} -> ${owmQuote(becomes)}`;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* A coordinate, to exactly two decimals — the whole of the fixed point's
|
|
180
|
+
* arithmetic.
|
|
181
|
+
*
|
|
182
|
+
* `-0` is written as `0.00`, because `(-0).toFixed(2)` is `"-0.00"` and a
|
|
183
|
+
* node dropped one pixel above the plot's top edge would otherwise produce a
|
|
184
|
+
* file whose bytes depend on which side of zero a float landed on. A
|
|
185
|
+
* non-finite value (an element with no geometry) is written as `0.00` rather
|
|
186
|
+
* than as `NaN`, which no parser reads.
|
|
187
|
+
*/
|
|
188
|
+
export function owmNumber(value) {
|
|
189
|
+
if (!Number.isFinite(value))
|
|
190
|
+
return '0.00';
|
|
191
|
+
const fixed = value.toFixed(2);
|
|
192
|
+
return fixed === '-0.00' ? '0.00' : fixed;
|
|
193
|
+
}
|
|
194
|
+
/** The reference map an import lays out on, and an export falls back to. */
|
|
195
|
+
export const OWM_DEFAULT_MAP_WIDTH = WARDLEY_BACKGROUND.geometry.width;
|
|
196
|
+
export const OWM_DEFAULT_MAP_HEIGHT = WARDLEY_BACKGROUND.geometry.height;
|
|
197
|
+
/**
|
|
198
|
+
* The plot of a background element, in absolute units — the declaration's
|
|
199
|
+
* margins, never a hand-written inset.
|
|
200
|
+
*
|
|
201
|
+
* `templates/maps.ts` learned this the hard way: a plot copied as four numbers
|
|
202
|
+
* drifted from the drawn one, and a rule measuring against the declaration then
|
|
203
|
+
* judged nodes laid out against the copy. Both directions of this format read
|
|
204
|
+
* the same function for the same reason.
|
|
205
|
+
*/
|
|
206
|
+
export function owmPlotOf(bound) {
|
|
207
|
+
const plot = backgroundPlot(WARDLEY_BACKGROUND, bound.w, bound.h);
|
|
208
|
+
return {
|
|
209
|
+
x0: bound.x + plot.x0,
|
|
210
|
+
y0: bound.y + plot.y0,
|
|
211
|
+
width: plot.width,
|
|
212
|
+
height: plot.height,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
/** The default plot: a reference map at the origin. */
|
|
216
|
+
export function owmDefaultPlot() {
|
|
217
|
+
return owmPlotOf({
|
|
218
|
+
x: 0,
|
|
219
|
+
y: 0,
|
|
220
|
+
w: OWM_DEFAULT_MAP_WIDTH,
|
|
221
|
+
h: OWM_DEFAULT_MAP_HEIGHT,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* `[visibility, evolution]` → a surface point.
|
|
226
|
+
*
|
|
227
|
+
* Mind the inversion, which is the one thing about these axes that is easy to
|
|
228
|
+
* get backwards and impossible to see in a test that only round-trips: OWM's
|
|
229
|
+
* visibility `1.0` is the TOP of the value chain, and a canvas' y grows
|
|
230
|
+
* downwards.
|
|
231
|
+
*/
|
|
232
|
+
export function owmPointOf(plot, visibility, evolution) {
|
|
233
|
+
return [
|
|
234
|
+
plot.x0 + evolution * plot.width,
|
|
235
|
+
plot.y0 + (1 - visibility) * plot.height,
|
|
236
|
+
];
|
|
237
|
+
}
|
|
238
|
+
/** A surface point → `[visibility, evolution]`. The exact inverse. */
|
|
239
|
+
export function owmCoordsOf(plot, x, y) {
|
|
240
|
+
return {
|
|
241
|
+
visibility: 1 - (y - plot.y0) / plot.height,
|
|
242
|
+
evolution: (x - plot.x0) / plot.width,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
export function wardleyBoardFrom(elements) {
|
|
246
|
+
const maps = [];
|
|
247
|
+
const nodes = [];
|
|
248
|
+
const labels = [];
|
|
249
|
+
const notes = [];
|
|
250
|
+
const connectors = [];
|
|
251
|
+
for (const element of elements) {
|
|
252
|
+
if (element instanceof WardleyBackgroundElementModel)
|
|
253
|
+
maps.push(element);
|
|
254
|
+
else if (element instanceof WardleyNodeElementModel)
|
|
255
|
+
nodes.push(element);
|
|
256
|
+
else if (element instanceof ConnectorElementModel)
|
|
257
|
+
connectors.push(element);
|
|
258
|
+
else if (element instanceof TextElementModel) {
|
|
259
|
+
if (element.role === WARDLEY_ROLE.label)
|
|
260
|
+
labels.push(element);
|
|
261
|
+
else if (element.role === undefined)
|
|
262
|
+
notes.push(element);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return { maps, nodes, labels, notes, connectors };
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* A name a file system will accept, minus the extension. BPMN's sanitizer,
|
|
269
|
+
* verbatim in behaviour and different only in its fallback — `map`, because
|
|
270
|
+
* that is what an OWM document is.
|
|
271
|
+
*/
|
|
272
|
+
export function wardleySafeFilename(raw) {
|
|
273
|
+
const safe = (raw ?? '')
|
|
274
|
+
.trim()
|
|
275
|
+
.replaceAll(/[\\/:*?"<>|]/g, '-')
|
|
276
|
+
.replaceAll(/\s+/g, ' ')
|
|
277
|
+
.trim()
|
|
278
|
+
.slice(0, 120)
|
|
279
|
+
.replace(/[. ]+$/, '');
|
|
280
|
+
return safe || 'map';
|
|
281
|
+
}
|
|
282
|
+
/* ── Matching a name to the artefact it names ─────────────────────────── */
|
|
283
|
+
/** How far a label may sit from where this node's label belongs, in units. */
|
|
284
|
+
const LABEL_MATCH_TOLERANCE = 24;
|
|
285
|
+
/** The label box an import writes, and the width a prediction assumes. */
|
|
286
|
+
export const OWM_LABEL_WIDTH = 200;
|
|
287
|
+
export const OWM_LABEL_HEIGHT = LABEL_FONT_SIZE + 8;
|
|
288
|
+
const boxOf = (element) => {
|
|
289
|
+
const { x, y, w, h } = element.elementBound;
|
|
290
|
+
return { x, y, w, h };
|
|
291
|
+
};
|
|
292
|
+
/**
|
|
293
|
+
* Where a label for this node would be written, under each of the three
|
|
294
|
+
* conventions this library actually uses.
|
|
295
|
+
*
|
|
296
|
+
* `actions.ts` writes a name to the RIGHT of the circle it names;
|
|
297
|
+
* `templates/maps.ts` writes some to the left and some centred above; this
|
|
298
|
+
* reader writes to the right, and above for a pipeline. All four are the same
|
|
299
|
+
* two numbers — the node's own half-size plus {@link LABEL_GAP} — so the
|
|
300
|
+
* prediction is computed from the node rather than tabulated, and a node of any
|
|
301
|
+
* size (a market is 30 units across, an ecosystem 40) is predicted correctly
|
|
302
|
+
* without a table to keep in step.
|
|
303
|
+
*/
|
|
304
|
+
function labelAnchors(node, label) {
|
|
305
|
+
const cx = node.x + node.w / 2;
|
|
306
|
+
const cy = node.y + node.h / 2;
|
|
307
|
+
const rx = node.w / 2;
|
|
308
|
+
const ry = node.h / 2;
|
|
309
|
+
return [
|
|
310
|
+
// To the right, vertically centred — the toolbox's own gesture.
|
|
311
|
+
[cx + rx + LABEL_GAP, cy - label.h / 2],
|
|
312
|
+
// To the left, vertically centred.
|
|
313
|
+
[cx - rx - LABEL_GAP - label.w, cy - label.h / 2],
|
|
314
|
+
// Centred above.
|
|
315
|
+
[cx - label.w / 2, cy - ry - LABEL_GAP - label.h],
|
|
316
|
+
];
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Which label names which node — the one heuristic in this module.
|
|
320
|
+
*
|
|
321
|
+
* A name is a separate element on this canvas, so "which node is this the name
|
|
322
|
+
* of" is a question the document does not answer directly and the writer has to
|
|
323
|
+
* ask of the geometry. The naive answer — nearest node to the label box — is
|
|
324
|
+
* WRONG on a real map and provably so: two components 60 units apart
|
|
325
|
+
* horizontally and 17 apart vertically (which is nothing on a 1530-wide plot,
|
|
326
|
+
* and is exactly what the tea-shop corpus holds) put the lower one's centre
|
|
327
|
+
* INSIDE the upper one's label box, so it wins by a distance of half a unit and
|
|
328
|
+
* steals the name.
|
|
329
|
+
*
|
|
330
|
+
* What is asked instead is: how far is this label from where a label for this
|
|
331
|
+
* node WOULD have been written? A wrong node's answer is a whole node spacing;
|
|
332
|
+
* the right node's is the few units between one convention and another. Pairs
|
|
333
|
+
* within {@link LABEL_MATCH_TOLERANCE} are then assigned greedily, closest
|
|
334
|
+
* first, ties broken by document order — so the assignment is a function of the
|
|
335
|
+
* document and not of the iteration order.
|
|
336
|
+
*
|
|
337
|
+
* A pipeline HANDLE never claims a label: it sits on the body's top edge, right
|
|
338
|
+
* under the name the body owns, and would win every pipeline's name from it.
|
|
339
|
+
*/
|
|
340
|
+
function matchLabels(nodes, labels) {
|
|
341
|
+
const pairs = [];
|
|
342
|
+
nodes.forEach((node, nodeIndex) => {
|
|
343
|
+
// A handle sits on the pipeline body's top edge, directly under the name
|
|
344
|
+
// the BODY owns, and would win it from the body every time. A roleless node
|
|
345
|
+
// is a glyph's own wiring (a market's three inner dots) and names nothing.
|
|
346
|
+
if (node.kind === 'handle' || node.role === undefined)
|
|
347
|
+
return;
|
|
348
|
+
const nodeBox = boxOf(node);
|
|
349
|
+
labels.forEach((label, labelIndex) => {
|
|
350
|
+
const labelBox = boxOf(label);
|
|
351
|
+
const distance = Math.min(...labelAnchors(nodeBox, labelBox).map(([x, y]) => Math.hypot(labelBox.x - x, labelBox.y - y)));
|
|
352
|
+
if (distance <= LABEL_MATCH_TOLERANCE) {
|
|
353
|
+
pairs.push({ distance, node: nodeIndex, label: labelIndex });
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
pairs.sort((a, b) => a.distance - b.distance || a.node - b.node || a.label - b.label);
|
|
358
|
+
const byNode = new Map();
|
|
359
|
+
const takenLabels = new Set();
|
|
360
|
+
const takenNodes = new Set();
|
|
361
|
+
for (const pair of pairs) {
|
|
362
|
+
if (takenNodes.has(pair.node) || takenLabels.has(pair.label))
|
|
363
|
+
continue;
|
|
364
|
+
takenNodes.add(pair.node);
|
|
365
|
+
takenLabels.add(pair.label);
|
|
366
|
+
byNode.set(nodes[pair.node].id, textOf(labels[pair.label]));
|
|
367
|
+
}
|
|
368
|
+
return byNode;
|
|
369
|
+
}
|
|
370
|
+
/** A text element's string, whether it is a `Y.Text` or a test's plain one. */
|
|
371
|
+
export function textOf(element) {
|
|
372
|
+
const text = element.text;
|
|
373
|
+
return text === undefined || text === null ? '' : String(text);
|
|
374
|
+
}
|
|
375
|
+
/* ── What a node is, in OWM's vocabulary ──────────────────────────────── */
|
|
376
|
+
/**
|
|
377
|
+
* The keyword each drawn kind is written under.
|
|
378
|
+
*
|
|
379
|
+
* `method` is the one that is not a mapping: OWM has no `method` ELEMENT — a
|
|
380
|
+
* method is the `(build)` / `(buy)` / `(outsource)` decorator on a component
|
|
381
|
+
* line — so a Labre method node is written as a component and the export warns
|
|
382
|
+
* that the method itself could not be said. `pipeline` and `handle` are absent
|
|
383
|
+
* because a pipeline is written from its BODY, in its own section.
|
|
384
|
+
*/
|
|
385
|
+
const OWM_KEYWORD_OF_KIND = {
|
|
386
|
+
component: 'component',
|
|
387
|
+
anchor: 'anchor',
|
|
388
|
+
market: 'market',
|
|
389
|
+
ecosystem: 'ecosystem',
|
|
390
|
+
method: 'component',
|
|
391
|
+
};
|
|
392
|
+
/** What one element carried from the file it came out of, if anything. */
|
|
393
|
+
function carriedOf(element) {
|
|
394
|
+
return element.interchange?.[WARDLEY_OWM_FORMAT_ID];
|
|
395
|
+
}
|
|
396
|
+
/** The verbatim tail of the line this element was, or the empty string. */
|
|
397
|
+
function tailOf(element) {
|
|
398
|
+
return carriedOf(element)?.attrs?.[OWM_SCOPE.self]?.[OWM_TAIL_ATTR] ?? '';
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* The board as an OWM document, plus what the format could not say.
|
|
402
|
+
*
|
|
403
|
+
* Sections in a fixed order — title, nodes, pipelines, notes, evolutions,
|
|
404
|
+
* links, carried lines — and DOCUMENT order inside each. That pairing is what
|
|
405
|
+
* makes the fixed point hold: the reader creates elements in the order it meets
|
|
406
|
+
* them, so a file's sections come back as a document whose order re-sections
|
|
407
|
+
* identically.
|
|
408
|
+
*/
|
|
409
|
+
export function exportWardleyOwmWithWarnings(board, options = {}) {
|
|
410
|
+
const warnings = [];
|
|
411
|
+
const map = board.maps[0];
|
|
412
|
+
if (board.maps.length > 1) {
|
|
413
|
+
warnings.push(`This board holds ${board.maps.length} Wardley maps and an OWM file holds one. Everything was measured against the first map in the document; the others' coordinates are read against it too.`);
|
|
414
|
+
}
|
|
415
|
+
if (!map) {
|
|
416
|
+
warnings.push('No Wardley map background was found, so there is no plot to measure against. Coordinates were read against a default 1600 × 900 map at the origin.');
|
|
417
|
+
}
|
|
418
|
+
const plot = map ? owmPlotOf(boxOf(map)) : owmDefaultPlot();
|
|
419
|
+
const carried = map ? carriedOf(map) : undefined;
|
|
420
|
+
const titleAttr = carried?.attrs?.[OWM_SCOPE.document]?.[OWM_TITLE_ATTR];
|
|
421
|
+
// A name of nothing but spaces is a name the caller does not have, and this
|
|
422
|
+
// is a PUBLIC entry point (P3: labre-mcp calls it directly, with whatever it
|
|
423
|
+
// has). `title ` with a trailing space and no title after it is not a
|
|
424
|
+
// statement any reader can do anything with — and the reference one would
|
|
425
|
+
// give the map the empty string as its name.
|
|
426
|
+
const named = options.name !== undefined && options.name.trim().length > 0
|
|
427
|
+
? options.name
|
|
428
|
+
: undefined;
|
|
429
|
+
// The FILE's title wins, and the caller's name is the fallback — D3's rule
|
|
430
|
+
// applied to the one thing this format has an identity for besides a
|
|
431
|
+
// component's name: record what we were given, never reconstruct what we
|
|
432
|
+
// think we sent. It is the same precedence `interchange.<fmt>.id` already has
|
|
433
|
+
// on every element, and it was the other way round until a browser recette
|
|
434
|
+
// caught it: a tea-shop map imported under its own title
|
|
435
|
+
// ("Tea Shop moderne 2026 …") left again as "BlockSuite Playground", because
|
|
436
|
+
// the host's document name is what the command passes as `context.name` and
|
|
437
|
+
// it was overriding the title the file actually carried.
|
|
438
|
+
//
|
|
439
|
+
// The unit suite could not see it, and that is worth saying: a fixed-point
|
|
440
|
+
// test passes the SAME `name` through both halves, so the two candidates
|
|
441
|
+
// agree in every round trip and the precedence between them is unobservable.
|
|
442
|
+
// The assertion shape that catches it is an import whose file has a title
|
|
443
|
+
// followed by an export under a DIFFERENT name.
|
|
444
|
+
const title = titleAttr ?? named;
|
|
445
|
+
if (titleAttr !== undefined && named !== undefined && titleAttr !== named) {
|
|
446
|
+
warnings.push(`This map came from a file titled "${titleAttr}", and that is the title written out — not "${named}", which is what the board is called here. Rename the map inside the file if you want the exported title to change.`);
|
|
447
|
+
}
|
|
448
|
+
/* Names, resolved once and read by every section. */
|
|
449
|
+
const nameByNode = matchLabels(board.nodes, board.labels);
|
|
450
|
+
const unnamed = [];
|
|
451
|
+
// MEMOIZED, and it is not an optimization: a node is asked for its name once
|
|
452
|
+
// per section it appears in — its own line, and every link that ends on it —
|
|
453
|
+
// and an unnamed one would otherwise be counted twice and christened twice,
|
|
454
|
+
// so one artefact would leave under two names and the links would disagree
|
|
455
|
+
// with the components.
|
|
456
|
+
const resolved = new Map();
|
|
457
|
+
const nameOf = (node) => {
|
|
458
|
+
const already = resolved.get(node.id);
|
|
459
|
+
if (already !== undefined)
|
|
460
|
+
return already;
|
|
461
|
+
const matched = nameByNode.get(node.id);
|
|
462
|
+
const carriedHere = carriedOf(node);
|
|
463
|
+
let name;
|
|
464
|
+
if (matched !== undefined && matched.trim().length > 0) {
|
|
465
|
+
name = matched;
|
|
466
|
+
}
|
|
467
|
+
else if (
|
|
468
|
+
// A carried id is what the FILE called this thing (D3) — but only when it
|
|
469
|
+
// is one. A handle this reader minted so a composite's own wiring could
|
|
470
|
+
// resolve says `element`, and is not a name anybody wrote.
|
|
471
|
+
carriedHere?.element === undefined &&
|
|
472
|
+
carriedHere?.id !== undefined &&
|
|
473
|
+
carriedHere.id.length > 0) {
|
|
474
|
+
name = carriedHere.id;
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
unnamed.push(node);
|
|
478
|
+
name = `Component ${unnamed.length}`;
|
|
479
|
+
}
|
|
480
|
+
resolved.set(node.id, name);
|
|
481
|
+
return name;
|
|
482
|
+
};
|
|
483
|
+
/* Which nodes are not components: pipeline parts, and evolved twins. */
|
|
484
|
+
const twins = new Set();
|
|
485
|
+
for (const connector of board.connectors) {
|
|
486
|
+
if (connector.role !== WARDLEY_ROLE.changeArrow)
|
|
487
|
+
continue;
|
|
488
|
+
const target = connector.target?.id;
|
|
489
|
+
if (target !== undefined)
|
|
490
|
+
twins.add(target);
|
|
491
|
+
}
|
|
492
|
+
const nodeById = new Map(board.nodes.map(node => [node.id, node]));
|
|
493
|
+
const centreOf = (node) => {
|
|
494
|
+
const box = boxOf(node);
|
|
495
|
+
return owmCoordsOf(plot, box.x + box.w / 2, box.y + box.h / 2);
|
|
496
|
+
};
|
|
497
|
+
const offPlot = [];
|
|
498
|
+
const pair = (node, name) => {
|
|
499
|
+
const { visibility, evolution } = centreOf(node);
|
|
500
|
+
if (visibility < 0 || visibility > 1 || evolution < 0 || evolution > 1) {
|
|
501
|
+
offPlot.push(name);
|
|
502
|
+
}
|
|
503
|
+
return `[${owmNumber(visibility)}, ${owmNumber(evolution)}]`;
|
|
504
|
+
};
|
|
505
|
+
/* ── Sections ─────────────────────────────────────────────────────── */
|
|
506
|
+
const nodeLines = [];
|
|
507
|
+
const pipelineLines = [];
|
|
508
|
+
const methodNodes = [];
|
|
509
|
+
for (const node of board.nodes) {
|
|
510
|
+
if (node.role === undefined)
|
|
511
|
+
continue; // a glyph's own wiring, not an artefact
|
|
512
|
+
if (node.kind === 'handle')
|
|
513
|
+
continue;
|
|
514
|
+
if (twins.has(node.id))
|
|
515
|
+
continue; // written by its `evolve` line
|
|
516
|
+
const name = nameOf(node);
|
|
517
|
+
if (node.kind === 'pipeline') {
|
|
518
|
+
const box = boxOf(node);
|
|
519
|
+
const left = owmCoordsOf(plot, box.x, box.y).evolution;
|
|
520
|
+
const right = owmCoordsOf(plot, box.x + box.w, box.y).evolution;
|
|
521
|
+
pipelineLines.push(`pipeline ${owmName(name)} [${owmNumber(left)}, ${owmNumber(right)}]${tailOf(node)}`);
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
const keyword = OWM_KEYWORD_OF_KIND[node.kind];
|
|
525
|
+
if (keyword === undefined)
|
|
526
|
+
continue;
|
|
527
|
+
if (node.kind === 'method')
|
|
528
|
+
methodNodes.push(name);
|
|
529
|
+
nodeLines.push(`${keyword} ${owmName(name)} ${pair(node, name)}${tailOf(node)}`);
|
|
530
|
+
}
|
|
531
|
+
const noteLines = board.notes.map(note => {
|
|
532
|
+
const box = boxOf(note);
|
|
533
|
+
const { visibility, evolution } = owmCoordsOf(plot, box.x + box.w / 2, box.y + box.h / 2);
|
|
534
|
+
return `note ${owmName(textOf(note))} [${owmNumber(visibility)}, ${owmNumber(evolution)}]${tailOf(note)}`;
|
|
535
|
+
});
|
|
536
|
+
const evolveLines = [];
|
|
537
|
+
const linkLines = [];
|
|
538
|
+
const looseArrows = [];
|
|
539
|
+
const looseLinks = [];
|
|
540
|
+
const movedTwins = [];
|
|
541
|
+
for (const connector of board.connectors) {
|
|
542
|
+
const role = connector.role;
|
|
543
|
+
if (role !== WARDLEY_ROLE.dependency && role !== WARDLEY_ROLE.changeArrow) {
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
const from = connector.source?.id;
|
|
547
|
+
const to = connector.target?.id;
|
|
548
|
+
const source = from === undefined ? undefined : nodeById.get(from);
|
|
549
|
+
const target = to === undefined ? undefined : nodeById.get(to);
|
|
550
|
+
if (role === WARDLEY_ROLE.changeArrow) {
|
|
551
|
+
if (!source || !target) {
|
|
552
|
+
looseArrows.push(connector.id);
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
const was = nameOf(source);
|
|
556
|
+
const becomes = nameOf(target);
|
|
557
|
+
const here = centreOf(source);
|
|
558
|
+
const there = centreOf(target);
|
|
559
|
+
// OWM's `evolve` moves a component along the evolution axis and says
|
|
560
|
+
// nothing about the value chain, so a twin drawn at a different height is
|
|
561
|
+
// a sentence the format has no way to write down.
|
|
562
|
+
if (Math.abs(owmNumberValue(here.visibility) - owmNumberValue(there.visibility)) > 0) {
|
|
563
|
+
movedTwins.push(was);
|
|
564
|
+
}
|
|
565
|
+
evolveLines.push(becomes === was
|
|
566
|
+
? `evolve ${owmName(was)} ${owmNumber(there.evolution)}${tailOf(target)}`
|
|
567
|
+
: `evolve ${evolvePair(was, becomes)} ${owmNumber(there.evolution)}${tailOf(target)}`);
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
if (!source || !target) {
|
|
571
|
+
looseLinks.push(connector.id);
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
// `source` is the CONSUMER and `target` is what it needs — the verb of
|
|
575
|
+
// `wardley:dependency` (ADR 0010, and `reading.ts`). Never inverted here:
|
|
576
|
+
// the arrow the user drew is the statement, and a writer that flipped it
|
|
577
|
+
// would publish the opposite of what the board says.
|
|
578
|
+
linkLines.push(`${owmName(nameOf(source))}->${owmName(nameOf(target))}`);
|
|
579
|
+
}
|
|
580
|
+
// NOT deduplicated. Two identical lines in a file — `// same` twice, two
|
|
581
|
+
// `pioneers` blocks — are two lines the reader carried and counted as two,
|
|
582
|
+
// and collapsing them here would lose one of them against a report that said
|
|
583
|
+
// it was kept. D1's whole promise is that a carried line comes back; "unless
|
|
584
|
+
// it looked like another one" is not a clause it has.
|
|
585
|
+
const carriedLines = carried?.children?.[OWM_SCOPE.document] ?? [];
|
|
586
|
+
/* ── Warnings the writer owes the person who clicked Export ───────── */
|
|
587
|
+
if (unnamed.length > 0) {
|
|
588
|
+
warnings.push(`${unnamed.length} artefact${unnamed.length === 1 ? ' has' : 's have'} no name on the map and ${unnamed.length === 1 ? 'was' : 'were'} written as "Component 1", "Component 2"… — an OWM component is identified by its name.`);
|
|
589
|
+
}
|
|
590
|
+
const duplicates = duplicateNames([...nodeLines, ...pipelineLines]);
|
|
591
|
+
if (duplicates.length > 0) {
|
|
592
|
+
warnings.push(`${duplicates.join(', ')} ${duplicates.length === 1 ? 'is the name of' : 'are the names of'} more than one artefact. OWM identifies a component by its name, so every link naming one of these means the first.`);
|
|
593
|
+
}
|
|
594
|
+
if (offPlot.length > 0) {
|
|
595
|
+
warnings.push(`${offPlot.length} artefact${offPlot.length === 1 ? '' : 's'} sit outside the map's plot, so their coordinates fall outside 0…1 and other tools will draw them off the map: ${offPlot.slice(0, 5).join(', ')}${offPlot.length > 5 ? '…' : ''}.`);
|
|
596
|
+
}
|
|
597
|
+
if (methodNodes.length > 0) {
|
|
598
|
+
warnings.push(`${methodNodes.length} component${methodNodes.length === 1 ? ' carries' : 's carry'} a method (build / buy / outsource). OWM writes a method as a decorator this export cannot tell apart, so ${methodNodes.length === 1 ? 'it was' : 'they were'} written as plain components.`);
|
|
599
|
+
}
|
|
600
|
+
if (looseLinks.length > 0) {
|
|
601
|
+
warnings.push(`${looseLinks.length} link${looseLinks.length === 1 ? ' has an end' : 's have ends'} that is loose or attached to something that is not a Wardley artefact. A link names two components, so ${looseLinks.length === 1 ? 'it was' : 'they were'} left out.`);
|
|
602
|
+
}
|
|
603
|
+
if (looseArrows.length > 0) {
|
|
604
|
+
// Two losses, not one, and the second is the one nobody would guess: the
|
|
605
|
+
// node an evolution arrow points AT is written by the `evolve` line rather
|
|
606
|
+
// than as a component of its own, so when the arrow cannot be written the
|
|
607
|
+
// twin has no line either and leaves the file altogether.
|
|
608
|
+
warnings.push(`${looseArrows.length} evolution arrow${looseArrows.length === 1 ? ' has an end' : 's have ends'} that is loose or attached to something that is not a Wardley artefact, so ${looseArrows.length === 1 ? 'it was' : 'they were'} left out — and so ${looseArrows.length === 1 ? 'was the evolved component it points at, which is written by its `evolve` line and has no line of its own' : 'were the evolved components they point at, which are written by their `evolve` lines and have no lines of their own'}.`);
|
|
609
|
+
}
|
|
610
|
+
if (movedTwins.length > 0) {
|
|
611
|
+
warnings.push(`${movedTwins.length} evolution arrow${movedTwins.length === 1 ? '' : 's'} ends at a different height on the value chain (${movedTwins.slice(0, 5).join(', ')}). OWM's \`evolve\` moves a component along the evolution axis only, so the change of visibility was not written.`);
|
|
612
|
+
}
|
|
613
|
+
/* ── The document ─────────────────────────────────────────────────── */
|
|
614
|
+
const sections = [
|
|
615
|
+
title === undefined ? [] : [`title ${title}`],
|
|
616
|
+
nodeLines,
|
|
617
|
+
pipelineLines,
|
|
618
|
+
noteLines,
|
|
619
|
+
evolveLines,
|
|
620
|
+
linkLines,
|
|
621
|
+
carriedLines,
|
|
622
|
+
].filter(section => section.length > 0);
|
|
623
|
+
const text = sections.map(section => section.join('\n')).join('\n\n') + '\n';
|
|
624
|
+
return { text, warnings };
|
|
625
|
+
}
|
|
626
|
+
/** The value a coordinate is WRITTEN as, so a comparison agrees with the file. */
|
|
627
|
+
function owmNumberValue(value) {
|
|
628
|
+
return Number(owmNumber(value));
|
|
629
|
+
}
|
|
630
|
+
/** The names two artefacts share, in first-seen order. */
|
|
631
|
+
function duplicateNames(lines) {
|
|
632
|
+
const seen = new Set();
|
|
633
|
+
const twice = new Set();
|
|
634
|
+
for (const line of lines) {
|
|
635
|
+
// `<keyword> <name> [` — the name is everything between the two.
|
|
636
|
+
const match = /^\S+\s+(.*?)\s+\[/.exec(line);
|
|
637
|
+
if (!match)
|
|
638
|
+
continue;
|
|
639
|
+
const name = match[1];
|
|
640
|
+
if (seen.has(name))
|
|
641
|
+
twice.add(name);
|
|
642
|
+
seen.add(name);
|
|
643
|
+
}
|
|
644
|
+
return [...twice];
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* The board as an OWM document — models in, text out, and nothing else.
|
|
648
|
+
*
|
|
649
|
+
* The signature P3 names: exported from this package's index so that labre-mcp
|
|
650
|
+
* calls THIS function rather than keeping the copy ADR 0012 records as the one
|
|
651
|
+
* violation of it.
|
|
652
|
+
*/
|
|
653
|
+
export function exportWardleyOwm(board, options = {}) {
|
|
654
|
+
return exportWardleyOwmWithWarnings(board, options).text;
|
|
655
|
+
}
|
package/dist/gradient.js
CHANGED
|
@@ -45,7 +45,7 @@ const RG = rangeOf(fDiff, DIFF_DOM[0], DIFF_DOM[1]);
|
|
|
45
45
|
const RR = rangeOf(fOper, OPER_DOM[0], OPER_DOM[1]);
|
|
46
46
|
const RB = rangeOf(fBen, 0, 1);
|
|
47
47
|
const clamp01 = (v) => Math.max(0, Math.min(1, v));
|
|
48
|
-
const norm = (v, lo, hi) =>
|
|
48
|
+
const norm = (v, lo, hi) => hi > lo ? (v - lo) / (hi - lo) : 0;
|
|
49
49
|
export const GRADIENT_GREEN = '#1f9e4d';
|
|
50
50
|
export const GRADIENT_RED = '#d6455d';
|
|
51
51
|
const GRADIENT_GREY = '#7c8389';
|