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 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 between columns cross as little as they
18
- can; orthogonal lines that never run through a card, room for every label, containers kept apart, and cards with no
19
- lines in a row of their own, under the view or at the bottom of their box. The same model gives the same picture.
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.10.0"`. npm 12 refuses git
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 between neighbouring columns cross as little as the order of the cards in a column allows (#53): a few barycentre
287
- sweeps (each card goes where its neighbours in the next or previous column lie, its place now breaking ties), and the
288
- best order found is kept only when it crosses less than the order given. cols: columns of ids in reading order, changed
289
- in place; links: [a, b] with b in a later column (lines that skip a column do not order). */
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 adj = links.filter(([a, b]) => colOf[a] != null && colOf[b] === colOf[a] + 1);
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 < adj.length; i++) for (let j = i + 1; j < adj.length; j++) { const [a, b] = adj[i], [c, d] = adj[j];
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 = adj.length > 1 ? crossings() : 0, keep = cols.map(c => c.slice()); // the order given, unless one crosses less
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 = adj.filter(([a, b]) => down ? b === id && at[a] != null : a === id && at[b] != null).map(([a, b]) => at[down ? a : b]);
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 = {}, taken = new Set(), key = (c, r) => c + ',' + r;
1565
- ids.forEach(i => { if (Array.isArray(L[i])) { out[i] = L[i]; taken.add(key(...L[i])); } });
1566
- ids.forEach(i => { if (out[i]) return; let [c, r] = auto[i] || [0, 0]; while (taken.has(key(c, r))) r++; out[i] = [c, r]; taken.add(key(c, r)); });
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) : flowPos(vid);
1611
- function flowPos(vid) {
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 cols = {}; ids.forEach(i => (cols[rank[i]] ||= []).push(i));
1621
- const pos = {};
1622
- const height = Math.max(1, ...Object.values(cols).map(c => c.filter(i => !sink(i)).length));
1623
- 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)));
1624
- untangle(mains, fwd.map(e => [e.from, e.to])); // in reading order, unless another order crosses less (#53)
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((i, r) => { pos[i] = [+c, r]; });
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] === r)) r++; c[a] = [nc, r]; }
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] === r)) r += arg > 0 ? 1 : -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.10.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/",