@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.
- package/dist/commands.js +12 -6
- package/dist/element-renderer.d.ts +1 -1
- package/dist/element-view.d.ts +1 -1
- package/dist/export.d.ts +56 -6
- package/dist/export.js +12 -13
- package/dist/import.d.ts +107 -106
- package/dist/import.js +96 -54
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/interchange.d.ts +13 -18
- package/dist/interchange.js +16 -20
- package/dist/node/node-renderer.d.ts +1 -1
- package/dist/reading.js +4 -0
- package/dist/roles.js +25 -6
- package/dist/svg-import.d.ts +2 -0
- package/dist/svg-import.js +309 -0
- package/dist/svg-read.d.ts +108 -0
- package/dist/svg-read.js +89 -0
- package/dist/svg-recognise-labre.d.ts +2 -0
- package/dist/svg-recognise-labre.js +240 -0
- package/dist/svg-recognise-owm.d.ts +2 -0
- package/dist/svg-recognise-owm.js +303 -0
- package/dist/svg-recognise-renderer.d.ts +2 -0
- package/dist/svg-recognise-renderer.js +385 -0
- package/dist/svg-recognise-shapes.d.ts +2 -0
- package/dist/svg-recognise-shapes.js +240 -0
- package/dist/svg-remarks.d.ts +16 -0
- package/dist/svg-remarks.js +31 -0
- package/dist/toolbar/config.d.ts +2 -2
- package/dist/toolbar/node-config.d.ts +1 -1
- package/dist/toolbar/senior-tool.d.ts +1 -1
- package/dist/translations.js +10 -2
- package/package.json +2 -2
|
@@ -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,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
|
+
};
|