@itsthecsguy/linework 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/NOTICE +11 -0
- package/README.md +206 -2
- package/assets/drawio/LICENSE +201 -0
- package/assets/drawio/VERSION +1 -0
- package/assets/drawio/favicon.ico +0 -0
- package/assets/drawio/images/1x1.png +0 -0
- package/assets/drawio/images/2x2.png +0 -0
- package/assets/drawio/images/3x3.png +0 -0
- package/assets/drawio/images/ajax-loader.gif +0 -0
- package/assets/drawio/images/android-chrome-512x512.png +0 -0
- package/assets/drawio/images/apple-touch-icon.png +0 -0
- package/assets/drawio/images/aui-wait.gif +0 -0
- package/assets/drawio/images/broken.png +0 -0
- package/assets/drawio/images/browserconfig.xml +9 -0
- package/assets/drawio/images/close.png +0 -0
- package/assets/drawio/images/cloud-connector-atlas.png +0 -0
- package/assets/drawio/images/collapsed.gif +0 -0
- package/assets/drawio/images/delete.png +0 -0
- package/assets/drawio/images/document-google-drive-icon-80.png +0 -0
- package/assets/drawio/images/draw-search.png +0 -0
- package/assets/drawio/images/drawio.svg +19 -0
- package/assets/drawio/images/drawlogo-color.svg +50 -0
- package/assets/drawio/images/drawlogo-gray.svg +18 -0
- package/assets/drawio/images/drawlogo-text-bottom.svg +75 -0
- package/assets/drawio/images/drawlogo.svg +18 -0
- package/assets/drawio/images/drawlogo128.png +0 -0
- package/assets/drawio/images/drawlogo144.png +0 -0
- package/assets/drawio/images/drawlogo256.png +0 -0
- package/assets/drawio/images/drawlogo48-gray.png +0 -0
- package/assets/drawio/images/drawlogo48.png +0 -0
- package/assets/drawio/images/drawlogo80.png +0 -0
- package/assets/drawio/images/droptarget.png +0 -0
- package/assets/drawio/images/edit.gif +0 -0
- package/assets/drawio/images/edit16.png +0 -0
- package/assets/drawio/images/edit32.png +0 -0
- package/assets/drawio/images/edit80.png +0 -0
- package/assets/drawio/images/embed-icon.png +0 -0
- package/assets/drawio/images/expanded.gif +0 -0
- package/assets/drawio/images/facebook.png +0 -0
- package/assets/drawio/images/favicon-16x16.png +0 -0
- package/assets/drawio/images/favicon-32x32.png +0 -0
- package/assets/drawio/images/file.png +0 -0
- package/assets/drawio/images/folder.png +0 -0
- package/assets/drawio/images/gadget-screenshot.png +0 -0
- package/assets/drawio/images/gadget-thumb.png +0 -0
- package/assets/drawio/images/github-logo-white.svg +6 -0
- package/assets/drawio/images/github-logo.svg +17 -0
- package/assets/drawio/images/gitlab-logo-white.svg +1 -0
- package/assets/drawio/images/gitlab-logo.svg +1 -0
- package/assets/drawio/images/glyphicons_blogger.png +0 -0
- package/assets/drawio/images/glyphicons_facebook.png +0 -0
- package/assets/drawio/images/glyphicons_github.png +0 -0
- package/assets/drawio/images/glyphicons_google.png +0 -0
- package/assets/drawio/images/glyphicons_star.png +0 -0
- package/assets/drawio/images/glyphicons_twitter.png +0 -0
- package/assets/drawio/images/glyphicons_youtube.png +0 -0
- package/assets/drawio/images/google-drive-connector-atlas.png +0 -0
- package/assets/drawio/images/google-drive-connector-atlas.svg +121 -0
- package/assets/drawio/images/google-drive-logo-white.svg +9 -0
- package/assets/drawio/images/google-drive-logo.svg +17 -0
- package/assets/drawio/images/google-share.png +0 -0
- package/assets/drawio/images/icon-192-maskable.png +0 -0
- package/assets/drawio/images/icon-192.png +0 -0
- package/assets/drawio/images/icon-512-maskable.png +0 -0
- package/assets/drawio/images/icon-512.png +0 -0
- package/assets/drawio/images/icon-search.svg +21 -0
- package/assets/drawio/images/img-hi-res.png +0 -0
- package/assets/drawio/images/img-lo-res.png +0 -0
- package/assets/drawio/images/list.svg +16 -0
- package/assets/drawio/images/locked.png +0 -0
- package/assets/drawio/images/log.png +0 -0
- package/assets/drawio/images/logo-confluence.png +0 -0
- package/assets/drawio/images/logo-flat-small.png +0 -0
- package/assets/drawio/images/logo-flat.png +0 -0
- package/assets/drawio/images/logo-jira.png +0 -0
- package/assets/drawio/images/logo-small.gif +0 -0
- package/assets/drawio/images/logo-white.png +0 -0
- package/assets/drawio/images/logo.png +0 -0
- package/assets/drawio/images/manifest.json +25 -0
- package/assets/drawio/images/move.png +0 -0
- package/assets/drawio/images/notion-logo-white.svg +1 -0
- package/assets/drawio/images/notion-logo.svg +1 -0
- package/assets/drawio/images/onedrive-connector-atlas.png +0 -0
- package/assets/drawio/images/onedrive-connector-atlas.svg +248 -0
- package/assets/drawio/images/onedrive-logo-white.svg +29 -0
- package/assets/drawio/images/onedrive-logo.svg +29 -0
- package/assets/drawio/images/osa_database.png +0 -0
- package/assets/drawio/images/osa_drive-harddisk.png +0 -0
- package/assets/drawio/images/plant-uml-atlas.png +0 -0
- package/assets/drawio/images/samepage-icon-color.svg +37 -0
- package/assets/drawio/images/save.png +0 -0
- package/assets/drawio/images/sidebar-android.png +0 -0
- package/assets/drawio/images/sidebar-archimate.png +0 -0
- package/assets/drawio/images/sidebar-arrows.png +0 -0
- package/assets/drawio/images/sidebar-arrows2.png +0 -0
- package/assets/drawio/images/sidebar-basic.png +0 -0
- package/assets/drawio/images/sidebar-c4.png +0 -0
- package/assets/drawio/images/sidebar-cabinets.png +0 -0
- package/assets/drawio/images/sidebar-cumulus.png +0 -0
- package/assets/drawio/images/sidebar-dfd.png +0 -0
- package/assets/drawio/images/sidebar-dynamics365.png +0 -0
- package/assets/drawio/images/sidebar-eip.png +0 -0
- package/assets/drawio/images/sidebar-er.png +0 -0
- package/assets/drawio/images/sidebar-floorplans.png +0 -0
- package/assets/drawio/images/sidebar-flowchart.png +0 -0
- package/assets/drawio/images/sidebar-gcp3.png +0 -0
- package/assets/drawio/images/sidebar-general.png +0 -0
- package/assets/drawio/images/sidebar-kubernetes.png +0 -0
- package/assets/drawio/images/sidebar-leanmapping.png +0 -0
- package/assets/drawio/images/sidebar-mockups.png +0 -0
- package/assets/drawio/images/sidebar-network.png +0 -0
- package/assets/drawio/images/sidebar-network2.png +0 -0
- package/assets/drawio/images/sidebar-openstack.png +0 -0
- package/assets/drawio/images/sidebar-sitemap.png +0 -0
- package/assets/drawio/images/sidebar-threatmodeling.png +0 -0
- package/assets/drawio/images/sidebar-uml.png +0 -0
- package/assets/drawio/images/sketch-logo128.png +0 -0
- package/assets/drawio/images/spin.gif +0 -0
- package/assets/drawio/images/sprites.png +0 -0
- package/assets/drawio/images/stop-flat-icon-80.png +0 -0
- package/assets/drawio/images/submenu.gif +0 -0
- package/assets/drawio/images/touch-submenu.png +0 -0
- package/assets/drawio/images/transparent.gif +0 -0
- package/assets/drawio/images/trello-logo-white-orange.svg +6 -0
- package/assets/drawio/images/trello-logo-white.svg +6 -0
- package/assets/drawio/images/trello-logo.svg +1 -0
- package/assets/drawio/images/tweet.png +0 -0
- package/assets/drawio/images/update16.png +0 -0
- package/assets/drawio/images/update32.png +0 -0
- package/assets/drawio/images/update80.png +0 -0
- package/assets/drawio/images/youtube.png +0 -0
- package/assets/drawio/img/clipart/Battery_0_128x128.png +0 -0
- package/assets/drawio/img/clipart/Battery_100_128x128.png +0 -0
- package/assets/drawio/img/clipart/Battery_50_128x128.png +0 -0
- package/assets/drawio/img/clipart/Battery_75_128x128.png +0 -0
- package/assets/drawio/img/clipart/Battery_allstates_128x128.png +0 -0
- package/assets/drawio/img/clipart/Bluetooth_128x128.png +0 -0
- package/assets/drawio/img/clipart/Earth_globe_128x128.png +0 -0
- package/assets/drawio/img/clipart/Empty_Folder_128x128.png +0 -0
- package/assets/drawio/img/clipart/Full_Folder_128x128.png +0 -0
- package/assets/drawio/img/clipart/Gear_128x128.png +0 -0
- package/assets/drawio/img/clipart/Keys_128x128.png +0 -0
- package/assets/drawio/img/clipart/Lock_128x128.png +0 -0
- package/assets/drawio/img/clipart/Mouse_Pointer_128x128.png +0 -0
- package/assets/drawio/img/clipart/Plug_128x128.png +0 -0
- package/assets/drawio/img/clipart/Ships_Wheel_128x128.png +0 -0
- package/assets/drawio/img/clipart/Star_128x128.png +0 -0
- package/assets/drawio/img/clipart/Tire_128x128.png +0 -0
- package/assets/drawio/index.html +92 -0
- package/assets/drawio/js/PostConfig.js +7 -0
- package/assets/drawio/js/PreConfig.js +13 -0
- package/assets/drawio/js/app.min.js +18466 -0
- package/assets/drawio/js/bootstrap.js +428 -0
- package/assets/drawio/js/diagramly/LibavoidRouting.js +4459 -0
- package/assets/drawio/js/export-init.js +21 -0
- package/assets/drawio/js/extensions.min.js +10478 -0
- package/assets/drawio/js/libavoid-js/LICENSE +505 -0
- package/assets/drawio/js/libavoid-js/libavoid-routing.js +1633 -0
- package/assets/drawio/js/libavoid-js/libavoid.min.js +59 -0
- package/assets/drawio/js/main.js +22 -0
- package/assets/drawio/js/rough/rough.min.js +1 -0
- package/assets/drawio/js/shapes-14-6-5.min.js +3457 -0
- package/assets/drawio/js/stencils.min.js +447 -0
- package/assets/drawio/mxgraph/css/common.css +147 -0
- package/assets/drawio/resources/dia.txt +2901 -0
- package/assets/drawio/styles/dark-default.xml +109 -0
- package/assets/drawio/styles/default-old.xml +109 -0
- package/assets/drawio/styles/default.xml +203 -0
- package/assets/drawio/styles/fonts/ArchitectsDaughter-Regular.ttf +0 -0
- package/assets/drawio/styles/grapheditor.css +4501 -0
- package/assets/drawio/styles/high-contrast.css +128 -0
- package/assets/drawio/styles/thumb_horz.png +0 -0
- package/assets/drawio/styles/thumb_vertical.png +0 -0
- package/assets/fonts/ibm-plex-mono-LICENSE.txt +93 -0
- package/assets/fonts/ibm-plex-sans-LICENSE.txt +93 -0
- package/assets/fonts/kalam-400.woff2 +0 -0
- package/assets/fonts/kalam-700.woff2 +0 -0
- package/assets/fonts/kalam-LICENSE.txt +93 -0
- package/assets/fonts/plex-mono-400.woff2 +0 -0
- package/assets/fonts/plex-mono-600.woff2 +0 -0
- package/assets/fonts/plex-sans-400.woff2 +0 -0
- package/assets/fonts/plex-sans-600.woff2 +0 -0
- package/dist/api.js +218 -0
- package/dist/cli.js +275 -0
- package/dist/drawio/icons.js +121 -0
- package/dist/drawio/merge.js +309 -0
- package/dist/drawio/read.js +180 -0
- package/dist/drawio/show.js +501 -0
- package/dist/drawio/theme.js +425 -0
- package/dist/drawio/write.js +300 -0
- package/dist/edit/server.js +212 -0
- package/dist/git.js +46 -0
- package/dist/import/mermaid.js +569 -0
- package/dist/layout/layout.js +212 -0
- package/dist/layout/sequence.js +333 -0
- package/dist/layout/text.js +73 -0
- package/dist/lint/check.js +188 -0
- package/dist/lint/fixlabels.js +128 -0
- package/dist/mcp/server.js +239 -0
- package/dist/publish.js +121 -0
- package/dist/render/browser.js +236 -0
- package/dist/render/fonts.js +66 -0
- package/dist/report.js +36 -0
- package/dist/spec/model.js +53 -0
- package/dist/spec/parse.js +497 -0
- package/package.json +71 -4
- package/skills/linework/SKILL.md +233 -0
- package/skills/linework/examples/billing-er.yaml +31 -0
- package/skills/linework/examples/checkout.yaml +28 -0
- package/skills/linework/examples/pay-sequence.yaml +28 -0
- package/skills/linework/examples/payment-state.yaml +22 -0
- package/skills/linework/examples/refund-flow.yaml +29 -0
- package/skills/linework/examples/serverless-aws.yaml +23 -0
- package/skills/linework/references/google-docs.md +46 -0
- package/skills/linework/references/spec.md +312 -0
- package/skills/linework/references/style-guide.md +53 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { absBox } from './read.js';
|
|
2
|
+
import { manualEdits, toDiagram } from './show.js';
|
|
3
|
+
import { autoAttr, diagramMeta, edgeAttrs, edgeCellId, edgeStyleFor, nodeChildren, styleFor, groupAttrs, groupStyleFor, nodeAttrs, routeAttr } from './write.js';
|
|
4
|
+
import { palette } from './theme.js';
|
|
5
|
+
import { kindDef } from '../spec/model.js';
|
|
6
|
+
const GAP = 24;
|
|
7
|
+
const PAD = { top: 40, left: 20, right: 20, bottom: 20 };
|
|
8
|
+
const center = (b) => ({ x: b.x + b.w / 2, y: b.y + b.h / 2 });
|
|
9
|
+
const overlaps = (a, b, m = 0) => a.x < b.x + b.w + m && b.x < a.x + a.w + m && a.y < b.y + b.h + m && b.y < a.y + a.h + m;
|
|
10
|
+
const PORT_KEYS = ['exitX', 'exitY', 'exitDx', 'exitDy', 'exitPerimeter', 'entryX', 'entryY', 'entryDx', 'entryDy', 'entryPerimeter'];
|
|
11
|
+
function withoutKeys(style, keys) {
|
|
12
|
+
return style
|
|
13
|
+
.split(';')
|
|
14
|
+
.filter((p) => p && !keys.includes(p.split('=')[0]))
|
|
15
|
+
.join(';') + ';';
|
|
16
|
+
}
|
|
17
|
+
function ownAttrs(c) {
|
|
18
|
+
// color/highlight come from the spec every time, so dropping one in the spec drops it here.
|
|
19
|
+
const { id: _id, value: _value, aiviz_auto: _auto, aiviz_color: _c, aiviz_highlight: _h, ...rest } = c.attrs;
|
|
20
|
+
return rest;
|
|
21
|
+
}
|
|
22
|
+
const sameLookOf = (a, b) => (a?.color ?? '') === (b?.color ?? '') && !!a?.highlight === !!b?.highlight;
|
|
23
|
+
export function merge(d, old, fresh, opts = {}) {
|
|
24
|
+
const shown = toDiagram(old);
|
|
25
|
+
const cellOf = new Map();
|
|
26
|
+
for (const [cellId, specId] of shown.idMap)
|
|
27
|
+
cellOf.set(specId, old.byId.get(cellId));
|
|
28
|
+
const oldNodes = new Map(shown.diagram.nodes.map((n) => [n.id, n]));
|
|
29
|
+
const oldGroups = new Map(shown.diagram.groups.map((g) => [g.id, g]));
|
|
30
|
+
const handEdits = new Map(manualEdits(old).map((e) => [e.id, e.what]));
|
|
31
|
+
// A theme change restyles everything; positions still stay.
|
|
32
|
+
const themeChanged = shown.diagram.theme !== d.theme;
|
|
33
|
+
const isTable = (n) => kindDef(d.type, n.kind).shape === 'table';
|
|
34
|
+
const abs = new Map(); // final absolute boxes for nodes
|
|
35
|
+
const changed = new Set(); // nodes whose box differs from the old file
|
|
36
|
+
const stats = { kept: 0, handMoved: 0, placed: [], grown: [], removed: [], rerouted: 0, carried: 0, keptUnknown: [] };
|
|
37
|
+
// 1. Existing nodes stay put.
|
|
38
|
+
for (const n of d.nodes) {
|
|
39
|
+
const c = cellOf.get(n.id);
|
|
40
|
+
if (!c || !oldNodes.has(n.id))
|
|
41
|
+
continue;
|
|
42
|
+
const b = absBox(old, c.id);
|
|
43
|
+
if (!b)
|
|
44
|
+
continue;
|
|
45
|
+
const prev = oldNodes.get(n.id);
|
|
46
|
+
const f = fresh.natural.get(n.id); // its own size, not the diagram-wide width
|
|
47
|
+
let box = b;
|
|
48
|
+
const kindChanged = prev.kind !== n.kind;
|
|
49
|
+
const textChanged = prev.label !== n.label || prev.tech !== n.tech;
|
|
50
|
+
const handSized = handEdits.get(c.id) === 'resized';
|
|
51
|
+
if (isTable(n)) {
|
|
52
|
+
// Tables are as tall as their fields; keep any extra width someone gave them.
|
|
53
|
+
const w = Math.max(b.w, f.w);
|
|
54
|
+
if (Math.abs(f.h - b.h) > 1 || w > b.w + 1) {
|
|
55
|
+
box = { x: b.x, y: b.y, w, h: f.h };
|
|
56
|
+
changed.add(n.id);
|
|
57
|
+
stats.grown.push(n.id);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
else if (kindChanged || (textChanged && !handSized && (f.w > b.w + 1 || f.h > b.h + 1))) {
|
|
61
|
+
const w = kindChanged ? f.w : Math.max(b.w, f.w);
|
|
62
|
+
const h = kindChanged ? f.h : Math.max(b.h, f.h);
|
|
63
|
+
const cc = center(b);
|
|
64
|
+
box = { x: cc.x - w / 2, y: cc.y - h / 2, w, h };
|
|
65
|
+
changed.add(n.id);
|
|
66
|
+
stats.grown.push(n.id);
|
|
67
|
+
}
|
|
68
|
+
if (handEdits.has(c.id))
|
|
69
|
+
stats.handMoved++;
|
|
70
|
+
stats.kept++;
|
|
71
|
+
abs.set(n.id, box);
|
|
72
|
+
}
|
|
73
|
+
// 2. New nodes go next to a neighbour.
|
|
74
|
+
const preds = (id) => d.edges.filter((e) => e.to === id).map((e) => e.from);
|
|
75
|
+
const succs = (id) => d.edges.filter((e) => e.from === id).map((e) => e.to);
|
|
76
|
+
const ancestors = (gid) => {
|
|
77
|
+
const s = new Set();
|
|
78
|
+
for (let g = gid; g; g = d.groups.find((x) => x.id === g)?.parent)
|
|
79
|
+
s.add(g);
|
|
80
|
+
return s;
|
|
81
|
+
};
|
|
82
|
+
const oldGroupBox = (gid) => {
|
|
83
|
+
const c = cellOf.get(gid);
|
|
84
|
+
return c && oldGroups.has(gid) ? absBox(old, c.id) : undefined;
|
|
85
|
+
};
|
|
86
|
+
// New box-like nodes match the most common width already in the diagram.
|
|
87
|
+
const widths = new Map();
|
|
88
|
+
for (const [id, b] of abs) {
|
|
89
|
+
const k = d.nodes.find((n) => n.id === id)?.kind;
|
|
90
|
+
if (k && !['start', 'end', 'decision'].includes(k))
|
|
91
|
+
widths.set(b.w, (widths.get(b.w) ?? 0) + 1);
|
|
92
|
+
}
|
|
93
|
+
const commonW = [...widths].sort((a, b) => b[1] - a[1])[0]?.[0] ?? 0;
|
|
94
|
+
const sizeOf = (n) => {
|
|
95
|
+
const nat = fresh.natural.get(n.id);
|
|
96
|
+
const boxy = !['start', 'end', 'decision'].includes(n.kind);
|
|
97
|
+
return { w: boxy ? Math.max(nat.w, commonW) : nat.w, h: nat.h };
|
|
98
|
+
};
|
|
99
|
+
const pending = d.nodes.filter((n) => !abs.has(n.id));
|
|
100
|
+
while (pending.length) {
|
|
101
|
+
let i = pending.findIndex((n) => [...preds(n.id), ...succs(n.id)].some((m) => abs.has(m)));
|
|
102
|
+
if (i < 0)
|
|
103
|
+
i = 0;
|
|
104
|
+
const n = pending.splice(i, 1)[0];
|
|
105
|
+
const f = { ...fresh.nodes.get(n.id), ...sizeOf(n) };
|
|
106
|
+
const anchor = [...preds(n.id), ...succs(n.id)].find((m) => abs.has(m));
|
|
107
|
+
let cand;
|
|
108
|
+
if (anchor) {
|
|
109
|
+
const fa = center(fresh.nodes.get(anchor)), a = center(abs.get(anchor)), fc = center(f);
|
|
110
|
+
cand = { x: a.x + (fc.x - fa.x) - f.w / 2, y: a.y + (fc.y - fa.y) - f.h / 2, w: f.w, h: f.h };
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
cand = fallbackSpot(n, f, abs, fresh, oldGroupBox(n.parent ?? ''));
|
|
114
|
+
}
|
|
115
|
+
abs.set(n.id, freeSpot(cand, n, abs, d, ancestors(n.parent), oldGroupBox));
|
|
116
|
+
changed.add(n.id);
|
|
117
|
+
stats.placed.push(n.id);
|
|
118
|
+
}
|
|
119
|
+
// 3. Groups: keep their box, grow to fit members, deepest first.
|
|
120
|
+
const depth = (gid) => ancestors(gid).size;
|
|
121
|
+
const gabs = new Map();
|
|
122
|
+
for (const g of [...d.groups].sort((a, b) => depth(b.id) - depth(a.id))) {
|
|
123
|
+
const kids = [
|
|
124
|
+
...d.nodes.filter((n) => n.parent === g.id).map((n) => abs.get(n.id)),
|
|
125
|
+
...d.groups.filter((x) => x.parent === g.id).map((x) => gabs.get(x.id)),
|
|
126
|
+
].filter(Boolean);
|
|
127
|
+
const need = kids.length ? padded(union(kids)) : undefined;
|
|
128
|
+
const prev = oldGroupBox(g.id);
|
|
129
|
+
const box = prev ? (need ? union([prev, need]) : prev) : need ?? { x: 0, y: 0, w: 200, h: 120 };
|
|
130
|
+
if (prev && (box.w > prev.w + 1 || box.h > prev.h + 1))
|
|
131
|
+
stats.grown.push(g.id);
|
|
132
|
+
gabs.set(g.id, box);
|
|
133
|
+
}
|
|
134
|
+
// 4. Build the scene.
|
|
135
|
+
const absOf = (id) => abs.get(id) ?? gabs.get(id);
|
|
136
|
+
const rel = (id, parent) => {
|
|
137
|
+
const a = absOf(id);
|
|
138
|
+
if (!parent)
|
|
139
|
+
return a;
|
|
140
|
+
const p = gabs.get(parent);
|
|
141
|
+
return { ...a, x: a.x - p.x, y: a.y - p.y };
|
|
142
|
+
};
|
|
143
|
+
const vertices = [];
|
|
144
|
+
for (const g of d.groups) {
|
|
145
|
+
const c = oldGroups.has(g.id) ? cellOf.get(g.id) : undefined;
|
|
146
|
+
const box = rel(g.id, g.parent);
|
|
147
|
+
const prev = oldGroups.get(g.id);
|
|
148
|
+
const attrs = { ...(c ? ownAttrs(c) : {}), ...groupAttrs(g, d.theme) };
|
|
149
|
+
const groupSame = !!c && !themeChanged && sameLookOf(prev, g) && prev?.parent === g.parent;
|
|
150
|
+
if (c && prev?.label === g.label && c.attrs.label != null && !themeChanged)
|
|
151
|
+
attrs.label = c.attrs.label;
|
|
152
|
+
attrs.aiviz_auto = autoAttr(box); // hand moves are now part of the layout
|
|
153
|
+
vertices.push({ id: g.id, parent: g.parent ?? '1', attrs, style: groupSame ? c.styleRaw : groupStyleFor(d, g), box });
|
|
154
|
+
}
|
|
155
|
+
for (const n of d.nodes) {
|
|
156
|
+
const c = oldNodes.has(n.id) ? cellOf.get(n.id) : undefined;
|
|
157
|
+
const prev = oldNodes.get(n.id);
|
|
158
|
+
const box = rel(n.id, n.parent);
|
|
159
|
+
const attrs = { ...(c ? ownAttrs(c) : {}), ...nodeAttrs(d, n) };
|
|
160
|
+
const lookSame = sameLookOf(prev, n);
|
|
161
|
+
if (c && prev && prev.label === n.label && prev.tech === n.tech && !themeChanged && lookSame)
|
|
162
|
+
attrs.label = c.attrs.label ?? c.label;
|
|
163
|
+
attrs.aiviz_auto = autoAttr(box);
|
|
164
|
+
// Keep a person's styling, unless the kind changed or it's a shape they added
|
|
165
|
+
// without picking any colours (draw.io's default black outline).
|
|
166
|
+
const unstyled = c && !c.attrs.aiviz_kind && !c.style.fillColor && !c.style.strokeColor;
|
|
167
|
+
const style = c && prev?.kind === n.kind && prev?.icon === n.icon && lookSame && !unstyled && !themeChanged ? c.styleRaw : styleFor(d, n);
|
|
168
|
+
vertices.push({ id: n.id, parent: n.parent ?? '1', attrs, style, box });
|
|
169
|
+
vertices.push(...nodeChildren(d, n, box));
|
|
170
|
+
}
|
|
171
|
+
const outIds = new Set(vertices.map((v) => v.id));
|
|
172
|
+
const reroute = [];
|
|
173
|
+
const edges = [];
|
|
174
|
+
for (const e of d.edges) {
|
|
175
|
+
const oldCellId = shown.edgeCells.get(e.id);
|
|
176
|
+
const oc = oldCellId ? old.byId.get(oldCellId) : undefined;
|
|
177
|
+
const prev = shown.diagram.edges.find((x) => x.id === e.id);
|
|
178
|
+
const id = edgeCellId(e.id);
|
|
179
|
+
const sameEnds = !!prev && !changed.has(e.from) && !changed.has(e.to);
|
|
180
|
+
// A route nobody adjusted is stale once a person moves either end.
|
|
181
|
+
const endMovedByHand = [e.from, e.to].some((n) => {
|
|
182
|
+
const c = cellOf.get(n);
|
|
183
|
+
return c && handEdits.has(c.id);
|
|
184
|
+
});
|
|
185
|
+
const routeUntouched = !oc?.attrs.aiviz_auto || oc.attrs.aiviz_auto === routeAttr(oc.geom?.points ?? []);
|
|
186
|
+
const stale = endMovedByHand && routeUntouched;
|
|
187
|
+
const keepParent = oc?.parent && (oc.parent === '1' || outIds.has(oc.parent)) ? oc.parent : undefined;
|
|
188
|
+
const attrs = { ...(oc ? ownAttrs(oc) : {}), ...edgeAttrs(e) };
|
|
189
|
+
if (oc && (prev?.label ?? '') === (e.label ?? '') && oc.attrs.label != null)
|
|
190
|
+
attrs.label = oc.attrs.label;
|
|
191
|
+
let style;
|
|
192
|
+
const unstyledEdge = oc && !oc.attrs.aiviz_edge && !oc.style.strokeColor;
|
|
193
|
+
const sameLook = prev?.style === e.style && JSON.stringify(prev?.card) === JSON.stringify(e.card) && sameLookOf(prev, e);
|
|
194
|
+
if (oc && sameLook && !unstyledEdge && !themeChanged)
|
|
195
|
+
style = oc.styleRaw;
|
|
196
|
+
else {
|
|
197
|
+
// Arrow style or theme changed: take the theme style, keep where the edge attaches.
|
|
198
|
+
const ports = oc ? PORT_KEYS.filter((k) => k in oc.style).map((k) => `${k}=${oc.style[k]};`).join('') : '';
|
|
199
|
+
style = edgeStyleFor(d, e) + ports;
|
|
200
|
+
}
|
|
201
|
+
if (oc && sameEnds && keepParent && !stale) {
|
|
202
|
+
edges.push({ id, parent: keepParent, attrs, style, source: e.from, target: e.to, geomRaw: oc.geomRaw });
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
edges.push({ id, parent: '1', attrs, style: withoutKeys(style, PORT_KEYS), source: e.from, target: e.to, points: [] });
|
|
206
|
+
reroute.push(id);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
stats.rerouted = reroute.length;
|
|
210
|
+
// 5. Carry over what the spec can't express.
|
|
211
|
+
const raw = [];
|
|
212
|
+
// A spec written before someone added shapes by hand doesn't know about them.
|
|
213
|
+
// Deleting them would silently destroy their work, so keep them.
|
|
214
|
+
if (!opts.specIsCurrent) {
|
|
215
|
+
const specIds = new Set(d.nodes.map((n) => n.id));
|
|
216
|
+
for (const [cellId, specId] of shown.idMap) {
|
|
217
|
+
const c = old.byId.get(cellId);
|
|
218
|
+
if (specIds.has(specId) || c.attrs.aiviz_kind || c.attrs.aiviz_type || outIds.has(cellId))
|
|
219
|
+
continue;
|
|
220
|
+
raw.push(c.raw);
|
|
221
|
+
outIds.add(cellId);
|
|
222
|
+
stats.keptUnknown.push(specId);
|
|
223
|
+
}
|
|
224
|
+
const specEdges = new Set(d.edges.map((e) => e.id));
|
|
225
|
+
for (const [eid, cellId] of shown.edgeCells) {
|
|
226
|
+
const c = old.byId.get(cellId);
|
|
227
|
+
if (specEdges.has(eid) || c.attrs.aiviz_edge)
|
|
228
|
+
continue;
|
|
229
|
+
if (c.source && c.target && outIds.has(c.source) && outIds.has(c.target))
|
|
230
|
+
raw.push(c.raw);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
for (const cid of shown.skipped) {
|
|
234
|
+
const c = old.byId.get(cid);
|
|
235
|
+
const parentOk = !c.parent || c.parent === '1' || outIds.has(c.parent);
|
|
236
|
+
const endsOk = (!c.source || outIds.has(c.source)) && (!c.target || outIds.has(c.target));
|
|
237
|
+
if (parentOk && endsOk && !outIds.has(cid))
|
|
238
|
+
raw.push(c.raw);
|
|
239
|
+
}
|
|
240
|
+
stats.carried = raw.length - stats.keptUnknown.length;
|
|
241
|
+
const kept = new Set([...outIds, ...stats.keptUnknown]);
|
|
242
|
+
for (const id of [...oldNodes.keys(), ...oldGroups.keys()])
|
|
243
|
+
if (!kept.has(id))
|
|
244
|
+
stats.removed.push(id);
|
|
245
|
+
const { id: _id, label: _label, ...oldMeta } = old.meta;
|
|
246
|
+
return {
|
|
247
|
+
scene: {
|
|
248
|
+
name: d.title ?? old.name,
|
|
249
|
+
meta: { ...withoutTheme(oldMeta), ...diagramMeta(d, { aiviz_layout: 'manual' }) },
|
|
250
|
+
background: palette(d.theme).canvas,
|
|
251
|
+
vertices,
|
|
252
|
+
edges,
|
|
253
|
+
raw,
|
|
254
|
+
},
|
|
255
|
+
reroute,
|
|
256
|
+
stats,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
function union(boxes) {
|
|
260
|
+
const x = Math.min(...boxes.map((b) => b.x)), y = Math.min(...boxes.map((b) => b.y));
|
|
261
|
+
const x2 = Math.max(...boxes.map((b) => b.x + b.w)), y2 = Math.max(...boxes.map((b) => b.y + b.h));
|
|
262
|
+
return { x, y, w: x2 - x, h: y2 - y };
|
|
263
|
+
}
|
|
264
|
+
function padded(b) {
|
|
265
|
+
return { x: b.x - PAD.left, y: b.y - PAD.top, w: b.w + PAD.left + PAD.right, h: b.h + PAD.top + PAD.bottom };
|
|
266
|
+
}
|
|
267
|
+
/** No placed neighbour: put it after everything else, in the layout direction. */
|
|
268
|
+
function fallbackSpot(n, f, abs, fresh, groupBox) {
|
|
269
|
+
const all = [...abs.values()];
|
|
270
|
+
const area = groupBox ? { ...groupBox, y: groupBox.y + PAD.top, h: groupBox.h - PAD.top } : all.length ? union(all) : { x: 0, y: 0, w: 0, h: 0 };
|
|
271
|
+
if (fresh.direction === 'LR' || fresh.direction === 'RL')
|
|
272
|
+
return { x: area.x + area.w + GAP * 2, y: area.y, w: f.w, h: f.h };
|
|
273
|
+
return { x: area.x, y: area.y + area.h + GAP * 2, w: f.w, h: f.h };
|
|
274
|
+
}
|
|
275
|
+
/** Nearest spot to `cand` on a 8px grid that doesn't collide with placed nodes or foreign groups. */
|
|
276
|
+
function freeSpot(cand, n, abs, d, myGroups, groupBox) {
|
|
277
|
+
const obstacles = [];
|
|
278
|
+
for (const [id, b] of abs)
|
|
279
|
+
if (id !== n.id)
|
|
280
|
+
obstacles.push(b);
|
|
281
|
+
for (const g of d.groups) {
|
|
282
|
+
if (myGroups.has(g.id))
|
|
283
|
+
continue;
|
|
284
|
+
const b = groupBox(g.id);
|
|
285
|
+
if (b)
|
|
286
|
+
obstacles.push(b);
|
|
287
|
+
}
|
|
288
|
+
const ok = (b) => obstacles.every((o) => !overlaps(b, o, GAP));
|
|
289
|
+
const snap = (v) => Math.round(v / 8) * 8;
|
|
290
|
+
const start = { ...cand, x: snap(cand.x), y: snap(cand.y) };
|
|
291
|
+
if (ok(start))
|
|
292
|
+
return start;
|
|
293
|
+
for (let r = 1; r <= 80; r++) {
|
|
294
|
+
const ring = [];
|
|
295
|
+
for (let k = -r; k <= r; k++) {
|
|
296
|
+
ring.push({ ...start, x: start.x + k * 16, y: start.y - r * 16 }, { ...start, x: start.x + k * 16, y: start.y + r * 16 });
|
|
297
|
+
ring.push({ ...start, x: start.x - r * 16, y: start.y + k * 16 }, { ...start, x: start.x + r * 16, y: start.y + k * 16 });
|
|
298
|
+
}
|
|
299
|
+
ring.sort((a, b) => Math.hypot(a.x - start.x, a.y - start.y) - Math.hypot(b.x - start.x, b.y - start.y));
|
|
300
|
+
const hit = ring.find(ok);
|
|
301
|
+
if (hit)
|
|
302
|
+
return hit;
|
|
303
|
+
}
|
|
304
|
+
return start;
|
|
305
|
+
}
|
|
306
|
+
function withoutTheme(meta) {
|
|
307
|
+
const { aiviz_theme: _t, ...rest } = meta;
|
|
308
|
+
return rest;
|
|
309
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Read .drawio.svg / .drawio / plain mxfile XML into a flat list of cells.
|
|
2
|
+
import zlib from 'node:zlib';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { DOMParser, XMLSerializer } from '@xmldom/xmldom';
|
|
5
|
+
export function extractMxfile(text) {
|
|
6
|
+
const t = text.trimStart();
|
|
7
|
+
if (t.startsWith('<mxfile') || t.startsWith('<mxGraphModel'))
|
|
8
|
+
return text;
|
|
9
|
+
if (t.startsWith('<?xml') || t.startsWith('<svg') || t.startsWith('<!DOCTYPE')) {
|
|
10
|
+
const m = /<svg\b[^>]*\scontent="([^"]*)"/s.exec(text);
|
|
11
|
+
if (!m)
|
|
12
|
+
throw new Error('this SVG has no embedded draw.io diagram (no content attribute). Was it exported without "include a copy of my diagram"?');
|
|
13
|
+
return decodeEntities(m[1]);
|
|
14
|
+
}
|
|
15
|
+
throw new Error('not a draw.io file: expected .drawio.svg, .drawio or mxfile XML');
|
|
16
|
+
}
|
|
17
|
+
function decodeEntities(s) {
|
|
18
|
+
return s
|
|
19
|
+
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(+n))
|
|
20
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16)))
|
|
21
|
+
.replace(/"/g, '"')
|
|
22
|
+
.replace(/'/g, "'")
|
|
23
|
+
.replace(/</g, '<')
|
|
24
|
+
.replace(/>/g, '>')
|
|
25
|
+
.replace(/&/g, '&');
|
|
26
|
+
}
|
|
27
|
+
/** draw.io stores pages compressed by default: base64(deflateRaw(encodeURIComponent(xml))). */
|
|
28
|
+
export function inflatePage(data) {
|
|
29
|
+
const buf = zlib.inflateRawSync(Buffer.from(data.trim(), 'base64'));
|
|
30
|
+
return decodeURIComponent(buf.toString('utf8'));
|
|
31
|
+
}
|
|
32
|
+
export function readDrawio(text) {
|
|
33
|
+
let xml = extractMxfile(text);
|
|
34
|
+
const doc = new DOMParser().parseFromString(xml, 'text/xml');
|
|
35
|
+
let modelEl = null;
|
|
36
|
+
let name = 'Page-1';
|
|
37
|
+
const rootEl = doc.documentElement;
|
|
38
|
+
if (rootEl.nodeName === 'mxGraphModel')
|
|
39
|
+
modelEl = rootEl;
|
|
40
|
+
else {
|
|
41
|
+
const diagrams = rootEl.getElementsByTagName('diagram');
|
|
42
|
+
if (diagrams.length === 0)
|
|
43
|
+
throw new Error('mxfile has no <diagram>');
|
|
44
|
+
if (diagrams.length > 1)
|
|
45
|
+
process.emitWarning?.('linework: file has several pages; only the first is used');
|
|
46
|
+
const diag = diagrams[0];
|
|
47
|
+
name = diag.getAttribute('name') ?? name;
|
|
48
|
+
const inner = diag.getElementsByTagName('mxGraphModel')[0];
|
|
49
|
+
if (inner)
|
|
50
|
+
modelEl = inner;
|
|
51
|
+
else {
|
|
52
|
+
const pageXml = inflatePage(diag.textContent ?? '');
|
|
53
|
+
modelEl = new DOMParser().parseFromString(pageXml, 'text/xml').documentElement;
|
|
54
|
+
// Normalise to an uncompressed mxfile so downstream tools can read it.
|
|
55
|
+
xml = `<mxfile><diagram id="${diag.getAttribute('id') ?? 'p1'}" name="${(diag.getAttribute('name') ?? 'Page-1').replace(/"/g, '"')}">${pageXml}</diagram></mxfile>`;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const root = modelEl.getElementsByTagName('root')[0];
|
|
59
|
+
if (!root)
|
|
60
|
+
throw new Error('mxGraphModel has no <root>');
|
|
61
|
+
const cells = [];
|
|
62
|
+
const ser = new XMLSerializer();
|
|
63
|
+
let meta = {};
|
|
64
|
+
for (let n = root.firstChild; n; n = n.nextSibling) {
|
|
65
|
+
if (n.nodeType !== 1)
|
|
66
|
+
continue;
|
|
67
|
+
const el = n;
|
|
68
|
+
let attrs = {};
|
|
69
|
+
let cellEl = el;
|
|
70
|
+
if (el.nodeName === 'object' || el.nodeName === 'UserObject') {
|
|
71
|
+
attrs = attrMap(el);
|
|
72
|
+
const inner = firstChildEl(el, 'mxCell');
|
|
73
|
+
if (!inner)
|
|
74
|
+
continue;
|
|
75
|
+
cellEl = inner;
|
|
76
|
+
}
|
|
77
|
+
else if (el.nodeName !== 'mxCell')
|
|
78
|
+
continue;
|
|
79
|
+
const c = attrMap(cellEl);
|
|
80
|
+
const id = attrs.id ?? c.id;
|
|
81
|
+
if (id == null)
|
|
82
|
+
continue;
|
|
83
|
+
if (id === '0') {
|
|
84
|
+
meta = attrs;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const label = attrs.label ?? c.value ?? '';
|
|
88
|
+
const styleRaw = c.style ?? '';
|
|
89
|
+
const g = firstChildEl(cellEl, 'mxGeometry');
|
|
90
|
+
let geom;
|
|
91
|
+
if (g) {
|
|
92
|
+
const ga = attrMap(g);
|
|
93
|
+
const arr = firstChildEl(g, 'Array');
|
|
94
|
+
const points = [];
|
|
95
|
+
if (arr)
|
|
96
|
+
for (let p = arr.firstChild; p; p = p.nextSibling)
|
|
97
|
+
if (p.nodeType === 1) {
|
|
98
|
+
const pa = attrMap(p);
|
|
99
|
+
points.push({ x: +(pa.x ?? 0), y: +(pa.y ?? 0) });
|
|
100
|
+
}
|
|
101
|
+
geom = { x: +(ga.x ?? 0), y: +(ga.y ?? 0), w: +(ga.width ?? 0), h: +(ga.height ?? 0), points };
|
|
102
|
+
}
|
|
103
|
+
cells.push({
|
|
104
|
+
raw: ser.serializeToString(el),
|
|
105
|
+
geomRaw: g ? ser.serializeToString(g) : undefined,
|
|
106
|
+
id,
|
|
107
|
+
attrs: Object.keys(attrs).length ? attrs : { value: label },
|
|
108
|
+
label,
|
|
109
|
+
style: parseStyle(styleRaw),
|
|
110
|
+
styleRaw,
|
|
111
|
+
vertex: c.vertex === '1',
|
|
112
|
+
edge: c.edge === '1',
|
|
113
|
+
parent: c.parent,
|
|
114
|
+
source: c.source,
|
|
115
|
+
target: c.target,
|
|
116
|
+
geom,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return { xml, cells, byId: new Map(cells.map((c) => [c.id, c])), meta, name };
|
|
120
|
+
}
|
|
121
|
+
export function parseStyle(s) {
|
|
122
|
+
const out = {};
|
|
123
|
+
for (const part of s.split(';')) {
|
|
124
|
+
if (!part)
|
|
125
|
+
continue;
|
|
126
|
+
const i = part.indexOf('=');
|
|
127
|
+
if (i < 0)
|
|
128
|
+
out[part] = '1'; // bare names like "ellipse" or "rhombus"
|
|
129
|
+
else
|
|
130
|
+
out[part.slice(0, i)] = part.slice(i + 1);
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
function attrMap(el) {
|
|
135
|
+
const out = {};
|
|
136
|
+
for (let i = 0; i < el.attributes.length; i++) {
|
|
137
|
+
const a = el.attributes.item(i);
|
|
138
|
+
out[a.name] = a.value;
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
function firstChildEl(el, name) {
|
|
143
|
+
for (let n = el.firstChild; n; n = n.nextSibling)
|
|
144
|
+
if (n.nodeType === 1 && n.nodeName === name)
|
|
145
|
+
return n;
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
/** Visible text lines of an HTML label. */
|
|
149
|
+
export function labelLines(html) {
|
|
150
|
+
return html
|
|
151
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
152
|
+
.replace(/<\/(div|p|li)>/gi, '\n')
|
|
153
|
+
.replace(/<\/t[dh]>/gi, ' ')
|
|
154
|
+
.replace(/<[^>]+>/g, '')
|
|
155
|
+
.replace(/ /g, ' ')
|
|
156
|
+
.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&')
|
|
157
|
+
.split('\n')
|
|
158
|
+
.map((s) => s.trim())
|
|
159
|
+
.filter(Boolean);
|
|
160
|
+
}
|
|
161
|
+
/** Absolute box of a vertex, summing the offsets of the groups it sits in. */
|
|
162
|
+
export function absBox(m, id) {
|
|
163
|
+
const c = m.byId.get(id);
|
|
164
|
+
if (!c?.geom)
|
|
165
|
+
return undefined;
|
|
166
|
+
let { x, y } = c.geom;
|
|
167
|
+
for (let p = c.parent ? m.byId.get(c.parent) : undefined; p?.geom && p.vertex; p = p.parent ? m.byId.get(p.parent) : undefined) {
|
|
168
|
+
x += p.geom.x;
|
|
169
|
+
y += p.geom.y;
|
|
170
|
+
}
|
|
171
|
+
return { x, y, w: c.geom.w, h: c.geom.h };
|
|
172
|
+
}
|
|
173
|
+
/** Short fingerprint of a diagram's content; `show` stamps it so `build` can tell a stale spec. */
|
|
174
|
+
export function fingerprint(m) {
|
|
175
|
+
return createHash('sha1').update(m.cells.map((c) => c.raw).join('')).digest('hex').slice(0, 12);
|
|
176
|
+
}
|
|
177
|
+
const BASE_RE = /^#\s*(?:linework|aiviz)-base:\s*([0-9a-f]{6,40})/m;
|
|
178
|
+
export function specBase(specText) {
|
|
179
|
+
return BASE_RE.exec(specText)?.[1];
|
|
180
|
+
}
|