@formicoidea/labre-framework-wardley 0.42.0 → 0.45.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.
@@ -0,0 +1,309 @@
1
+ import { sanitizeSvg, sketchSvgTree, SVG_SKETCH_EMPTY, SvgSketchNotebook, } from '@formicoidea/labre-core/blocks/surface';
2
+ import { FontFamily, TextAlign } from '@formicoidea/labre-core/model';
3
+ import { fillPlaceholders } from '@formicoidea/labre-core/shared/services';
4
+ import { OWM_DEFAULT_MAP_WIDTH, owmCoordsOf, owmDefaultPlot, WARDLEY_OWM_FORMAT_ID, } from './export.js';
5
+ import { layoutWardleyStatements } from './import.js';
6
+ import { NODE_STROKE } from './node/consts.js';
7
+ import { recogniseLabreExport } from './svg-recognise-labre.js';
8
+ import { recogniseOnlineWardleyMaps } from './svg-recognise-owm.js';
9
+ import { recogniseWardleyMapRenderer } from './svg-recognise-renderer.js';
10
+ import { recogniseByShape } from './svg-recognise-shapes.js';
11
+ import { WARDLEY_SVG_IMPORT_REMARKS } from './svg-remarks.js';
12
+ /**
13
+ * **A Wardley SVG, read as the map it is a picture of** (ADR 0032).
14
+ *
15
+ * ## The heuristics statement, and its known failure modes
16
+ *
17
+ * _ADR 0012's open question 2, answered for Wardley beside the parser that
18
+ * makes the guesses. What reaches the shared sketch walk is covered by that
19
+ * walk's own statement (`blocks/surface/src/extensions/svg-sketch.ts`); this
20
+ * one covers what is CLAIMED before it._
21
+ *
22
+ * **What it guesses, and from what.** Roles, relations and coordinates — and
23
+ * only when it can say where they came from. A producer is detected by a
24
+ * structural marker on the sanitised tree, never by searching the text, in a
25
+ * fixed order that stops at the first certain match: Labre's own board export
26
+ * (its `data-labre-svg` root and per-element `data-labre-*` markers, which
27
+ * state roles exactly, `svg-recognise-labre.ts`), then wardley-map-renderer
28
+ * (its `axes` and `nodes` layers, `svg-recognise-renderer.ts`), then
29
+ * OnlineWardleyMaps (an element marker inside its own movable wrapper,
30
+ * `svg-recognise-owm.ts`), then — no marker at all — the shapes themselves
31
+ * (`svg-recognise-shapes.ts`), which promote components and dependencies and
32
+ * nothing else, and only once they have found the two axes of a plot; with
33
+ * no plot, the whole file is a sketch and the report says no axes were found.
34
+ * A recogniser reads the producer's components,
35
+ * anchors, markets, ecosystems, climate arrows, pipelines, evolved twins,
36
+ * inertia bars, notes, dependencies and title, and the PLOT they sit on;
37
+ * `[visibility, evolution]` is then read off that plot (`owmCoordsOf`) and
38
+ * laid on a new native board at the reference size with the OWM importer's own
39
+ * layout (`layoutWardleyStatements`), so a map that arrives as a picture and
40
+ * one that arrives as `.owm` text are the same elements.
41
+ *
42
+ * **Where it is known to be wrong.** A name is what the producer DREW: a
43
+ * truncated or wrapped label arrives truncated or joined. A producer certain
44
+ * but plot-less file (its `fillArea` stripped) is laid out over the extent of
45
+ * its own components, and the report says those coordinates were ESTIMATED
46
+ * (`invented-layout`). OnlineWardleyMaps draws a climate arrow with no name, so
47
+ * one arrives unnamed. The renderer's dependencies name no node — their
48
+ * `data-id` is the relation's own — so their ends are bound by geometry in
49
+ * both modes, each within its node's reach; its static output carries no id
50
+ * at all, so a name is the label drawn nearest its node, closest pairs first,
51
+ * and two nodes crowded closer than their labels can swap names. The renderer
52
+ * draws no evolved twin, so the twin takes the moving node's name. Methods,
53
+ * annotations, PST boxes, steps and flow labels have no native
54
+ * artefact and arrive as a sketch. The title is drawn as a free text above the
55
+ * board, because a board stores no title of its own. Nothing of the source
56
+ * tool's MODEL beyond the drawing survives: the OWM DSL stays the reference
57
+ * Wardley import.
58
+ *
59
+ * **Everything else is the sketch's.** What the recogniser did not consume —
60
+ * a logo, a hand-drawn remark, an artefact it has no sentence for — is read by
61
+ * the shared walk exactly as before, placed so that the file's plot lands on
62
+ * the board's plot (a uniform scale, centred), in the SAME result: one import,
63
+ * one undo step. The report counts it in one `warning`.
64
+ *
65
+ * ## Still the visual tier
66
+ *
67
+ * No `interchange` payload on any element, `carried` and `quarantined` always
68
+ * `0`, no round-trip promised (ADR 0012 P2, which ADR 0032 §1 keeps). The OWM
69
+ * layout attaches `interchange.owm` to the elements a later statement refers
70
+ * to; here that identity becomes a provisional local `id` the materializer
71
+ * replaces with a nanoid, and the payload is dropped.
72
+ *
73
+ * ## Ids are ids
74
+ *
75
+ * A producer's id, forged or not, is only ever a provisional local name and a
76
+ * `Map` key (`svg-read.ts`); a name is the text the sanitised tree holds; a
77
+ * coordinate that is not a finite number sends its element to the sketch with
78
+ * a remark. Nothing is evaluated: the sanitiser has already removed what could
79
+ * run, and this reads attributes and text content only.
80
+ *
81
+ * Pure, like every reader (P3): exported from the package index, so
82
+ * labre-mcp calls the same function the command does.
83
+ */
84
+ /** Detection order (ADR 0032 §4): the first certain match wins. */
85
+ const RECOGNISERS = [
86
+ recogniseLabreExport,
87
+ recogniseWardleyMapRenderer,
88
+ recogniseOnlineWardleyMaps,
89
+ recogniseByShape,
90
+ ];
91
+ export function importWardleySvg(source, context = {}) {
92
+ // What a board is called is the document's business; the map's own title,
93
+ // when the picture drew one, is drawn back as a text.
94
+ void context;
95
+ const notebook = new SvgSketchNotebook();
96
+ const root = sanitizeSvg(source, notebook);
97
+ let drawn;
98
+ for (const recognise of RECOGNISERS) {
99
+ drawn = recognise(root);
100
+ if (drawn)
101
+ break;
102
+ }
103
+ if (!drawn)
104
+ return sketchOnly(root, notebook);
105
+ return mapAndSketch(root, notebook, drawn);
106
+ }
107
+ /**
108
+ * Nothing recognised — no producer marked the file and no axes were found —
109
+ * so today's sketch, word for word, plus the one sentence that says why
110
+ * (ADR 0032 §5).
111
+ */
112
+ function sketchOnly(root, notebook) {
113
+ const elements = sketchSvgTree(root, notebook);
114
+ if (elements.length === 0) {
115
+ notebook.once('empty', SVG_SKETCH_EMPTY[1], SVG_SKETCH_EMPTY);
116
+ }
117
+ return {
118
+ elements,
119
+ report: {
120
+ mapped: elements.length,
121
+ carried: 0,
122
+ quarantined: 0,
123
+ notes: [
124
+ {
125
+ kind: 'warning',
126
+ message: WARDLEY_SVG_IMPORT_REMARKS.noAxes[1],
127
+ messageKey: WARDLEY_SVG_IMPORT_REMARKS.noAxes[0],
128
+ },
129
+ ...notebook.notes,
130
+ ],
131
+ },
132
+ };
133
+ }
134
+ /** The extent of what was drawn, standing in for a plot the file lost. */
135
+ function extentPlot(drawn) {
136
+ const xs = [
137
+ ...drawn.nodes.map(node => node.x),
138
+ ...drawn.notes.map(note => note.x),
139
+ ];
140
+ const ys = [
141
+ ...drawn.nodes.map(node => node.y),
142
+ ...drawn.notes.map(note => note.y),
143
+ ];
144
+ if (xs.length === 0)
145
+ return { x0: 0, y0: 0, width: 1, height: 1 };
146
+ const x0 = Math.min(...xs);
147
+ const y0 = Math.min(...ys);
148
+ return {
149
+ x0,
150
+ y0,
151
+ width: Math.max(Math.max(...xs) - x0, 1),
152
+ height: Math.max(Math.max(...ys) - y0, 1),
153
+ };
154
+ }
155
+ /**
156
+ * The frame that lands the file's plot on the board's: one uniform scale (a
157
+ * sketch has no independent axes), centred — the default
158
+ * `preserveAspectRatio` of the format itself.
159
+ */
160
+ function placeOnBoard(file, board) {
161
+ const s = Math.min(board.width / file.width, board.height / file.height);
162
+ return {
163
+ s,
164
+ ox: board.x0 + board.width / 2 - s * (file.x0 + file.width / 2),
165
+ oy: board.y0 + board.height / 2 - s * (file.y0 + file.height / 2),
166
+ };
167
+ }
168
+ function mapAndSketch(root, notebook, drawn) {
169
+ const notes = [...drawn.remarks];
170
+ const plot = drawn.plot ?? extentPlot(drawn);
171
+ if (!drawn.plot) {
172
+ notes.push({
173
+ kind: 'invented-layout',
174
+ element: 'map',
175
+ message: WARDLEY_SVG_IMPORT_REMARKS.inventedPlot[1],
176
+ messageKey: WARDLEY_SVG_IMPORT_REMARKS.inventedPlot[0],
177
+ });
178
+ }
179
+ const at = (x, y) => owmCoordsOf(plot, x, y);
180
+ const byId = new Map(drawn.nodes.map(node => [node.id, node]));
181
+ const statements = {
182
+ nodes: drawn.nodes.map(node => ({
183
+ keyword: node.kind,
184
+ name: node.name,
185
+ id: node.id,
186
+ ...at(node.x, node.y),
187
+ tail: '',
188
+ invented: false,
189
+ })),
190
+ pipelines: drawn.pipelines.flatMap(pipeline => {
191
+ if (!('of' in pipeline)) {
192
+ return [
193
+ {
194
+ name: pipeline.name,
195
+ id: pipeline.id,
196
+ from: at(pipeline.x1, pipeline.top).evolution,
197
+ to: at(pipeline.x2, pipeline.top).evolution,
198
+ top: at(pipeline.x1, pipeline.top).visibility,
199
+ tail: '',
200
+ invented: false,
201
+ },
202
+ ];
203
+ }
204
+ const owner = byId.get(pipeline.of);
205
+ if (!owner)
206
+ return [];
207
+ return [
208
+ {
209
+ name: owner.name,
210
+ id: owner.id,
211
+ from: at(pipeline.x1, owner.y).evolution,
212
+ to: at(pipeline.x2, owner.y).evolution,
213
+ tail: '',
214
+ invented: false,
215
+ },
216
+ ];
217
+ }),
218
+ notes: drawn.notes.map(note => ({
219
+ text: note.text,
220
+ ...at(note.x, note.y),
221
+ tail: '',
222
+ invented: false,
223
+ })),
224
+ evolutions: drawn.evolutions.flatMap(evolution => {
225
+ const owner = byId.get(evolution.of);
226
+ if (!owner)
227
+ return [];
228
+ return [
229
+ {
230
+ name: owner.name,
231
+ id: owner.id,
232
+ becomes: evolution.name,
233
+ evolution: at(evolution.x, owner.y).evolution,
234
+ tail: '',
235
+ invented: false,
236
+ },
237
+ ];
238
+ }),
239
+ links: drawn.links,
240
+ inertias: drawn.inertias.map(inertia => at(inertia.x, inertia.y)),
241
+ };
242
+ const native = layoutWardleyStatements(statements, {
243
+ onInvented: (element, name, why, whyKey) => notes.push({
244
+ kind: 'invented-layout',
245
+ sourceId: name,
246
+ element,
247
+ message: why,
248
+ ...(whyKey ? { messageKey: whyKey } : {}),
249
+ }),
250
+ }).map(provisional);
251
+ if (drawn.title)
252
+ native.push(titleProps(drawn.title));
253
+ const board = owmDefaultPlot();
254
+ const sketch = sketchSvgTree(root, notebook, {
255
+ skip: drawn.consumed,
256
+ place: placeOnBoard(plot, board),
257
+ });
258
+ if (sketch.length > 0) {
259
+ notes.push({
260
+ kind: 'warning',
261
+ message: fillPlaceholders(WARDLEY_SVG_IMPORT_REMARKS.sketchedRemainder[1], {
262
+ count: sketch.length,
263
+ }),
264
+ messageKey: WARDLEY_SVG_IMPORT_REMARKS.sketchedRemainder[0],
265
+ messageParams: { count: sketch.length },
266
+ });
267
+ }
268
+ const elements = [...native, ...sketch];
269
+ return {
270
+ elements,
271
+ report: {
272
+ mapped: elements.length,
273
+ carried: 0,
274
+ quarantined: 0,
275
+ notes: [...notes, ...notebook.notes],
276
+ sourceVersion: drawn.producer,
277
+ },
278
+ };
279
+ }
280
+ /**
281
+ * One laid-out element with its OWM identity turned into a provisional local
282
+ * id, and the payload gone: the identity wires the connectors
283
+ * (`materializeInterchangeImport`), and a picture carries nothing to round-trip.
284
+ */
285
+ function provisional(props) {
286
+ const carried = props.interchange;
287
+ if (carried === undefined)
288
+ return props;
289
+ const rest = { ...props };
290
+ delete rest.interchange;
291
+ const id = carried[WARDLEY_OWM_FORMAT_ID]?.id;
292
+ return typeof id === 'string' ? { ...rest, id } : rest;
293
+ }
294
+ /**
295
+ * The map's title, where the shipped maps draw theirs: centred above the plot,
296
+ * across the board (`templates/maps.ts`). A free text, because a board stores
297
+ * no title of its own.
298
+ */
299
+ function titleProps(text) {
300
+ return {
301
+ type: 'text',
302
+ text,
303
+ color: NODE_STROKE,
304
+ fontFamily: FontFamily.Inter,
305
+ fontSize: 28,
306
+ textAlign: TextAlign.Center,
307
+ xywh: `[${OWM_DEFAULT_MAP_WIDTH / 2 - 500},12,1000,40]`,
308
+ };
309
+ }
@@ -0,0 +1,108 @@
1
+ import type { InterchangeNote } from '@formicoidea/labre-core/blocks/surface';
2
+ import type { OwmPlot } from './export.js';
3
+ import type { WardleyNodeKeyword } from './import.js';
4
+ /**
5
+ * What a recogniser reads off a picture, before anything is laid out — the
6
+ * shared shape every producer's recogniser answers in (ADR 0032 §2.2).
7
+ *
8
+ * Everything is in the FILE's coordinates, exactly as the shared sketch walk
9
+ * would place it (`svgFrameOf`), so `svg-import.ts` can turn a point into
10
+ * `[visibility, evolution]` against the plot the recogniser found, and place
11
+ * the remainder with the same arithmetic.
12
+ */
13
+ export interface DrawnWardleyNode {
14
+ /** The producer's own id, used as a provisional local name and nothing else. */
15
+ id: string;
16
+ kind: WardleyNodeKeyword;
17
+ /** What the producer drew beside it, verbatim (truncated stays truncated). */
18
+ name: string;
19
+ x: number;
20
+ y: number;
21
+ }
22
+ export interface DrawnWardleyMap {
23
+ /** `sourceVersion`: the producer, and the marker version it read. */
24
+ producer: string;
25
+ /** The plot, read off the file; absent when the producer is certain but the file lost it. */
26
+ plot?: OwmPlot;
27
+ title?: string;
28
+ nodes: DrawnWardleyNode[];
29
+ /**
30
+ * A pipeline body: either hung under a component (`of`, OnlineWardleyMaps'
31
+ * reading), or standing alone with its own id, name and top edge (`top`,
32
+ * the renderer's).
33
+ */
34
+ pipelines: ({
35
+ of: string;
36
+ x1: number;
37
+ x2: number;
38
+ } | {
39
+ id: string;
40
+ name: string;
41
+ top: number;
42
+ x1: number;
43
+ x2: number;
44
+ })[];
45
+ /** An evolved twin: which node moves, what the twin is called, where it lands. */
46
+ evolutions: {
47
+ of: string;
48
+ name: string;
49
+ x: number;
50
+ }[];
51
+ /** Consumer, then what it needs (ADR 0010), by producer id. */
52
+ links: {
53
+ from: string;
54
+ to: string;
55
+ }[];
56
+ notes: {
57
+ text: string;
58
+ x: number;
59
+ y: number;
60
+ }[];
61
+ inertias: {
62
+ x: number;
63
+ y: number;
64
+ }[];
65
+ /** Every node the recogniser turned into the above; the sketch skips them. */
66
+ consumed: Set<Element>;
67
+ /** What the recogniser has to say about the file (dangling ends, bad numbers). */
68
+ remarks: InterchangeNote[];
69
+ }
70
+ /**
71
+ * A recogniser's answer before it has read anything, and the one way it marks
72
+ * an element as turned into a native one (so the sketch skips it).
73
+ *
74
+ * `head` is spread first, so the result's keys keep the order every
75
+ * recogniser wrote them in — a `plot` passed here, even `undefined`, sits
76
+ * after `producer`; one found later is assigned at the end.
77
+ */
78
+ export declare function startDrawnMap(head: Pick<DrawnWardleyMap, 'producer'> & Partial<Pick<DrawnWardleyMap, 'plot'>>): {
79
+ drawn: DrawnWardleyMap;
80
+ consume: (element: Element | undefined) => void;
81
+ };
82
+ /**
83
+ * Every element carrying an `id`, first wins — the index a producer's markers
84
+ * are looked up in.
85
+ *
86
+ * A `Map`, never an object: a hostile file's `__proto__` or `constructor` is a
87
+ * key like any other here, and can never reach a prototype (ADR 0032 §7).
88
+ */
89
+ export declare function idIndex(root: Element): Map<string, Element>;
90
+ /**
91
+ * A text's words, collapsed the way the sketch collapses a label — one span
92
+ * per line joined by a space, so a name a producer wrapped over two `<tspan>`s
93
+ * reads as the name and not as two words glued together.
94
+ */
95
+ export declare function textOf(element: Element | undefined): string;
96
+ /**
97
+ * Where an element's own user-space origin lands, or `undefined` when the
98
+ * file does not give it as numbers.
99
+ *
100
+ * The shared frame reader is lenient — it reads the numbers out of a
101
+ * transform and skips what is not one — which is right for a sketch and wrong
102
+ * for a coordinate this import is about to present as READ. So an element
103
+ * whose own `transform` is anything but a translate of finite numbers is
104
+ * refused here, and the caller sends it to the sketch with a remark.
105
+ */
106
+ export declare function originOf(element: Element): [number, number] | undefined;
107
+ /** A finite number off an attribute, or `undefined`. */
108
+ export declare function finiteAttr(element: Element, name: string): number | undefined;
@@ -0,0 +1,89 @@
1
+ import { svgFrameOf } from '@formicoidea/labre-core/blocks/surface';
2
+ /**
3
+ * A recogniser's answer before it has read anything, and the one way it marks
4
+ * an element as turned into a native one (so the sketch skips it).
5
+ *
6
+ * `head` is spread first, so the result's keys keep the order every
7
+ * recogniser wrote them in — a `plot` passed here, even `undefined`, sits
8
+ * after `producer`; one found later is assigned at the end.
9
+ */
10
+ export function startDrawnMap(head) {
11
+ const drawn = {
12
+ ...head,
13
+ nodes: [],
14
+ pipelines: [],
15
+ evolutions: [],
16
+ links: [],
17
+ notes: [],
18
+ inertias: [],
19
+ consumed: new Set(),
20
+ remarks: [],
21
+ };
22
+ const consume = (element) => {
23
+ if (element)
24
+ drawn.consumed.add(element);
25
+ };
26
+ return { drawn, consume };
27
+ }
28
+ /**
29
+ * Every element carrying an `id`, first wins — the index a producer's markers
30
+ * are looked up in.
31
+ *
32
+ * A `Map`, never an object: a hostile file's `__proto__` or `constructor` is a
33
+ * key like any other here, and can never reach a prototype (ADR 0032 §7).
34
+ */
35
+ export function idIndex(root) {
36
+ const index = new Map();
37
+ for (const element of Array.from(root.querySelectorAll('[id]'))) {
38
+ const id = element.getAttribute('id');
39
+ if (id !== null && !index.has(id))
40
+ index.set(id, element);
41
+ }
42
+ return index;
43
+ }
44
+ /**
45
+ * A text's words, collapsed the way the sketch collapses a label — one span
46
+ * per line joined by a space, so a name a producer wrapped over two `<tspan>`s
47
+ * reads as the name and not as two words glued together.
48
+ */
49
+ export function textOf(element) {
50
+ if (!element)
51
+ return '';
52
+ const spans = Array.from(element.children).filter(child => child.localName === 'tspan');
53
+ const raw = spans.length > 0
54
+ ? spans.map(span => span.textContent ?? '').join(' ')
55
+ : (element.textContent ?? '');
56
+ return raw.replace(/\s+/g, ' ').trim();
57
+ }
58
+ /**
59
+ * Where an element's own user-space origin lands, or `undefined` when the
60
+ * file does not give it as numbers.
61
+ *
62
+ * The shared frame reader is lenient — it reads the numbers out of a
63
+ * transform and skips what is not one — which is right for a sketch and wrong
64
+ * for a coordinate this import is about to present as READ. So an element
65
+ * whose own `transform` is anything but a translate of finite numbers is
66
+ * refused here, and the caller sends it to the sketch with a remark.
67
+ */
68
+ export function originOf(element) {
69
+ const raw = element.getAttribute('transform');
70
+ if (raw !== null && raw.trim().length > 0) {
71
+ const match = /^\s*translate\(\s*([^,\s)]+)(?:[\s,]+([^,\s)]+))?\s*\)\s*$/.exec(raw);
72
+ if (!match)
73
+ return undefined;
74
+ for (const value of [match[1], match[2] ?? '0']) {
75
+ if (!Number.isFinite(Number(value)))
76
+ return undefined;
77
+ }
78
+ }
79
+ const frame = svgFrameOf(element);
80
+ return [frame.ox, frame.oy];
81
+ }
82
+ /** A finite number off an attribute, or `undefined`. */
83
+ export function finiteAttr(element, name) {
84
+ const raw = element.getAttribute(name);
85
+ if (raw === null || raw.trim().length === 0)
86
+ return undefined;
87
+ const value = Number(raw);
88
+ return Number.isFinite(value) ? value : undefined;
89
+ }
@@ -0,0 +1,2 @@
1
+ import { type DrawnWardleyMap } from './svg-read.js';
2
+ export declare function recogniseLabreExport(root: Element): DrawnWardleyMap | undefined;