@formicoidea/labre-framework-wardley 0.43.1 → 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,385 @@
1
+ import { svgFrameOf } from '@formicoidea/labre-core/blocks/surface';
2
+ import { finiteAttr, startDrawnMap, textOf, } from './svg-read.js';
3
+ import { WARDLEY_SVG_IMPORT_REMARKS } from './svg-remarks.js';
4
+ /**
5
+ * A wardley-map-renderer SVG (labre-mcp's renderer), recognised (ADR 0032
6
+ * §4.2).
7
+ *
8
+ * Read against `src/render/*` of that renderer and the corpus files it
9
+ * rendered. Every layer is a `<g data-layer="…">` — `title`, `axes`,
10
+ * `pipelines`, `edges`, `evolvesTo`, `nodes`, `steps`, `accelerators`,
11
+ * `labels`, `notes`, `legend` — in BOTH modes; the interactive mode adds
12
+ * `data-id` / `data-kind` on one group per artefact. So the producer is
13
+ * certain from the `axes` and `nodes` layers alone, and the mode only decides
14
+ * how much is read by id rather than by geometry:
15
+ *
16
+ * - a node is one or more concentric shapes in the `nodes` layer: a plain
17
+ * circle (a component), with a `clipPath` `anchor-clip-<id>` and a
18
+ * silhouette (an anchor), a triangle `<polygon>` and three rings inside a
19
+ * 16-unit circle (a market), or a 30-unit grey disc (an ecosystem); a
20
+ * method's aura is one more concentric circle. Interactive: one
21
+ * `g[data-kind=component][data-id]` per node, whose `data-id` is its id;
22
+ * static: no id at all, so the node is named by its index;
23
+ * - a name is a `<text>` of the `labels` layer: by `data-id` in interactive
24
+ * mode, and in static mode by GEOMETRY — every label is matched to the
25
+ * nearest node or pipeline handle, closest pairs first, because the
26
+ * renderer orders labels by input and handles after components;
27
+ * - a dependency is a `<line>` of the `edges` layer, consumer end first; it
28
+ * names no node in either mode (its `data-id` is the RELATION's), so both
29
+ * ends are bound by geometry: each end within its node's own reach;
30
+ * - an evolution is a dashed line of the `evolvesTo` layer leaving a node,
31
+ * and an inertia bar the 6-wide line across it; the renderer draws no twin,
32
+ * so the twin takes the node's name;
33
+ * - a pipeline is a `<rect>` of the `pipelines` layer with its own handle
34
+ * square in `nodes` and its own label: it stands alone, it does not hang
35
+ * under a component;
36
+ * - the plot is the two axis lines: the x axis from `(left, bottom)` to
37
+ * `(right, bottom)`, the y axis from `(left, bottom)` to `(left, top)`.
38
+ *
39
+ * The axes layer, the background rect and the renderer's own legend are
40
+ * chrome the native board redraws; `steps`, `accelerators`, flow labels and
41
+ * anything else are left to the sketch.
42
+ */
43
+ const LAYER = (root, name) => root.querySelector(`g[data-layer="${name}"]`) ?? undefined;
44
+ /** How far a dependency's end may sit from the centre of its node. */
45
+ const END_REACH = 6;
46
+ /** A circle's centre and radius in file units, or `undefined` when unreadable. */
47
+ function circleOf(element) {
48
+ const [cx, cy, r] = ['cx', 'cy', 'r'].map(name => finiteAttr(element, name));
49
+ if (cx === undefined || cy === undefined || r === undefined)
50
+ return undefined;
51
+ const frame = svgFrameOf(element);
52
+ return {
53
+ element,
54
+ x: frame.ox + cx * frame.s,
55
+ y: frame.oy + cy * frame.s,
56
+ r: r * frame.s,
57
+ };
58
+ }
59
+ /** A line's two ends in file units. */
60
+ function lineOf(element) {
61
+ const values = ['x1', 'y1', 'x2', 'y2'].map(name => finiteAttr(element, name));
62
+ if (values.some(value => value === undefined))
63
+ return undefined;
64
+ const frame = svgFrameOf(element);
65
+ const [x1, y1, x2, y2] = values;
66
+ return [
67
+ frame.ox + x1 * frame.s,
68
+ frame.oy + y1 * frame.s,
69
+ frame.ox + x2 * frame.s,
70
+ frame.oy + y2 * frame.s,
71
+ ];
72
+ }
73
+ /** A rect's box in file units. */
74
+ function rectOf(element) {
75
+ const values = ['x', 'y', 'width', 'height'].map(name => finiteAttr(element, name));
76
+ if (values.some(value => value === undefined))
77
+ return undefined;
78
+ const frame = svgFrameOf(element);
79
+ const [x, y, w, h] = values;
80
+ return [
81
+ frame.ox + x * frame.s,
82
+ frame.oy + y * frame.s,
83
+ w * frame.s,
84
+ h * frame.s,
85
+ ];
86
+ }
87
+ const visible = (element) => !element.classList.contains('hit-area') &&
88
+ element.getAttribute('stroke') !== 'transparent';
89
+ /** The plot off the two axis lines, or `undefined`. */
90
+ function plotOf(axes) {
91
+ const lines = Array.from(axes.querySelectorAll('line'))
92
+ .map(lineOf)
93
+ .filter((line) => !!line);
94
+ const xAxis = lines
95
+ .filter(([, y1, , y2]) => Math.abs(y1 - y2) < 0.5)
96
+ .sort((a, b) => Math.abs(b[2] - b[0]) - Math.abs(a[2] - a[0]))[0];
97
+ if (!xAxis)
98
+ return undefined;
99
+ const [left, bottom, right] = xAxis;
100
+ const yAxis = lines.find(([x1, y1, x2, y2]) => Math.abs(x1 - left) < 0.5 &&
101
+ Math.abs(x2 - left) < 0.5 &&
102
+ Math.abs(y1 - bottom) < 0.5 &&
103
+ y2 < y1);
104
+ if (!yAxis || right <= left)
105
+ return undefined;
106
+ const top = yAxis[3];
107
+ return { x0: left, y0: top, width: right - left, height: bottom - top };
108
+ }
109
+ /**
110
+ * The nodes of the `nodes` layer: concentric shapes clustered under the
111
+ * largest circle that holds them, then named by what the cluster contains.
112
+ */
113
+ function nodesOf(layer, live) {
114
+ const found = [];
115
+ const clusters = [];
116
+ const units = live
117
+ ? Array.from(layer.children).filter(child => child.getAttribute('data-kind') === 'component' &&
118
+ child.hasAttribute('data-id'))
119
+ : [layer];
120
+ for (const unit of units) {
121
+ const circles = Array.from(unit.children)
122
+ .filter(child => child.localName === 'circle')
123
+ .map(circleOf)
124
+ .filter((shape) => !!shape);
125
+ for (const circle of circles) {
126
+ const home = live
127
+ ? clusters.find(cluster => cluster.container === unit)
128
+ : clusters.find(cluster => cluster.shapes.some(shape => Math.hypot(shape.x - circle.x, shape.y - circle.y) <
129
+ Math.max(shape.r, circle.r)));
130
+ if (home)
131
+ home.shapes.push(circle);
132
+ else
133
+ clusters.push({ shapes: [circle], container: unit });
134
+ }
135
+ }
136
+ const anchors = new Set(Array.from(layer.querySelectorAll('clipPath'))
137
+ .filter(clip => (clip.getAttribute('id') ?? '').startsWith('anchor-clip-'))
138
+ .map(clip => clip.querySelector('circle'))
139
+ .map(circle => (circle ? circleOf(circle) : undefined))
140
+ .filter((shape) => !!shape)
141
+ .map(shape => `${shape.x},${shape.y}`));
142
+ const polygons = Array.from(layer.querySelectorAll('polygon'));
143
+ clusters.forEach((cluster, index) => {
144
+ const outer = cluster.shapes.reduce((a, b) => (b.r > a.r ? b : a));
145
+ // The node's own circle: concentric with the outermost one.
146
+ const centre = cluster.shapes.find(shape => Math.hypot(shape.x - outer.x, shape.y - outer.y) < 0.5);
147
+ const market = polygons.some(polygon => {
148
+ const box = polygon.getAttribute('points') ?? '';
149
+ const xs = box.match(/-?[\d.]+/g)?.map(Number) ?? [];
150
+ const px = (xs[0] + xs[2] + xs[4]) / 3;
151
+ const py = (xs[1] + xs[3] + xs[5]) / 3;
152
+ return Math.hypot(px - outer.x, py - outer.y) < outer.r;
153
+ });
154
+ const kind = anchors.has(`${centre.x},${centre.y}`)
155
+ ? 'anchor'
156
+ : market && outer.r >= 12
157
+ ? 'market'
158
+ : outer.r >= 25
159
+ ? 'ecosystem'
160
+ : 'component';
161
+ const id = live
162
+ ? (cluster.container.getAttribute('data-id') ?? `node ${index + 1}`)
163
+ : `node ${index + 1}`;
164
+ found.push({
165
+ node: { id, kind, name: '', x: centre.x, y: centre.y },
166
+ group: live ? cluster.container : undefined,
167
+ r: Math.max(centre.r, END_REACH),
168
+ });
169
+ });
170
+ return found;
171
+ }
172
+ export function recogniseWardleyMapRenderer(root) {
173
+ const axes = LAYER(root, 'axes');
174
+ const nodesLayer = LAYER(root, 'nodes');
175
+ if (!axes || !nodesLayer)
176
+ return undefined;
177
+ const live = root.querySelector('[data-id][data-kind]') !== null;
178
+ const { drawn, consume } = startDrawnMap({
179
+ producer: live
180
+ ? 'wardley-map-renderer SVG (interactive)'
181
+ : 'wardley-map-renderer SVG',
182
+ plot: plotOf(axes),
183
+ });
184
+ /* Chrome: redrawn by the native board. */
185
+ consume(axes);
186
+ consume(LAYER(root, 'legend'));
187
+ const backdrop = Array.from(root.children).find(child => child.localName === 'rect' &&
188
+ (child.getAttribute('data-kind') === 'background' ||
189
+ root.firstElementChild === child));
190
+ consume(backdrop);
191
+ const titleLayer = LAYER(root, 'title');
192
+ const title = textOf(titleLayer?.querySelector('text') ?? undefined);
193
+ if (title.length > 0) {
194
+ drawn.title = title;
195
+ consume(titleLayer);
196
+ }
197
+ /* Nodes. */
198
+ const nodes = nodesOf(nodesLayer, live);
199
+ for (const { node } of nodes)
200
+ drawn.nodes.push(node);
201
+ // Every node is read, so the layer's own shapes are all consumed; a
202
+ // pipeline handle is consumed with its pipeline below.
203
+ for (const child of Array.from(nodesLayer.children)) {
204
+ const handle = child.getAttribute('data-part') === 'handle' ||
205
+ (child.localName === 'rect' &&
206
+ child.getAttribute('width') === child.getAttribute('height'));
207
+ if (!handle)
208
+ consume(child);
209
+ }
210
+ /* Pipelines: a rect, its handle square and its label. */
211
+ const handles = Array.from(nodesLayer.children).filter(child => child.getAttribute('data-part') === 'handle' ||
212
+ (child.localName === 'rect' &&
213
+ child.getAttribute('width') === child.getAttribute('height')));
214
+ const pipelineLayer = LAYER(root, 'pipelines');
215
+ const pipelines = [];
216
+ for (const unit of Array.from(pipelineLayer?.children ?? [])) {
217
+ const rect = unit.localName === 'rect'
218
+ ? unit
219
+ : (unit.querySelector('rect') ?? undefined);
220
+ const box = rect ? rectOf(rect) : undefined;
221
+ if (!box)
222
+ continue;
223
+ const id = unit.getAttribute('data-id') ?? `pipeline ${pipelines.length + 1}`;
224
+ const handle = handles
225
+ .map(candidate => {
226
+ const square = (candidate.localName === 'rect'
227
+ ? [candidate]
228
+ : Array.from(candidate.querySelectorAll('rect')).filter(visible))[0];
229
+ const at = square ? rectOf(square) : undefined;
230
+ return at
231
+ ? {
232
+ element: candidate,
233
+ x: at[0] + at[2] / 2,
234
+ y: at[1] + at[3] / 2,
235
+ id: candidate.getAttribute('data-id'),
236
+ }
237
+ : undefined;
238
+ })
239
+ .find(candidate => candidate &&
240
+ (candidate.id === id ||
241
+ (candidate.id === null &&
242
+ candidate.x >= box[0] &&
243
+ candidate.x <= box[0] + box[2] &&
244
+ Math.abs(candidate.y - box[1]) < box[3])));
245
+ pipelines.push({
246
+ id,
247
+ box,
248
+ handle: handle ? { x: handle.x, y: handle.y } : undefined,
249
+ });
250
+ consume(unit);
251
+ consume(handle?.element);
252
+ }
253
+ /* Names: by id in interactive mode, by geometry otherwise. */
254
+ const labelLayer = LAYER(root, 'labels');
255
+ const labels = Array.from(labelLayer?.querySelectorAll('text') ?? []);
256
+ const names = new Map();
257
+ const targets = [
258
+ ...nodes.map(({ node }) => ({ id: node.id, x: node.x, y: node.y })),
259
+ ...pipelines.flatMap(pipeline => pipeline.handle ? [{ id: pipeline.id, ...pipeline.handle }] : []),
260
+ ];
261
+ if (live) {
262
+ for (const label of labels) {
263
+ const id = label.getAttribute('data-id');
264
+ if (id === null || names.has(id))
265
+ continue;
266
+ if (!targets.some(target => target.id === id))
267
+ continue;
268
+ names.set(id, textOf(label));
269
+ consume(label);
270
+ }
271
+ }
272
+ else {
273
+ // Closest pairs first: a label is placed beside its own node, and the
274
+ // collision avoidance that moves it only ever moves it a little.
275
+ const pairs = labels
276
+ .flatMap(label => {
277
+ const x = finiteAttr(label, 'x');
278
+ const y = finiteAttr(label, 'y');
279
+ if (x === undefined || y === undefined)
280
+ return [];
281
+ const frame = svgFrameOf(label);
282
+ const lx = frame.ox + x * frame.s;
283
+ const ly = frame.oy + y * frame.s;
284
+ return targets.map(target => ({
285
+ label,
286
+ target,
287
+ distance: Math.hypot(lx - target.x, ly - target.y),
288
+ }));
289
+ })
290
+ .sort((a, b) => a.distance - b.distance);
291
+ const used = new Set();
292
+ for (const { label, target, distance } of pairs) {
293
+ if (used.has(label) || names.has(target.id) || distance > 200)
294
+ continue;
295
+ used.add(label);
296
+ names.set(target.id, textOf(label));
297
+ consume(label);
298
+ }
299
+ }
300
+ for (const node of drawn.nodes)
301
+ node.name = names.get(node.id) ?? '';
302
+ for (const pipeline of pipelines) {
303
+ drawn.pipelines.push({
304
+ id: pipeline.id,
305
+ name: names.get(pipeline.id) ?? '',
306
+ top: pipeline.box[1],
307
+ x1: pipeline.box[0],
308
+ x2: pipeline.box[0] + pipeline.box[2],
309
+ });
310
+ }
311
+ /* Dependencies: both ends bound by geometry, in either mode. */
312
+ const nodeAt = (x, y) => nodes
313
+ .map(entry => ({
314
+ entry,
315
+ distance: Math.hypot(entry.node.x - x, entry.node.y - y),
316
+ }))
317
+ .filter(({ entry, distance }) => distance <= entry.r)
318
+ .sort((a, b) => a.distance - b.distance)[0]?.entry.node;
319
+ for (const unit of Array.from(LAYER(root, 'edges')?.children ?? [])) {
320
+ const line = unit.localName === 'line'
321
+ ? unit
322
+ : Array.from(unit.querySelectorAll('line')).find(visible);
323
+ const ends = line ? lineOf(line) : undefined;
324
+ if (!ends)
325
+ continue;
326
+ const from = nodeAt(ends[0], ends[1]);
327
+ const to = nodeAt(ends[2], ends[3]);
328
+ if (!from || !to || from === to) {
329
+ drawn.remarks.push({
330
+ kind: 'warning',
331
+ sourceId: unit.getAttribute('data-id') ?? undefined,
332
+ element: 'link',
333
+ message: WARDLEY_SVG_IMPORT_REMARKS.danglingLink[1],
334
+ messageKey: WARDLEY_SVG_IMPORT_REMARKS.danglingLink[0],
335
+ });
336
+ continue;
337
+ }
338
+ drawn.links.push({ from: from.id, to: to.id });
339
+ // The lines and nothing else: a flow label the relation carries has no
340
+ // native home, so it stays for the sketch.
341
+ for (const drawnLine of unit.localName === 'line'
342
+ ? [unit]
343
+ : Array.from(unit.querySelectorAll('line'))) {
344
+ consume(drawnLine);
345
+ }
346
+ }
347
+ /* Evolutions and inertia. */
348
+ const evolveLayer = LAYER(root, 'evolvesTo');
349
+ const evolveUnits = live
350
+ ? Array.from(evolveLayer?.children ?? [])
351
+ : evolveLayer
352
+ ? [evolveLayer]
353
+ : [];
354
+ for (const unit of evolveUnits) {
355
+ // In interactive mode the inertia bar is a sibling of the arrow's group,
356
+ // not a child of it, so a unit may be the line itself.
357
+ const lines = unit.localName === 'line'
358
+ ? [unit]
359
+ : Array.from(unit.querySelectorAll('line'));
360
+ for (const line of lines.filter(visible)) {
361
+ const ends = lineOf(line);
362
+ if (!ends)
363
+ continue;
364
+ const width = finiteAttr(line, 'stroke-width') ?? 1;
365
+ if (width >= 4) {
366
+ drawn.inertias.push({
367
+ x: (ends[0] + ends[2]) / 2,
368
+ y: (ends[1] + ends[3]) / 2,
369
+ });
370
+ consume(line);
371
+ continue;
372
+ }
373
+ const owner = nodeAt(ends[0], ends[1]);
374
+ if (!owner)
375
+ continue;
376
+ drawn.evolutions.push({ of: owner.id, name: owner.name, x: ends[2] });
377
+ consume(line);
378
+ }
379
+ // The hit areas, and the arrowheads that finish the arrows read above.
380
+ for (const rest of Array.from(unit.querySelectorAll('line.hit-area, polygon'))) {
381
+ consume(rest);
382
+ }
383
+ }
384
+ return drawn;
385
+ }
@@ -0,0 +1,2 @@
1
+ import { type DrawnWardleyMap } from './svg-read.js';
2
+ export declare function recogniseByShape(root: Element): DrawnWardleyMap | undefined;
@@ -0,0 +1,240 @@
1
+ import { svgFrameOf } from '@formicoidea/labre-core/blocks/surface';
2
+ import { finiteAttr, startDrawnMap, textOf, } from './svg-read.js';
3
+ /**
4
+ * An SVG no producer marked, read by shape (ADR 0032 §4.4, §5) — the last
5
+ * step of detection, and the most cautious.
6
+ *
7
+ * **The plot first, or nothing.** Two straight lines meeting at their
8
+ * bottom-left — a horizontal one and a vertical one, each longer than half the
9
+ * drawing's extent — are the map's axes, and the rectangle they span is the
10
+ * plot. Without them nothing is promoted and this answers `undefined`: a plot
11
+ * guessed for a picture whose producer is unknown is the invented axis ADR
12
+ * 0012 forbids.
13
+ *
14
+ * **Then two things, and only two.** Inside the plot, a circle (radius 20 or
15
+ * less) with a `<text>` beside it — within four radii or 40 units, closest
16
+ * pairs first — is a component named by that text; a straight segment whose
17
+ * two ends sit on two such components is a dependency, its consumer the
18
+ * higher of the two (needs descend the value chain). Nothing else is promoted:
19
+ * no anchor, pipeline, inertia bar, evolve arrow or note is guessed from bare
20
+ * geometry, so a dashed red arrow, a thick bar or a rect stays a sketch.
21
+ *
22
+ * **Known failure modes.** A curved link is not straight and stays a sketch.
23
+ * A `matrix`, `scale` or `rotate` transform is ignored, as the sketch walk
24
+ * ignores it, so a map drawn under one is read where it would be without it.
25
+ * A name drawn far from its circle, or a circle with no name, leaves that
26
+ * circle a sketch; and one link to it then stays a sketch too.
27
+ */
28
+ const MAX_NODE_RADIUS = 20;
29
+ const NAME_REACH = 40;
30
+ const END_SLACK = 3;
31
+ /**
32
+ * A path that is ONE straight segment — `M` then a single `L`, `H` or `V`,
33
+ * absolute or relative, or the implicit line-to of a second coordinate pair —
34
+ * as its two ends in the path's own units; anything else is `undefined`.
35
+ */
36
+ function straightPath(d) {
37
+ const commands = [...d.matchAll(/([MmLlHhVv])([^MmLlHhVvCcSsQqTtAaZz]*)/g)];
38
+ const rest = d.replace(/([MmLlHhVv])([^MmLlHhVvCcSsQqTtAaZz]*)/g, '').trim();
39
+ if (commands.length === 0 || rest.length > 0)
40
+ return undefined;
41
+ const points = [];
42
+ let x = 0;
43
+ let y = 0;
44
+ for (const [index, [, letter, raw]] of commands.entries()) {
45
+ const args = (raw.match(/-?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/g) ?? []).map(Number);
46
+ const relative = letter === letter.toLowerCase();
47
+ const kind = letter.toLowerCase();
48
+ // A second move-to starts a second subpath: not one segment.
49
+ if (kind === 'm' && index > 0)
50
+ return undefined;
51
+ if (kind === 'h' || kind === 'v') {
52
+ for (const value of args) {
53
+ if (kind === 'h')
54
+ x = relative ? x + value : value;
55
+ else
56
+ y = relative ? y + value : value;
57
+ points.push([x, y]);
58
+ }
59
+ continue;
60
+ }
61
+ for (let at = 0; at + 1 < args.length; at += 2) {
62
+ // A leading `m` is absolute (SVG 1.1 §8.3.2); every other relative pair
63
+ // — an `l`, or the implicit line-to after an `m` — is an offset.
64
+ const offset = relative && !(kind === 'm' && points.length === 0);
65
+ x = offset ? x + args[at] : args[at];
66
+ y = offset ? y + args[at + 1] : args[at + 1];
67
+ points.push([x, y]);
68
+ }
69
+ }
70
+ if (points.length !== 2)
71
+ return undefined;
72
+ if (!points.flat().every(Number.isFinite))
73
+ return undefined;
74
+ return [points[0][0], points[0][1], points[1][0], points[1][1]];
75
+ }
76
+ function segmentsOf(root) {
77
+ const found = [];
78
+ for (const element of Array.from(root.querySelectorAll('line, polyline, path'))) {
79
+ let ends;
80
+ if (element.localName === 'line') {
81
+ const values = ['x1', 'y1', 'x2', 'y2'].map(name => finiteAttr(element, name));
82
+ if (values.every(value => value !== undefined)) {
83
+ ends = values;
84
+ }
85
+ }
86
+ else if (element.localName === 'polyline') {
87
+ const values = (element.getAttribute('points') ?? '')
88
+ .match(/-?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/g)
89
+ ?.map(Number);
90
+ if (values?.length === 4)
91
+ ends = values;
92
+ }
93
+ else {
94
+ ends = straightPath(element.getAttribute('d') ?? '');
95
+ }
96
+ if (!ends)
97
+ continue;
98
+ const frame = svgFrameOf(element);
99
+ found.push({
100
+ element,
101
+ x1: frame.ox + ends[0] * frame.s,
102
+ y1: frame.oy + ends[1] * frame.s,
103
+ x2: frame.ox + ends[2] * frame.s,
104
+ y2: frame.oy + ends[3] * frame.s,
105
+ });
106
+ }
107
+ return found;
108
+ }
109
+ /** The two axes meeting bottom-left, and the plot they span — or nothing. */
110
+ function axesOf(segments, extent) {
111
+ const slack = Math.max(2, 0.02 * Math.max(extent.w, extent.h));
112
+ const horizontals = segments
113
+ .filter(s => Math.abs(s.y1 - s.y2) < 1)
114
+ .filter(s => Math.abs(s.x2 - s.x1) > extent.w / 2)
115
+ .sort((a, b) => Math.abs(b.x2 - b.x1) - Math.abs(a.x2 - a.x1));
116
+ const verticals = segments
117
+ .filter(s => Math.abs(s.x1 - s.x2) < 1)
118
+ .filter(s => Math.abs(s.y2 - s.y1) > extent.h / 2);
119
+ for (const h of horizontals) {
120
+ const left = Math.min(h.x1, h.x2);
121
+ const right = Math.max(h.x1, h.x2);
122
+ const bottom = h.y1;
123
+ const v = verticals.find(candidate => Math.abs(candidate.x1 - left) <= slack &&
124
+ Math.abs(Math.max(candidate.y1, candidate.y2) - bottom) <= slack);
125
+ if (!v)
126
+ continue;
127
+ const top = Math.min(v.y1, v.y2);
128
+ if (right - left <= 0 || bottom - top <= 0)
129
+ continue;
130
+ return {
131
+ plot: { x0: left, y0: top, width: right - left, height: bottom - top },
132
+ axes: [h, v],
133
+ };
134
+ }
135
+ return undefined;
136
+ }
137
+ export function recogniseByShape(root) {
138
+ const segments = segmentsOf(root);
139
+ const circles = Array.from(root.querySelectorAll('circle')).flatMap(element => {
140
+ const [cx, cy, r] = ['cx', 'cy', 'r'].map(name => finiteAttr(element, name));
141
+ if (cx === undefined || cy === undefined || r === undefined)
142
+ return [];
143
+ const frame = svgFrameOf(element);
144
+ return [
145
+ {
146
+ element,
147
+ x: frame.ox + cx * frame.s,
148
+ y: frame.oy + cy * frame.s,
149
+ r: r * frame.s,
150
+ },
151
+ ];
152
+ });
153
+ const texts = Array.from(root.querySelectorAll('text')).flatMap(element => {
154
+ const span = element.querySelector('tspan');
155
+ const x = finiteAttr(element, 'x') ?? (span ? finiteAttr(span, 'x') : undefined);
156
+ const y = finiteAttr(element, 'y') ?? (span ? finiteAttr(span, 'y') : undefined);
157
+ const name = textOf(element);
158
+ if (x === undefined || y === undefined || name.length === 0)
159
+ return [];
160
+ const frame = svgFrameOf(element);
161
+ return [
162
+ { element, x: frame.ox + x * frame.s, y: frame.oy + y * frame.s, name },
163
+ ];
164
+ });
165
+ const xs = [
166
+ ...segments.flatMap(s => [s.x1, s.x2]),
167
+ ...circles.map(c => c.x),
168
+ ...texts.map(t => t.x),
169
+ ];
170
+ const ys = [
171
+ ...segments.flatMap(s => [s.y1, s.y2]),
172
+ ...circles.map(c => c.y),
173
+ ...texts.map(t => t.y),
174
+ ];
175
+ if (xs.length === 0)
176
+ return undefined;
177
+ const extent = {
178
+ w: Math.max(...xs) - Math.min(...xs),
179
+ h: Math.max(...ys) - Math.min(...ys),
180
+ };
181
+ const found = axesOf(segments, extent);
182
+ if (!found)
183
+ return undefined;
184
+ const { plot, axes } = found;
185
+ const { drawn, consume } = startDrawnMap({
186
+ producer: 'SVG (recognised by shape)',
187
+ plot,
188
+ });
189
+ for (const axis of axes)
190
+ consume(axis.element);
191
+ /* Components: a small circle inside the plot, named by the text beside it. */
192
+ const inside = circles.filter(c => c.r <= MAX_NODE_RADIUS &&
193
+ c.x > plot.x0 &&
194
+ c.x < plot.x0 + plot.width &&
195
+ c.y > plot.y0 &&
196
+ c.y < plot.y0 + plot.height);
197
+ const pairs = inside
198
+ .flatMap(circle => texts.map(text => ({
199
+ circle,
200
+ text,
201
+ distance: Math.hypot(text.x - circle.x, text.y - circle.y),
202
+ })))
203
+ .filter(({ circle, distance }) => distance <= Math.max(4 * circle.r, NAME_REACH))
204
+ .sort((a, b) => a.distance - b.distance);
205
+ const named = new Map();
206
+ const reach = new Map();
207
+ const usedTexts = new Set();
208
+ for (const { circle, text } of pairs) {
209
+ if (named.has(circle.element) || usedTexts.has(text.element))
210
+ continue;
211
+ usedTexts.add(text.element);
212
+ const node = {
213
+ id: `shape ${named.size + 1}`,
214
+ kind: 'component',
215
+ name: text.name,
216
+ x: circle.x,
217
+ y: circle.y,
218
+ };
219
+ named.set(circle.element, node);
220
+ reach.set(node, circle.r + END_SLACK);
221
+ drawn.nodes.push(node);
222
+ drawn.consumed.add(circle.element);
223
+ drawn.consumed.add(text.element);
224
+ }
225
+ /* Dependencies: a straight segment from one component to another. */
226
+ const nodeAt = (x, y) => drawn.nodes.find(node => Math.hypot(node.x - x, node.y - y) <= (reach.get(node) ?? 0));
227
+ for (const segment of segments) {
228
+ if (axes.includes(segment))
229
+ continue;
230
+ const a = nodeAt(segment.x1, segment.y1);
231
+ const b = nodeAt(segment.x2, segment.y2);
232
+ if (!a || !b || a === b)
233
+ continue;
234
+ // The consumer is the higher one: needs descend the value chain.
235
+ const [from, to] = a.y <= b.y ? [a, b] : [b, a];
236
+ drawn.links.push({ from: from.id, to: to.id });
237
+ drawn.consumed.add(segment.element);
238
+ }
239
+ return drawn;
240
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The Wardley SVG import's own remarks, as `[key, English]` pairs (ADR 0032
3
+ * §3) — the same shape, and the same reason, as `WARDLEY_OWM_IMPORT_REMARKS`:
4
+ * a reader is a pure function with no `std`, so it hands the key over and
5
+ * `reportInterchangeImport` resolves it. `{{count}}` is filled from
6
+ * `messageParams`; plural wording stays neutral, the host pluralises.
7
+ *
8
+ * Contributed to the manifest by `./translations.ts`.
9
+ */
10
+ export declare const WARDLEY_SVG_IMPORT_REMARKS: {
11
+ readonly sketchedRemainder: readonly ["com.labre.wardley.import.svg.remark.sketched-remainder", "{{count}} shape(s) were not recognised as Wardley and arrive as a sketch."];
12
+ readonly inventedPlot: readonly ["com.labre.wardley.import.svg.remark.invented-plot", "the file draws no plot this reader could find, so evolution and visibility were ESTIMATED from the extent of the drawing, not read."];
13
+ readonly danglingLink: readonly ["com.labre.wardley.import.svg.remark.dangling-link", "a link in this file names an artefact the drawing does not hold, so it arrives as a sketch rather than as a dependency."];
14
+ readonly noAxes: readonly ["com.labre.wardley.import.svg.remark.no-axes", "No map axes were found and no known producer marked this file, so nothing was recognised as a Wardley map: it arrives as a sketch."];
15
+ readonly unreadableCoordinates: readonly ["com.labre.wardley.import.svg.remark.unreadable-coordinates", "its position in the file is not a pair of numbers this reader can trust, so it arrives as a sketch."];
16
+ };
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The Wardley SVG import's own remarks, as `[key, English]` pairs (ADR 0032
3
+ * §3) — the same shape, and the same reason, as `WARDLEY_OWM_IMPORT_REMARKS`:
4
+ * a reader is a pure function with no `std`, so it hands the key over and
5
+ * `reportInterchangeImport` resolves it. `{{count}}` is filled from
6
+ * `messageParams`; plural wording stays neutral, the host pluralises.
7
+ *
8
+ * Contributed to the manifest by `./translations.ts`.
9
+ */
10
+ export const WARDLEY_SVG_IMPORT_REMARKS = {
11
+ sketchedRemainder: [
12
+ 'com.labre.wardley.import.svg.remark.sketched-remainder',
13
+ '{{count}} shape(s) were not recognised as Wardley and arrive as a sketch.',
14
+ ],
15
+ inventedPlot: [
16
+ 'com.labre.wardley.import.svg.remark.invented-plot',
17
+ 'the file draws no plot this reader could find, so evolution and visibility were ESTIMATED from the extent of the drawing, not read.',
18
+ ],
19
+ danglingLink: [
20
+ 'com.labre.wardley.import.svg.remark.dangling-link',
21
+ 'a link in this file names an artefact the drawing does not hold, so it arrives as a sketch rather than as a dependency.',
22
+ ],
23
+ noAxes: [
24
+ 'com.labre.wardley.import.svg.remark.no-axes',
25
+ 'No map axes were found and no known producer marked this file, so nothing was recognised as a Wardley map: it arrives as a sketch.',
26
+ ],
27
+ unreadableCoordinates: [
28
+ 'com.labre.wardley.import.svg.remark.unreadable-coordinates',
29
+ 'its position in the file is not a pair of numbers this reader can trust, so it arrives as a sketch.',
30
+ ],
31
+ };