umlimator 0.10.0 → 0.11.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/README.md +20 -4
- package/lib/umlimator.js +87 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,9 +14,10 @@ drill-down, a data pipeline, an AI agent's skills, and host pages for embedding,
|
|
|
14
14
|
|
|
15
15
|
- **Layers**: a view per level (system, containers, components, flows). A stacked card opens its inner view, which the
|
|
16
16
|
host may load only when asked.
|
|
17
|
-
- **Layout and routing**: columns in reading order, each ordered so the lines
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
- **Layout and routing**: columns in reading order, each ordered so the lines cross as little as they can, those that
|
|
18
|
+
skip columns included; each card level with the cards its lines come from, so lines run straight; orthogonal lines
|
|
19
|
+
that never run through a card, room for every label, containers kept apart, and cards with no lines in a row of their
|
|
20
|
+
own, under the view or at the bottom of their box. The same model gives the same picture.
|
|
20
21
|
- **Reading**: animated flow, neighbours lit on selection, numbered steps, tours, minimap, light and dark themes.
|
|
21
22
|
- **Keyboard and screen readers**: cards, then lines, in the Tab order (a card's tabs and chips too, for a host that
|
|
22
23
|
listens for click); arrows between cards; selections announced.
|
|
@@ -30,7 +31,7 @@ drill-down, a data pipeline, an AI agent's skills, and host pages for embedding,
|
|
|
30
31
|
npm install umlimator
|
|
31
32
|
```
|
|
32
33
|
|
|
33
|
-
Or from GitHub, at a tag or any commit: `"umlimator": "github:zintaen/umlimator#v0.
|
|
34
|
+
Or from GitHub, at a tag or any commit: `"umlimator": "github:zintaen/umlimator#v0.11.0"`. npm 12 refuses git
|
|
34
35
|
dependencies unless the project opts in, so that also needs `allow-git=root` in the `.npmrc` next to `package.json`.
|
|
35
36
|
|
|
36
37
|
## Embed
|
|
@@ -111,6 +112,20 @@ The page has breadcrumbs, a details panel per card, ⌘K search, deep links (`#v
|
|
|
111
112
|
`stills` skips a view whose inputs have not changed since its last export (`--all` exports every view). Needs Python
|
|
112
113
|
3.9+ (standard library only), and Google Chrome or Chromium for `stills` and `check`.
|
|
113
114
|
|
|
115
|
+
## Map a repo with Claude Code
|
|
116
|
+
|
|
117
|
+
The `map-repo` skill reads a codebase, writes a C4-style model (context, containers, components), renders it to a page
|
|
118
|
+
and stills, and fixes what the layout check reports. Install it, then ask Claude Code to map the repository:
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
/plugin marketplace add zintaen/umlimator
|
|
122
|
+
/plugin install umlimator@umlimator
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
On Claude Code 2.1.275 or later, `/plugin install umlimator --marketplace zintaen/umlimator` does both in one step. The
|
|
126
|
+
skill needs git, Python 3.9+ and Chrome; on first use it fetches the CLI of one pinned release into
|
|
127
|
+
`~/.cache/umlimator/cli`.
|
|
128
|
+
|
|
114
129
|
## Icons
|
|
115
130
|
|
|
116
131
|
A node's `icon` names an entry in `icons/manifest.json`. Line icons ship in `icons/` and take the card's colour. Brand
|
|
@@ -152,6 +167,7 @@ docs/ the module's API and the editing design
|
|
|
152
167
|
tests/, bench/ tests, benchmark models and results
|
|
153
168
|
icons/ line icons and the logo manifest (sources and hashes only)
|
|
154
169
|
examples/ made-up systems: models, host pages and a Node script (live: zintaen.github.io/umlimator)
|
|
170
|
+
.claude-plugin/, plugins/ the Claude Code marketplace and its map-repo skill
|
|
155
171
|
```
|
|
156
172
|
|
|
157
173
|
## Licence
|
package/lib/umlimator.js
CHANGED
|
@@ -283,28 +283,71 @@ const warned = new Set(), warn = m => { if (!warned.has(m)) { warned.add(m); con
|
|
|
283
283
|
|
|
284
284
|
/* Layered auto-layout for views that carry no col/row: longest-path layers (cycles broken by DFS),
|
|
285
285
|
barycentre ordering sweeps, integer rows so row gaps stay free as routing channels. */
|
|
286
|
-
/* Lines
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
286
|
+
/* Lines cross as little as the order of the cards in a column allows (#53): a few barycentre sweeps (each card goes where
|
|
287
|
+
its neighbours in the next or previous column lie, its place now breaking ties), and the best order found is kept only
|
|
288
|
+
when it crosses less than the order given. A line that skips columns has a point in each column it passes (Sugiyama's
|
|
289
|
+
dummy nodes), so it orders the cards it runs between too. cols: columns of ids in reading order, changed in place;
|
|
290
|
+
links: [a, b] with b in a later column. */
|
|
290
291
|
function untangle(cols, links) {
|
|
291
292
|
const colOf = {}; cols.forEach((c, k) => c.forEach(id => { colOf[id] = k; }));
|
|
292
|
-
const
|
|
293
|
+
const segs = [], extra = new Set(), want = {};
|
|
294
|
+
links.forEach(([a, b], n) => {
|
|
295
|
+
const ka = colOf[a], kb = colOf[b]; if (ka == null || kb == null || kb <= ka) return;
|
|
296
|
+
let prev = a; const ra = cols[ka].indexOf(a), rb = cols[kb].indexOf(b);
|
|
297
|
+
for (let k = ka + 1; k < kb; k++) { // a point first goes where the straight line between its ends would be
|
|
298
|
+
const p = '\u0001' + n + '.' + k; extra.add(p); colOf[p] = k; want[p] = ra + (rb - ra) * (k - ka) / (kb - ka);
|
|
299
|
+
segs.push([prev, p]); prev = p; }
|
|
300
|
+
segs.push([prev, b]);
|
|
301
|
+
});
|
|
302
|
+
if (extra.size) cols.forEach((c, k) => { const ps = [...extra].filter(p => colOf[p] === k);
|
|
303
|
+
if (ps.length) cols[k] = [...c.map((id, i) => [id, i, 0]), ...ps.map(p => [p, want[p], 1])].sort((x, y) => x[1] - y[1] || x[2] - y[2]).map(x => x[0]); });
|
|
304
|
+
const ins = {}, outs = {}; segs.forEach(([a, b]) => { (outs[a] ||= []).push(b); (ins[b] ||= []).push(a); });
|
|
293
305
|
const crossings = () => { const at = {}; let n = 0; cols.forEach(c => c.forEach((id, i) => { at[id] = i; }));
|
|
294
|
-
for (let i = 0; i <
|
|
306
|
+
for (let i = 0; i < segs.length; i++) for (let j = i + 1; j < segs.length; j++) { const [a, b] = segs[i], [c, d] = segs[j];
|
|
295
307
|
if (colOf[a] === colOf[c] && (at[a] - at[c]) * (at[b] - at[d]) < 0) n++; }
|
|
296
308
|
return n; };
|
|
297
|
-
let best =
|
|
309
|
+
let best = segs.length > 1 ? crossings() : 0, keep = cols.map(c => c.slice()); // the order given, unless one crosses less
|
|
298
310
|
for (let it = 0; it < 8 && best; it++) {
|
|
299
311
|
const down = it % 2 === 0, seq = cols.map((_, k) => k).filter(k => down ? k > 0 : k < cols.length - 1);
|
|
300
312
|
if (!down) seq.reverse();
|
|
301
313
|
for (const k of seq) { const at = Object.fromEntries(cols[down ? k - 1 : k + 1].map((id, i) => [id, i]));
|
|
302
|
-
const key = (id, i) => { const nb =
|
|
314
|
+
const key = (id, i) => { const nb = ((down ? ins : outs)[id] || []).filter(x => at[x] != null).map(x => at[x]);
|
|
303
315
|
return nb.length ? nb.reduce((x, y) => x + y, 0) / nb.length : i; };
|
|
304
316
|
cols[k] = cols[k].map((id, i) => [id, key(id, i), i]).sort((p, q) => p[1] - q[1] || p[2] - q[2]).map(x => x[0]); }
|
|
305
317
|
const n = crossings(); if (n < best) { best = n; keep = cols.map(c => c.slice()); }
|
|
306
318
|
}
|
|
307
|
-
keep.forEach((c, k) => { cols[k] = c; });
|
|
319
|
+
keep.forEach((c, k) => { cols[k] = c.filter(id => !extra.has(id)); });
|
|
320
|
+
}
|
|
321
|
+
/* A card no line comes into goes just before its nearest callee, not in the first column: its lines get shorter. Returns
|
|
322
|
+
the ranks with it moved, or null when none moves (bestFlow keeps them when they route better). */
|
|
323
|
+
function pullSources(ids, links, rank) {
|
|
324
|
+
const into = new Set(links.map(([, b]) => b)); let moved = false; const out = { ...rank };
|
|
325
|
+
ids.forEach(i => { const to = links.filter(([a]) => a === i).map(([, b]) => rank[b]).filter(r => r != null);
|
|
326
|
+
if (to.length && !into.has(i) && Math.min(...to) - 1 > rank[i]) { out[i] = Math.min(...to) - 1; moved = true; } });
|
|
327
|
+
return moved ? out : null;
|
|
328
|
+
}
|
|
329
|
+
/* Rows for ordered columns: each card level with the cards its lines come from (the median row), then with those they go
|
|
330
|
+
to, then again from the left, as near as the order allows with its height between it and the next (least squares: pool
|
|
331
|
+
adjacent violators); on half rows from 0. A line between cards on one row runs straight, past fewer cards. h: an item's
|
|
332
|
+
height in rows (a box in a view with boxes); a link [a, b, oa, ob] leaves a at row oa inside it and reaches b at row
|
|
333
|
+
ob (a box's card), else at its middle. */
|
|
334
|
+
function rowsFor(cols, links, h = () => 1) {
|
|
335
|
+
const row = {}, colOf = {}; cols.forEach((c, k) => { let r = 0; c.forEach(id => { row[id] = r; r += h(id); colOf[id] = k; }); });
|
|
336
|
+
const ins = {}, outs = {}, at = (id, o) => o ?? (h(id) - 1) / 2; // where a link meets an item, in rows from its top
|
|
337
|
+
links.forEach(([a, b, oa, ob]) => { if (colOf[a] == null || colOf[b] == null || colOf[b] <= colOf[a]) return;
|
|
338
|
+
(outs[a] ||= []).push([b, at(b, ob), at(a, oa)]); (ins[b] ||= []).push([a, at(a, oa), at(b, ob)]); });
|
|
339
|
+
const median = xs => { const s = xs.slice().sort((a, b) => a - b), m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; };
|
|
340
|
+
const fit = (list, want) => { const blocks = []; let off = 0;
|
|
341
|
+
list.forEach(id => { blocks.push({ ids: [id], sum: want[id] - off, n: 1 }); off += h(id);
|
|
342
|
+
while (blocks.length > 1 && blocks.at(-2).sum / blocks.at(-2).n > blocks.at(-1).sum / blocks.at(-1).n) { const b = blocks.pop(), a = blocks.at(-1); a.ids.push(...b.ids); a.sum += b.sum; a.n += b.n; } });
|
|
343
|
+
off = 0; blocks.forEach(b => b.ids.forEach(id => { row[id] = b.sum / b.n + off; off += h(id); })); };
|
|
344
|
+
for (const [from, to, step] of [[1, cols.length, 1], [cols.length - 2, -1, -1], [1, cols.length, 1]])
|
|
345
|
+
for (let k = from; k !== to; k += step) { const want = {};
|
|
346
|
+
cols[k].forEach(id => { const nb = (step > 0 ? ins : outs)[id] || []; want[id] = nb.length ? median(nb.map(([x, ox, o]) => row[x] + ox - o)) : row[id]; });
|
|
347
|
+
fit(cols[k], want); }
|
|
348
|
+
const low = Math.min(...Object.values(row));
|
|
349
|
+
cols.forEach(c => c.forEach((id, i) => { row[id] = Math.round((row[id] - low) * 2) / 2; if (i) row[id] = Math.max(row[id], row[c[i - 1]] + h(c[i - 1])); }));
|
|
350
|
+
return row;
|
|
308
351
|
}
|
|
309
352
|
|
|
310
353
|
/* A view about one node: the node sits in the middle, callers to its left, callees to its right,
|
|
@@ -1561,9 +1604,9 @@ function mapContext(model, opts = {}) {
|
|
|
1561
1604
|
function fillLayout(vid, L) {
|
|
1562
1605
|
const ids = (V[vid].nodeIds || []).filter(i => N[i]);
|
|
1563
1606
|
if (ids.every(i => Array.isArray(L[i]))) return L;
|
|
1564
|
-
const auto = autoPos(vid), out = {}
|
|
1565
|
-
ids.forEach(i => { if (Array.isArray(L[i]))
|
|
1566
|
-
ids.forEach(i => { if (out[i]) return; let [c, r] = auto[i] || [0, 0]; while (
|
|
1607
|
+
const auto = autoPos(vid), out = {};
|
|
1608
|
+
ids.forEach(i => { if (Array.isArray(L[i])) out[i] = L[i]; });
|
|
1609
|
+
ids.forEach(i => { if (out[i]) return; let [c, r] = auto[i] || [0, 0]; while (Object.values(out).some(([c2, r2]) => c2 === c && Math.abs(r2 - r) < 1)) r++; out[i] = [c, r]; }); // auto rows may be half rows: a row apart
|
|
1567
1610
|
return out;
|
|
1568
1611
|
}
|
|
1569
1612
|
/* plain views: longest-path columns from the roots, state at the bottom of its column */
|
|
@@ -1596,9 +1639,12 @@ function mapContext(model, opts = {}) {
|
|
|
1596
1639
|
items.forEach(x => (cols[rank[x]] ||= []).push(x));
|
|
1597
1640
|
const main = cols.map(l => (l || []).filter(x => !sink(x)).sort((a, b) => first(a) - first(b)));
|
|
1598
1641
|
untangle(main, dag); // in call order, unless another order crosses less (#53)
|
|
1642
|
+
const inside = (x, id) => ids.includes(x) ? 0 : (size[x].pos[id] || [0, null])[1]; // the row of a box's card that a line meets
|
|
1643
|
+
const row = rowsFor(main, fwd.filter(level).map(e => { const a = itemIn(e.from, g), b = itemIn(e.to, g); return a !== undefined && b !== undefined && a !== b ? [a, b, inside(a, e.from), inside(b, e.to)] : null; }).filter(Boolean),
|
|
1644
|
+
x => size[x].h); // each block level with the cards its lines come from and go to
|
|
1599
1645
|
main.forEach((l, k) => { cols[k] = [...l, ...(cols[k] || []).filter(sink).sort((a, b) => first(a) - first(b))]; });
|
|
1600
1646
|
cols.forEach(list => { let r0 = 0, w = 0;
|
|
1601
|
-
list.forEach(x => { const sz = size[x];
|
|
1647
|
+
list.forEach(x => { const sz = size[x]; if (row[x] != null) r0 = Math.max(r0, row[x]);
|
|
1602
1648
|
Object.entries(sz.pos).forEach(([id, [c, r]]) => { pos[id] = [c0 + c, r0 + r]; }); r0 += sz.h; w = Math.max(w, sz.w); });
|
|
1603
1649
|
c0 += w; h = Math.max(h, r0); });
|
|
1604
1650
|
const per = Math.max(2, c0); loose.forEach((x, k) => { pos[x] = [k % per, h + Math.floor(k / per)]; });
|
|
@@ -1607,8 +1653,23 @@ function mapContext(model, opts = {}) {
|
|
|
1607
1653
|
};
|
|
1608
1654
|
return block(null).pos;
|
|
1609
1655
|
}
|
|
1610
|
-
const autoPos = vid => ((V[vid] || {}).containers || []).length ? blockPos(vid) :
|
|
1611
|
-
|
|
1656
|
+
const autoPos = vid => ((V[vid] || {}).containers || []).length ? blockPos(vid) : bestFlow(vid);
|
|
1657
|
+
const level = e => !/^(top|bottom)$/.test(e.fromSide) && !/^(top|bottom)$/.test(e.toSide); // a line into a card's top or bottom asks for no row
|
|
1658
|
+
/* A small plain view is routed a few ways and keeps the one the layout check scores best: cards level with their
|
|
1659
|
+
neighbours, or packed from the top as before, with cards no line comes into beside their callee or not. A cheap
|
|
1660
|
+
version of the CLI's search (optimize), affordable up to 30 cards; a larger view takes the first way. */
|
|
1661
|
+
function bestFlow(vid) {
|
|
1662
|
+
const first = flowPos(vid), n = ((V[vid] || {}).nodeIds || []).length;
|
|
1663
|
+
if (n > 30 || n < 3) return first;
|
|
1664
|
+
const seen = new Set([JSON.stringify(first)]), ways = [first];
|
|
1665
|
+
for (const o of [{ pull: true }, { packed: true }, { pull: true, packed: true }]) { const p = flowPos(vid, o), k = JSON.stringify(p); if (!seen.has(k)) { seen.add(k); ways.push(p); } }
|
|
1666
|
+
if (ways.length < 2) return first;
|
|
1667
|
+
let best = first, score = Infinity;
|
|
1668
|
+
ways.forEach(p => { const g = buildGeo(vid, p), s = scoreGeo(g); if (s < score - 0.5) { score = s; best = p; drawn[vid] = { pos: p, g }; } }); // as it will be drawn: with port repair
|
|
1669
|
+
return best;
|
|
1670
|
+
}
|
|
1671
|
+
const drawn = {}; // the way bestFlow kept, routed already: the view's next buildGeo takes it once instead of routing it again
|
|
1672
|
+
function flowPos(vid, o = {}) {
|
|
1612
1673
|
const v = V[vid], lp = loopsOf(v), fwd = lp.edges.filter((e, k) => !lp.back.has(k) && e.from !== e.to);
|
|
1613
1674
|
// cards with no line go in rows of their own under the view (#46), as wide as its columns; they stacked with the sources
|
|
1614
1675
|
const all = (v.nodeIds || []).filter(i => N[i]), lined = new Set(lp.edges.filter(e => e.from !== e.to).flatMap(e => [e.from, e.to]));
|
|
@@ -1617,14 +1678,16 @@ function mapContext(model, opts = {}) {
|
|
|
1617
1678
|
for (let it = 0; it <= ids.length; it++) fwd.forEach(e => { if (rank[e.to] < rank[e.from] + 1) rank[e.to] = rank[e.from] + 1; });
|
|
1618
1679
|
const seen = id => { const k = lp.edges.findIndex(e => e.to === id || e.from === id); return k < 0 ? 999 : k; };
|
|
1619
1680
|
const sink = i => isState(i) && !fwd.some(e => e.from === i); // state that leads nowhere sits along the bottom
|
|
1620
|
-
const
|
|
1621
|
-
const
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1681
|
+
const pos = {}, links = fwd.map(e => [e.from, e.to]);
|
|
1682
|
+
const order = rk => { const cols = {}; ids.forEach(i => (cols[rk[i]] ||= []).push(i));
|
|
1683
|
+
const ranks = Object.keys(cols).map(Number).sort((a, b) => a - b), mains = ranks.map(c => cols[c].filter(i => !sink(i)).sort((a, b) => seen(a) - seen(b)));
|
|
1684
|
+
untangle(mains, links); return { cols, ranks, mains }; }; // in reading order, unless another order crosses less (#53)
|
|
1685
|
+
const { cols, ranks, mains } = order((o.pull && pullSources(ids, links, rank)) || rank);
|
|
1686
|
+
const row = o.packed ? Object.fromEntries(mains.flatMap(m => m.map((i, r) => [i, r]))) : rowsFor(mains, fwd.filter(level).map(e => [e.from, e.to]));
|
|
1687
|
+
const height = Math.max(0, ...Object.values(row)) + 1;
|
|
1625
1688
|
Object.entries(cols).forEach(([c, list]) => {
|
|
1626
1689
|
const main = mains[ranks.indexOf(+c)], st = list.filter(sink).sort((a, b) => seen(a) - seen(b));
|
|
1627
|
-
main.forEach(
|
|
1690
|
+
main.forEach(i => { pos[i] = [+c, row[i]]; });
|
|
1628
1691
|
st.forEach((i, r) => { pos[i] = [+c, height + r]; }); // state along the bottom, never above the flow
|
|
1629
1692
|
});
|
|
1630
1693
|
const per = Math.max(2, Object.keys(cols).length), under = Math.max(...Object.values(pos).map(p => p[1])) + 1;
|
|
@@ -1669,6 +1732,7 @@ function mapContext(model, opts = {}) {
|
|
|
1669
1732
|
const P = planCache[vid] || { flow: true, backKeys: loopsOf(v).keys, steps: {} };
|
|
1670
1733
|
if (!override && P.hub) return hubGeo(vid, P, edges, repair, quick);
|
|
1671
1734
|
const pos = override || (v.layout ? fillLayout(vid, v.layout) : (layoutCache[vid] ||= autoPos(vid)));
|
|
1735
|
+
const ready = drawn[vid]; delete drawn[vid]; if (ready && !override && ready.pos === pos && repair === (v.repair !== false) && !quick) return ready.g;
|
|
1672
1736
|
const mainSteps = v.mainSteps || v.render === 'process';
|
|
1673
1737
|
const stepIds = v.stepIds || ids;
|
|
1674
1738
|
const dg = { nodeH: mainSteps ? 82 : v.subAndChips ? 80 : 64, subAndChips: !!v.subAndChips, mainSteps, containers: v.containers, nodes: ids.map(i => { const n = nodeIn(i, vid), step = mainSteps && stepIds.includes(i); return { ...n, sub: n.sub || n.sublabel, emphasis: !!step || !!n.emphasis, step: step ? stepIds.indexOf(i) + 1 : null, verbs: step ? [] : n.verbs, kids: kidCount(i, vid), opens: !!opensOf(i, vid), col: (pos[i] || [0, 0])[0], row: (pos[i] || [0, 0])[1] }; }),
|
|
@@ -1760,11 +1824,11 @@ function mapContext(model, opts = {}) {
|
|
|
1760
1824
|
if (evals >= budget) break;
|
|
1761
1825
|
const c = JSON.parse(JSON.stringify(pos));
|
|
1762
1826
|
if (kind === 'swap') { if (a === hub) continue; const t2 = c[a]; c[a] = c[arg]; c[arg] = t2; }
|
|
1763
|
-
else if (kind === 'col') { const nc = ca + arg; let r = ra; while (ids.some(b => c[b][0] === nc && c[b][1]
|
|
1827
|
+
else if (kind === 'col') { const nc = ca + arg; let r = ra; while (ids.some(b => b !== a && c[b][0] === nc && Math.abs(c[b][1] - r) < 1)) r++; c[a] = [nc, r]; }
|
|
1764
1828
|
else if (kind === 'row') {
|
|
1765
1829
|
let r = ra + arg;
|
|
1766
1830
|
if (band(a) && r <= stateFloor()) r = stateFloor() + 1;
|
|
1767
|
-
while (ids.some(b => c[b][0] === ca && c[b][1]
|
|
1831
|
+
while (ids.some(b => b !== a && c[b][0] === ca && Math.abs(c[b][1] - r) < 1)) r += arg > 0 ? 1 : -1; // rows may be half rows: keep a row apart
|
|
1768
1832
|
if (r < 0 || (band(a) && r <= stateFloor())) continue;
|
|
1769
1833
|
c[a][1] = r;
|
|
1770
1834
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "umlimator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Embeddable, layered architecture maps: layout, orthogonal routing and an interactive canvas in one ES module.",
|
|
5
5
|
"keywords": ["architecture", "diagram", "c4", "map", "svg", "layout", "orthogonal-routing", "drill-down"],
|
|
6
6
|
"homepage": "https://zintaen.github.io/umlimator/",
|