keygraph 0.2.0__py3-none-any.whl

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.
Files changed (50) hide show
  1. keygraph/__init__.py +8 -0
  2. keygraph/__main__.py +3 -0
  3. keygraph/adapters/__init__.py +33 -0
  4. keygraph/adapters/base.py +57 -0
  5. keygraph/adapters/clickhouse.py +67 -0
  6. keygraph/adapters/dump.py +169 -0
  7. keygraph/adapters/fetch.py +93 -0
  8. keygraph/adapters/live.py +50 -0
  9. keygraph/adapters/metabase.py +78 -0
  10. keygraph/adapters/mongo.py +93 -0
  11. keygraph/adapters/mysql.py +30 -0
  12. keygraph/adapters/postgres.py +68 -0
  13. keygraph/adapters/sqlite.py +24 -0
  14. keygraph/adapters/sqlserver.py +65 -0
  15. keygraph/areas.py +225 -0
  16. keygraph/cli.py +497 -0
  17. keygraph/context.py +387 -0
  18. keygraph/docs_out.py +199 -0
  19. keygraph/explorer.py +128 -0
  20. keygraph/graph.py +163 -0
  21. keygraph/hubs.py +48 -0
  22. keygraph/infer.py +344 -0
  23. keygraph/mcp_server.py +147 -0
  24. keygraph/mirrors.py +109 -0
  25. keygraph/model.py +149 -0
  26. keygraph/notes.py +114 -0
  27. keygraph/overrides.py +112 -0
  28. keygraph/probe.py +119 -0
  29. keygraph/profile.py +262 -0
  30. keygraph/query.py +376 -0
  31. keygraph/report.py +92 -0
  32. keygraph/search.py +175 -0
  33. keygraph/static/DAGRE_LICENSE +19 -0
  34. keygraph/static/OFL-Inter.txt +93 -0
  35. keygraph/static/OFL-JetBrainsMono.txt +93 -0
  36. keygraph/static/dagre-1.1.4.min.js +801 -0
  37. keygraph/static/explorer.html +69 -0
  38. keygraph/static/explorer.js +1076 -0
  39. keygraph/static/favicon.svg +1 -0
  40. keygraph/static/keygraph-logo.svg +1 -0
  41. keygraph/static/keygraph-mark.svg +1 -0
  42. keygraph/static/keygraph.inline.css +843 -0
  43. keygraph/static/kg-diagram.js +267 -0
  44. keygraph/static/kg-theme.js +269 -0
  45. keygraph/summary.py +60 -0
  46. keygraph-0.2.0.dist-info/METADATA +334 -0
  47. keygraph-0.2.0.dist-info/RECORD +50 -0
  48. keygraph-0.2.0.dist-info/WHEEL +4 -0
  49. keygraph-0.2.0.dist-info/entry_points.txt +2 -0
  50. keygraph-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,267 @@
1
+ /* kg-diagram.js - keygraph's diagram renderer: HTML boxes + one SVG layer for edges, laid out by dagre.
2
+ *
3
+ * Why not cytoscape: the views draw at most ~40 boxes, and the boxes are tables that list their key columns,
4
+ * with relations attached to the exact column rows. That is HTML (real text, CSS tokens, selectable, accessible),
5
+ * not canvas dots. dagre (@dagrejs/dagre 1.1.4, MIT, 94 KB) does the layered layout; this file does sizing,
6
+ * column-anchored edge routing, ER end markers, pan and zoom. Requires window.dagre.
7
+ *
8
+ * const d = KGDiagram.mount(container); // container gets .kg-viewport > .kg-world (+ svg.kg-edges)
9
+ * d.er({ tables, edges }, { rankdir: 'LR' }); // Area view (ER)
10
+ * d.map({ areas, links }); // Area map
11
+ * d.fit(); d.zoomBy(1.2); d.destroy();
12
+ *
13
+ * ER table: { id, schema (shown muted before the name), name, color, rows: [{ name, type, key: 'PK'|'FK'|'UK'|'' , hub?: 'users' }], more: 12,
14
+ * hubs: ['users'], neighbour: false, selected: false }
15
+ * ER edge: { from: { t, col }, to: { t, col }, kind: 'declared'|'inferred'|'polymorphic', conf: 0..1, reason }
16
+ * "from" is the referencing (FK, many) side, "to" the referenced (PK, one) side.
17
+ * Map area: { id, name, sub?: 'muted second line', count, top: ['orders', ...], color, other?: true } Map link: { a, b, count }
18
+ */
19
+ (function (root) {
20
+ const ROW = 24, HEAD = 38, MORE = 26, CARD_W = 196, NS = 'http://www.w3.org/2000/svg';
21
+ const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
22
+ const el = (tag, cls, html) => { const e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; };
23
+ const sv = (tag, attrs) => { const e = document.createElementNS(NS, tag); for (const k in attrs) e.setAttribute(k, attrs[k]); return e; };
24
+ const opacity = c => c >= 0.95 ? 1 : c >= 0.85 ? 0.8 : c >= 0.7 ? 0.6 : 0.4;
25
+
26
+ function mount(host) {
27
+ // the viewport is a child of host, so overlays already inside host (legend, zoom, strips) are kept
28
+ const container = el('div', 'kg-viewport'); host.prepend(container);
29
+ const world = el('div', 'kg-world'); const svg = sv('svg', { class: 'kg-edges' });
30
+ world.appendChild(svg); container.appendChild(world);
31
+ let tx = 0, ty = 0, k = 1, W = 0, H = 0;
32
+ const apply = () => { world.style.transform = `translate(${tx}px, ${ty}px) scale(${k})`; container.dataset.zoom = k.toFixed(2); };
33
+ const api = { container, world, svg, onSelect: null };
34
+
35
+ // pan and zoom
36
+ let drag = null;
37
+ container.addEventListener('pointerdown', e => { if (e.target.closest('.kg-node, button, a')) return; drag = { x: e.clientX - tx, y: e.clientY - ty }; container.setPointerCapture(e.pointerId); container.classList.add('is-panning'); });
38
+ container.addEventListener('pointermove', e => { if (!drag) return; tx = e.clientX - drag.x; ty = e.clientY - drag.y; apply(); });
39
+ container.addEventListener('pointerup', () => { drag = null; container.classList.remove('is-panning'); });
40
+ container.addEventListener('wheel', e => { e.preventDefault(); const r = container.getBoundingClientRect(); zoomAt(Math.exp(-e.deltaY * 0.0015), e.clientX - r.left, e.clientY - r.top); }, { passive: false });
41
+ function zoomAt(f, cx, cy) { const nk = Math.min(2, Math.max(0.3, k * f)); tx = cx - (cx - tx) * nk / k; ty = cy - (cy - ty) * nk / k; k = nk; apply(); }
42
+ api.zoomBy = f => { const r = container.getBoundingClientRect(); zoomAt(f, r.width / 2, r.height / 2); };
43
+ /* Fit the drawing, but never below minK: below ~0.8 the mono labels stop being readable, so a large
44
+ drawing is shown at minK centred on the focus box (or the middle) and the reader pans. */
45
+ api.fit = ({ pad = 48, maxK = 1, minK = 0.8, focus = null, offsetY = 0, inset = null, anchor = 'focus' } = {}) => {
46
+ // inset: { top, right, bottom, left } in px reserved for overlays (strips, callouts), overriding pad per side;
47
+ // anchor 'start' shows a drawing that cannot fit from its top-left corner instead of centring on `focus`
48
+ const r = container.getBoundingClientRect(); if (!W || !H) return;
49
+ const p = Object.assign({ top: pad, right: pad, bottom: pad, left: pad }, inset || {});
50
+ const aw = Math.max(40, r.width - p.left - p.right), ah = Math.max(40, r.height - p.top - p.bottom);
51
+ k = Math.min(maxK, aw / W, ah / H);
52
+ if (k >= minK) { tx = p.left + (aw - W * k) / 2; ty = p.top + (ah - H * k) / 2 + offsetY; return apply(); }
53
+ k = minK;
54
+ if (anchor === 'start') { tx = p.left; ty = p.top + offsetY; return apply(); } // too big: show the top-left, below any overlay
55
+ const f = focus && world.querySelector(`.kg-node[data-id="${CSS.escape(focus)}"]`);
56
+ const cx = f ? f.offsetLeft + f.offsetWidth / 2 : W / 2, cy = f ? f.offsetTop + f.offsetHeight / 2 : H / 2;
57
+ tx = p.left + aw / 2 - cx * k; ty = p.top + ah / 2 - cy * k + offsetY;
58
+ tx = Math.min(p.left, Math.max(r.width - p.right - W * k, tx)); ty = Math.min(p.top, Math.max(r.height - p.bottom - H * k, ty));
59
+ apply();
60
+ };
61
+ api.destroy = () => container.remove();
62
+
63
+ /* rankdir 'auto' lays the graph out both ways (LR and TB) and keeps the one that fits the viewport at the
64
+ larger zoom, so a wide canvas gets a wide drawing and a tall one a tall drawing. */
65
+ function layout(nodes, edges, opts) {
66
+ const run = dir => {
67
+ const g = new dagre.graphlib.Graph({ multigraph: true });
68
+ g.setGraph({ rankdir: dir, nodesep: opts.nodesep || 28, ranksep: opts.ranksep || 96, marginx: 8, marginy: 8, ranker: opts.ranker || 'network-simplex' });
69
+ g.setDefaultEdgeLabel(() => ({}));
70
+ nodes.forEach(n => g.setNode(n.id, { width: n.w, height: n.h }));
71
+ edges.forEach((e, i) => { if (g.hasNode(e.s) && g.hasNode(e.t) && e.s !== e.t) g.setEdge(e.s, e.t, { weight: e.weight || 1, minlen: e.minlen || 1 }, 'e' + i); });
72
+ dagre.layout(g); return g;
73
+ };
74
+ const r = container.getBoundingClientRect();
75
+ const score = g => Math.min((r.width - 96) / g.graph().width, (r.height - 96) / g.graph().height);
76
+ let g = run(opts.rankdir === 'auto' ? 'LR' : (opts.rankdir || 'LR'));
77
+ if (opts.rankdir === 'auto') { const t = run('TB'); if (score(t) > score(g) * 1.05) g = t; }
78
+ nodes.forEach(n => { const p = g.node(n.id); n.x = p.x - n.w / 2; n.y = p.y - n.h / 2; });
79
+ W = g.graph().width; H = g.graph().height; api.graph = g; api.rankdir = g.graph().rankdir;
80
+ pack(nodes, edges, r);
81
+ world.style.width = W + 'px'; world.style.height = H + 'px';
82
+ svg.setAttribute('width', W); svg.setAttribute('height', H); svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
83
+ }
84
+
85
+ /* dagre stacks disconnected components in one long column. Shelf-pack them instead, to the viewport's
86
+ aspect, so an area of loosely connected tables fills the canvas rather than one strip. */
87
+ function pack(nodes, edges, r) {
88
+ const parent = {}; const find = x => parent[x] === x ? x : (parent[x] = find(parent[x]));
89
+ nodes.forEach(n => { parent[n.id] = n.id; });
90
+ edges.forEach(e => { if (parent[e.s] != null && parent[e.t] != null) parent[find(e.s)] = find(e.t); });
91
+ const comps = {}; nodes.forEach(n => (comps[find(n.id)] = comps[find(n.id)] || []).push(n));
92
+ const list = Object.values(comps); if (list.length < 2) return;
93
+ const GAP = 28, boxes = list.map(ns => { const x0 = Math.min(...ns.map(n => n.x)), y0 = Math.min(...ns.map(n => n.y));
94
+ return { ns, x0, y0, w: Math.max(...ns.map(n => n.x + n.w)) - x0, h: Math.max(...ns.map(n => n.y + n.h)) - y0 }; });
95
+ boxes.sort((a, b) => b.ns.length - a.ns.length || b.h - a.h);
96
+ const area = boxes.reduce((s, b) => s + (b.w + GAP) * (b.h + GAP), 0), aspect = Math.max(0.6, (r.width || 900) / (r.height || 700));
97
+ const rowW = Math.max(Math.max(...boxes.map(b => b.w)), Math.sqrt(area * aspect));
98
+ let x = 8, y = 8, rowH = 0;
99
+ boxes.forEach(b => {
100
+ if (x > 8 && x + b.w > rowW + 8) { x = 8; y += rowH + GAP; rowH = 0; }
101
+ b.ns.forEach(n => { n.x += x - b.x0; n.y += y - b.y0; });
102
+ x += b.w + GAP; rowH = Math.max(rowH, b.h);
103
+ });
104
+ W = Math.max(...nodes.map(n => n.x + n.w)) + 8; H = Math.max(...nodes.map(n => n.y + n.h)) + 8;
105
+ world.style.width = W + 'px'; world.style.height = H + 'px';
106
+ svg.setAttribute('width', W); svg.setAttribute('height', H); svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
107
+ }
108
+
109
+ /* ---------- ER (Area view) ---------- */
110
+ api.er = (data, opts = {}) => {
111
+ world.querySelectorAll('.kg-node').forEach(n => n.remove()); svg.innerHTML = '';
112
+ const byId = {};
113
+ const nodes = data.tables.map(t => {
114
+ const h = HEAD + t.rows.length * ROW + (t.more ? MORE : 0) + 2;
115
+ return byId[t.id] = { ...t, w: CARD_W, h };
116
+ });
117
+ // rank direction: referenced (parent) tables first, so edges run parent -> child
118
+ layout(nodes, data.edges.map(e => ({ s: e.to.t, t: e.from.t, weight: e.kind === 'declared' ? 3 : 1 })), opts);
119
+ nodes.forEach(n => world.appendChild(card(n)));
120
+ const rowY = (n, col) => { const i = n.rows.findIndex(r => r.name === col); const top = n.y + HEAD; return i < 0 ? n.y + HEAD / 2 : top + i * ROW + ROW / 2; };
121
+ data.edges.forEach((e, i) => {
122
+ const a = byId[e.from.t], b = byId[e.to.t]; if (!a || !b) return;
123
+ const ay = rowY(a, e.from.col), by = rowY(b, e.to.col);
124
+ const aRight = a.x + a.w / 2 < b.x + b.w / 2; // child left of parent?
125
+ const ax = aRight ? a.x + a.w : a.x, bx = aRight ? b.x : b.x + b.w;
126
+ const dir = aRight ? 1 : -1, dx = Math.max(40, Math.abs(bx - ax) / 2);
127
+ const d = `M${ax},${ay} C${ax + dir * dx},${ay} ${bx - dir * dx},${by} ${bx},${by}`;
128
+ const g = sv('g', { class: `kg-edge kg-edge--${e.kind}`, 'data-i': i, 'data-from': e.from.t + '.' + e.from.col, 'data-to': e.to.t + '.' + e.to.col, style: `--kg-edge-opacity:${opacity(e.conf)}` });
129
+ g.appendChild(sv('path', { class: 'kg-edge-hit', d }));
130
+ g.appendChild(sv('path', { class: 'kg-edge-line', d }));
131
+ // many end (crow's foot) at the FK row, one end (bar) at the PK row
132
+ g.appendChild(sv('path', { class: 'kg-edge-end', d: `M${ax + dir * 12},${ay} L${ax},${ay - 5} M${ax + dir * 12},${ay} L${ax},${ay} M${ax + dir * 12},${ay} L${ax},${ay + 5}` }));
133
+ g.appendChild(sv('path', { class: 'kg-edge-end', d: `M${bx - dir * 7},${by - 5} L${bx - dir * 7},${by + 5}` }));
134
+ if (e.kind !== 'declared' && opts.confLabels !== false) {
135
+ const mx = (ax + bx) / 2, my = (ay + by) / 2, t = e.conf.toFixed(2);
136
+ g.appendChild(sv('rect', { class: 'kg-edge-pill', x: mx - 17, y: my - 9, width: 34, height: 18, rx: 9 }));
137
+ const tt = sv('text', { class: 'kg-edge-pilltext', x: mx, y: my + 4, 'text-anchor': 'middle' }); tt.textContent = t; g.appendChild(tt);
138
+ }
139
+ g.addEventListener('mouseenter', () => highlight(g, true)); g.addEventListener('mouseleave', () => highlight(g, false));
140
+ svg.appendChild(g);
141
+ });
142
+ return api;
143
+ };
144
+ function highlight(g, on) {
145
+ g.classList.toggle('is-hover', on);
146
+ [g.dataset.from, g.dataset.to].forEach(k => world.querySelectorAll(`[data-col="${CSS.escape(k)}"]`).forEach(r => r.classList.toggle('is-linked', on)));
147
+ }
148
+ function card(n, opts = {}) {
149
+ const c = el('div', 'kg-node kg-tcard' + (n.neighbour ? ' kg-tcard--neighbour' : '') + (n.focus ? ' kg-tcard--focus' : ''));
150
+ c.style.cssText = `left:${n.x}px;top:${n.y}px;width:${n.w}px;--kg-glyph-color:${n.color}`;
151
+ if (n.selected) c.setAttribute('aria-selected', 'true');
152
+ c.dataset.id = n.id; c.tabIndex = 0;
153
+ const hubs = n.hubs && n.hubs.length ? `<span class="kg-tcard-hubs">${n.hubs.map(h => `<span class="kg-hub" title="hub relation to ${esc(h)}, drawn as a label">→ ${esc(h)}</span>`).join('')}</span>` : '';
154
+ const schema = n.schema ? `<span class="kg-tcard-schema">${esc(n.schema)}</span>` : '';
155
+ c.title = (n.schema ? n.schema + '.' : '') + n.name;
156
+ c.innerHTML = `<div class="kg-tcard-head"><span class="kg-glyph kg-glyph--table"></span>${schema}<span class="kg-entity-name">${esc(n.name)}</span>${n.neighbour ? `<span class="kg-tcard-area">${esc(n.area || '')}</span>` : hubs}</div>`
157
+ + n.rows.map(r => `<div class="kg-tcard-row" data-col="${esc(n.id + '.' + r.name)}"><span>${r.key ? `<span class="kg-key kg-key--${r.key.toLowerCase()}">${r.key}</span>` : ''}</span><span class="kg-entity-name" title="${esc(r.name + ' ' + r.type)}">${esc(r.name)}</span>${opts.types ? `<span class="t">${esc(r.type)}</span>` : '<span></span>'}</div>`).join('')
158
+ + (n.more ? `<div class="kg-tcard-more">+${n.more} column${n.more === 1 ? '' : 's'}</div>` : '');
159
+ c.addEventListener('click', ev => api.onSelect && api.onSelect(n, ev));
160
+ return c;
161
+ }
162
+
163
+ /* ---------- Area map ----------
164
+ Radial, not layered: the busiest area sits in the middle, the areas it connects to on an inner ring (ordered
165
+ so linked areas sit side by side), everything else on an outer ring next to its strongest inner neighbour.
166
+ Lines are straight and clipped to box edges; their width grows with the relation count. The ellipse is
167
+ stretched to the viewport's aspect, so the map fills a laptop canvas without scrolling. */
168
+ api.map = (data, opts = {}) => {
169
+ world.querySelectorAll('.kg-node').forEach(n => n.remove()); svg.innerHTML = '';
170
+ const byId = {}, w = {}, deg = {};
171
+ const nodes = data.areas.map(a => { const bw = Math.round(176 + Math.min(56, Math.sqrt(a.count) * 9)); return byId[a.id] = { ...a, w: bw, h: 44 + 20 * Math.min(3, a.top.length) + 24 + (a.sub ? 16 : 0) + (a.name.length > bw / 9 ? 18 : 0) }; });
172
+ const key = (a, b) => a < b ? a + '|' + b : b + '|' + a;
173
+ data.links.forEach(l => { w[key(l.a, l.b)] = l.count; deg[l.a] = (deg[l.a] || 0) + l.count; deg[l.b] = (deg[l.b] || 0) + l.count; });
174
+ const wt = (a, b) => w[key(a, b)] || 0;
175
+ const order = nodes.slice().sort((a, b) => (deg[b.id] || 0) - (deg[a.id] || 0) || a.id.localeCompare(b.id));
176
+ const center = order.find(n => !n.other) || order[0]; // the grouped "Other areas" box never takes the middle
177
+ let ring1 = order.filter(n => n !== center && wt(n.id, center.id) > 0 && !n.other);
178
+ const rest = order.filter(n => n !== center && !ring1.includes(n));
179
+ // greedy ordering of ring 1: start from the strongest, then always the area most linked to the previous one
180
+ const seq = []; let pool = ring1.slice();
181
+ while (pool.length) { const prev = seq[seq.length - 1]; pool.sort((a, b) => (prev ? wt(b.id, prev.id) - wt(a.id, prev.id) : 0) || wt(b.id, center.id) - wt(a.id, center.id)); seq.push(pool.shift()); }
182
+ ring1 = seq;
183
+ const r = container.getBoundingClientRect(), aspect = Math.max(0.8, Math.min(1.8, (r.width || 900) / (r.height || 700)));
184
+ const maxW = Math.max(...nodes.map(n => n.w)), maxH = Math.max(...nodes.map(n => n.h));
185
+ const grow = Math.max(1, ring1.length / 6);
186
+ const ry1 = (maxH + 48) * grow, rx1 = Math.max(maxW + 40, ry1 * aspect) * grow, ry2 = ry1 + maxH + 48, rx2 = rx1 + maxW + 36;
187
+ const place = (n, ang, ring) => { n.cx = Math.cos(ang) * (ring === 1 ? rx1 : rx2); n.cy = Math.sin(ang) * (ring === 1 ? ry1 : ry2); n.ang = ang; };
188
+ center.cx = 0; center.cy = 0;
189
+ ring1.forEach((n, i) => place(n, -Math.PI / 2 + (i / ring1.length) * 2 * Math.PI, 1));
190
+ // outer ring: near the strongest inner neighbour, nudged apart
191
+ const taken = [];
192
+ rest.forEach(n => {
193
+ const anchor = ring1.slice().sort((a, b) => wt(b.id, n.id) - wt(a.id, n.id))[0];
194
+ let ang = anchor && wt(anchor.id, n.id) ? anchor.ang : Math.PI / 2;
195
+ const step = Math.PI / Math.max(6, rest.length + 2);
196
+ for (let k = 0; taken.some(t => Math.abs(((t - ang + 3 * Math.PI) % (2 * Math.PI)) - Math.PI) < step) && k < 40; k++) ang += (k % 2 ? -1 : 1) * step * (k + 1);
197
+ taken.push(ang); place(n, ang, 2);
198
+ });
199
+ // settle with a small deterministic force pass: links pull (more relations, shorter line), boxes repel,
200
+ // rectangles never overlap (28px gap), and the picture is stretched to the viewport's aspect.
201
+ const L = data.links.filter(l => byId[l.a] && byId[l.b]);
202
+ for (let it = 0; it < 400; it++) {
203
+ const cool = 1 - it / 400;
204
+ nodes.forEach(n => { n.fx = -n.cx * 0.03 / aspect; n.fy = -n.cy * 0.03 * aspect; });
205
+ for (let i = 0; i < nodes.length; i++) for (let j = i + 1; j < nodes.length; j++) {
206
+ const a = nodes[i], b = nodes[j]; let dx = (b.cx - a.cx) / aspect, dy = b.cy - a.cy; const d2 = Math.max(400, dx * dx + dy * dy), f = 4e5 / d2, d = Math.sqrt(d2);
207
+ a.fx -= f * dx / d; a.fy -= f * dy / d; b.fx += f * dx / d; b.fy += f * dy / d;
208
+ }
209
+ L.forEach(l => { const a = byId[l.a], b = byId[l.b], dx = b.cx - a.cx, dy = b.cy - a.cy, d = Math.hypot(dx, dy) || 1, rest = maxW * 1.1 - Math.min(60, l.count * 4), f = (d - rest) * 0.02 * Math.log2(1 + l.count);
210
+ a.fx += f * dx / d; a.fy += f * dy / d; b.fx -= f * dx / d; b.fy -= f * dy / d; });
211
+ nodes.forEach(n => { if (n === center) return; n.cx += Math.max(-30, Math.min(30, n.fx)) * cool; n.cy += Math.max(-30, Math.min(30, n.fy)) * cool; });
212
+ for (let i = 0; i < nodes.length; i++) for (let j = i + 1; j < nodes.length; j++) {
213
+ const a = nodes[i], b = nodes[j], ox = (a.w + b.w) / 2 + 64 - Math.abs(a.cx - b.cx), oy = (a.h + b.h) / 2 + 48 - Math.abs(a.cy - b.cy);
214
+ if (ox > 0 && oy > 0) {
215
+ const sx = Math.sign(b.cx - a.cx) || 1, sy = Math.sign(b.cy - a.cy) || 1, ka = a === center ? 0 : b === center ? 1 : 0.5, kb = 1 - ka;
216
+ if (ox < oy) { a.cx -= sx * ox * ka; b.cx += sx * ox * kb; } else { a.cy -= sy * oy * ka; b.cy += sy * oy * kb; }
217
+ }
218
+ }
219
+ }
220
+ const minX = Math.min(...nodes.map(n => n.cx - n.w / 2)) - 8, minY = Math.min(...nodes.map(n => n.cy - n.h / 2)) - 8;
221
+ nodes.forEach(n => { n.x = n.cx - n.w / 2 - minX; n.y = n.cy - n.h / 2 - minY; });
222
+ W = Math.max(...nodes.map(n => n.x + n.w)) + 8; H = Math.max(...nodes.map(n => n.y + n.h)) + 8;
223
+ world.style.width = W + 'px'; world.style.height = H + 'px'; svg.setAttribute('width', W); svg.setAttribute('height', H); svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
224
+ const clip = (n, tx, ty) => { const cx = n.x + n.w / 2, cy = n.y + n.h / 2, dx = tx - cx, dy = ty - cy; const t = Math.min(Math.abs((n.w / 2 + 4) / (dx || 1e-9)), Math.abs((n.h / 2 + 4) / (dy || 1e-9))); return { x: cx + dx * t, y: cy + dy * t }; };
225
+ const maxC = Math.max(...data.links.map(l => l.count));
226
+ data.links.slice().sort((a, b) => a.count - b.count).forEach(l => {
227
+ const a = byId[l.a], b = byId[l.b]; if (!a || !b) return;
228
+ const p = clip(a, b.x + b.w / 2, b.y + b.h / 2), q = clip(b, a.x + a.w / 2, a.y + a.h / 2);
229
+ const grp = sv('g', { class: 'kg-link', style: `--kg-link-w:${(1 + 5 * Math.sqrt(l.count / maxC)).toFixed(1)}px` });
230
+ grp.appendChild(sv('path', { class: 'kg-link-line', d: `M${p.x},${p.y} L${q.x},${q.y}` }));
231
+ const mx = (p.x + q.x) / 2, my = (p.y + q.y) / 2;
232
+ grp.appendChild(sv('rect', { class: 'kg-edge-pill', x: mx - 15, y: my - 9, width: 30, height: 18, rx: 9 }));
233
+ const tt = sv('text', { class: 'kg-edge-pilltext', x: mx, y: my + 4, 'text-anchor': 'middle' }); tt.textContent = l.count; grp.appendChild(tt);
234
+ svg.appendChild(grp);
235
+ });
236
+ nodes.forEach(n => {
237
+ const c = el('div', 'kg-node kg-area' + (n.other ? ' kg-area--other' : '') + (n === center ? ' kg-area--center' : ''));
238
+ c.style.cssText = `left:${n.x}px;top:${n.y}px;width:${n.w}px;min-height:${n.h}px;--kg-glyph-color:${n.color}`;
239
+ c.tabIndex = 0; c.dataset.id = n.id;
240
+ const dots = Array.from({ length: Math.min(n.count, 24) }, () => '<i></i>').join('');
241
+ c.title = n.name + (n.sub ? ' · ' + n.sub : '');
242
+ c.innerHTML = `<div class="kg-area-head"><span class="kg-area-name">${esc(n.name)}</span><span class="kg-area-count">${n.count} tables</span></div>${n.sub ? `<div class="kg-area-sub">${esc(n.sub)}</div>` : ''}`
243
+ + `<div class="kg-area-dots" aria-hidden="true">${dots}${n.count > 24 ? '<b>+</b>' : ''}</div>`
244
+ + `<ul class="kg-area-top">${n.top.slice(0, 3).map(t => `<li>${esc(t)}</li>`).join('')}</ul>`;
245
+ c.addEventListener('click', ev => api.onSelect && api.onSelect(n, ev));
246
+ world.appendChild(c);
247
+ });
248
+ return api;
249
+ };
250
+ /* smooth curve through dagre's routed points (Catmull-Rom converted to cubic Bezier) */
251
+ function curve(p) {
252
+ if (p.length < 3) return `M${p[0].x},${p[0].y} L${p[p.length - 1].x},${p[p.length - 1].y}`;
253
+ let d = `M${p[0].x},${p[0].y}`;
254
+ for (let i = 0; i < p.length - 1; i++) {
255
+ const p0 = p[i - 1] || p[i], p1 = p[i], p2 = p[i + 1], p3 = p[i + 2] || p2;
256
+ d += ` C${p1.x + (p2.x - p0.x) / 6},${p1.y + (p2.y - p0.y) / 6} ${p2.x - (p3.x - p1.x) / 6},${p2.y - (p3.y - p1.y) / 6} ${p2.x},${p2.y}`;
257
+ }
258
+ return d;
259
+ }
260
+ function midpoint(d) { const p = sv('path', { d }); svg.appendChild(p); const L = p.getTotalLength(); const m = p.getPointAtLength(L / 2); p.remove(); return m; }
261
+
262
+ apply();
263
+ return api;
264
+ }
265
+
266
+ root.KGDiagram = { mount, ROW, HEAD, CARD_W };
267
+ })(typeof window !== 'undefined' ? window : globalThis);
@@ -0,0 +1,269 @@
1
+ /* kg-theme.js: theme helpers for the explorer - schemaColor (a colour per schema) and band (confidence band).
2
+ * Generated by design/build.py from tokens.json; edit design/src/kg-theme.src.js, then run
3
+ * `python3 design/build.py --inline --export-static`.
4
+ *
5
+ * KGTHEME.schemaColor(schemaIndex, 'light' | 'dark') -> '#rrggbb'
6
+ * KGTHEME.band(confidence) -> 'high' | 'medium' | 'low'
7
+ */
8
+ (function (root) {
9
+ const THEMES = {
10
+ "light": {
11
+ "graph": {
12
+ "edge-declared": "#5E6066",
13
+ "edge-inferred": "#7D7F86",
14
+ "edge-concept": "#0E8A80",
15
+ "edge-poly": "#B23F8A",
16
+ "edge-path": "#D99A0B",
17
+ "edge-dim": "#D9D9D5",
18
+ "node-ink": "#2A2C30",
19
+ "node-ring": "#FFFFFF",
20
+ "node-dim": "#E2E2DE",
21
+ "label": "#2A2C30",
22
+ "label-dim": "#B4B5B9",
23
+ "label-halo": "#F5F5F3",
24
+ "focus-ring": "#E2A317",
25
+ "column": "#55575D",
26
+ "concept": "#0E8A80",
27
+ "metric": "#6B4FCF",
28
+ "hop": [
29
+ "#17181A",
30
+ "#3A3C41",
31
+ "#62656B",
32
+ "#8C8F95",
33
+ "#B3B5B9",
34
+ "#CFD0D3"
35
+ ]
36
+ },
37
+ "schema": [
38
+ "#3E7BD6",
39
+ "#34914B",
40
+ "#C7681E",
41
+ "#7C5CD6",
42
+ "#D04F6A",
43
+ "#76861C",
44
+ "#9A6B4B",
45
+ "#5E7A96"
46
+ ],
47
+ "gen": {
48
+ "l": 0.62,
49
+ "c": 0.12,
50
+ "avoid-hue": [
51
+ [
52
+ 70,
53
+ 100
54
+ ],
55
+ [
56
+ 170,
57
+ 200
58
+ ],
59
+ [
60
+ 330,
61
+ 355
62
+ ]
63
+ ]
64
+ },
65
+ "canvas": "#F5F5F3"
66
+ },
67
+ "dark": {
68
+ "graph": {
69
+ "edge-declared": "#9B9DA3",
70
+ "edge-inferred": "#7E8188",
71
+ "edge-concept": "#3CC2B5",
72
+ "edge-poly": "#E57BC1",
73
+ "edge-path": "#F0B93A",
74
+ "edge-dim": "#24262A",
75
+ "node-ink": "#EDEDEC",
76
+ "node-ring": "#0D0E10",
77
+ "node-dim": "#24262A",
78
+ "label": "#D6D7D9",
79
+ "label-dim": "#4A4D53",
80
+ "label-halo": "#0D0E10",
81
+ "focus-ring": "#F0B93A",
82
+ "column": "#A3A5AB",
83
+ "concept": "#3CC2B5",
84
+ "metric": "#A48CF5",
85
+ "hop": [
86
+ "#EDEDEC",
87
+ "#C9CACD",
88
+ "#9B9DA3",
89
+ "#74777E",
90
+ "#555860",
91
+ "#3E4147"
92
+ ]
93
+ },
94
+ "schema": [
95
+ "#6FA2F2",
96
+ "#62C27A",
97
+ "#F2A061",
98
+ "#A68BF2",
99
+ "#F0839A",
100
+ "#B8C75A",
101
+ "#C79C7C",
102
+ "#8FA8C2"
103
+ ],
104
+ "gen": {
105
+ "l": 0.76,
106
+ "c": 0.11,
107
+ "avoid-hue": [
108
+ [
109
+ 70,
110
+ 100
111
+ ],
112
+ [
113
+ 170,
114
+ 200
115
+ ],
116
+ [
117
+ 330,
118
+ 355
119
+ ]
120
+ ]
121
+ },
122
+ "canvas": "#0D0E10"
123
+ }
124
+ };
125
+ const G = {
126
+ "$note": "Geometry shared by both themes. Sizes in px at zoom 1.",
127
+ "node": {
128
+ "table": {
129
+ "shape": "round-rectangle",
130
+ "size-by-hop": [
131
+ 40,
132
+ 26,
133
+ 18,
134
+ 13,
135
+ 10,
136
+ 8
137
+ ],
138
+ "aspect": 1.0
139
+ },
140
+ "column": {
141
+ "shape": "ellipse",
142
+ "size": 10
143
+ },
144
+ "concept": {
145
+ "shape": "round-diamond",
146
+ "size": 22,
147
+ "fill": "hollow"
148
+ },
149
+ "metric": {
150
+ "shape": "round-hexagon",
151
+ "size": 22,
152
+ "fill": "hollow"
153
+ },
154
+ "schema": {
155
+ "shape": "ellipse",
156
+ "size-min": 28,
157
+ "size-max": 96,
158
+ "scale": "sqrt(tables)"
159
+ },
160
+ "border": {
161
+ "default": 1.5,
162
+ "focus": 3,
163
+ "selected": 2.5
164
+ },
165
+ "polymorphic": {
166
+ "border-style": "dashed",
167
+ "border-color": "edge-poly"
168
+ }
169
+ },
170
+ "edge": {
171
+ "declared": {
172
+ "line-style": "solid",
173
+ "width": 1.6,
174
+ "arrow": "triangle"
175
+ },
176
+ "inferred": {
177
+ "line-style": "dashed",
178
+ "dash": [
179
+ 5,
180
+ 4
181
+ ],
182
+ "width-by-confidence": [
183
+ 0.8,
184
+ 1.8
185
+ ],
186
+ "arrow": "triangle"
187
+ },
188
+ "concept": {
189
+ "line-style": "dotted",
190
+ "width": 1.4,
191
+ "arrow": "none"
192
+ },
193
+ "polymorphic": {
194
+ "line-style": "dashed",
195
+ "dash": [
196
+ 1,
197
+ 3,
198
+ 6,
199
+ 3
200
+ ],
201
+ "width": 1.4,
202
+ "arrow": "triangle",
203
+ "source-arrow": "diamond"
204
+ },
205
+ "path": {
206
+ "width": 3,
207
+ "arrow": "triangle"
208
+ },
209
+ "opacity-by-confidence": {
210
+ "0.4": 0.25,
211
+ "0.6": 0.45,
212
+ "0.8": 0.7,
213
+ "0.95": 0.9,
214
+ "1.0": 1.0
215
+ },
216
+ "arrow-scale": 0.7
217
+ },
218
+ "confidence": {
219
+ "$note": "Bands drive chip colour and the default 'min confidence' preset.",
220
+ "high": {
221
+ "min": 0.9,
222
+ "label": "high"
223
+ },
224
+ "medium": {
225
+ "min": 0.7,
226
+ "label": "medium"
227
+ },
228
+ "low": {
229
+ "min": 0.0,
230
+ "label": "low"
231
+ }
232
+ },
233
+ "label": {
234
+ "size": 11,
235
+ "size-focus": 13,
236
+ "family": "mono",
237
+ "max-width": 140,
238
+ "halo": 3,
239
+ "rule": "Always label hop 0 and hop 1. Label hop 2 when zoom >= 0.9. Label hop 3+ only on hover, search match or selection."
240
+ },
241
+ "dim": {
242
+ "node-opacity": 0.18,
243
+ "edge-opacity": 0.08,
244
+ "label-opacity": 0
245
+ }
246
+ };
247
+
248
+ /* ---- colour helpers ---- */
249
+ function oklchToHex(L, C, h) {
250
+ const a = C * Math.cos(h * Math.PI / 180), b = C * Math.sin(h * Math.PI / 180);
251
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * b, m_ = L - 0.1055613458 * a - 0.0638541728 * b, s_ = L - 0.0894841775 * a - 1.2914855480 * b;
252
+ const l = l_ ** 3, m = m_ ** 3, s = s_ ** 3;
253
+ const rgb = [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s];
254
+ return '#' + rgb.map(v => { v = v <= 0.0031308 ? 12.92 * v : 1.055 * Math.pow(v, 1 / 2.4) - 0.055; return Math.round(Math.max(0, Math.min(1, v)) * 255).toString(16).padStart(2, '0'); }).join('');
255
+ }
256
+ /* Schemas 0-7 (ordered by table count, largest first) get the curated palette. Schema 8+ gets a generated hue:
257
+ golden-angle steps at fixed OKLCH lightness and chroma, skipping the hues reserved for the accent (brass),
258
+ concept (teal) and polymorphic (magenta). Past ~16 schemas, prefer grouping (see DESIGN.md). */
259
+ function schemaColor(i, theme) {
260
+ const t = THEMES[theme || 'light'];
261
+ if (i < t.schema.length) return t.schema[i];
262
+ let h = (i * 137.508 + 20) % 360, guard = 0;
263
+ while (t.gen['avoid-hue'].some(([a, b]) => h >= a && h <= b) && guard++ < 12) h = (h + 37) % 360;
264
+ return oklchToHex(t.gen.l, t.gen.c, h);
265
+ }
266
+ function band(conf) { return conf >= G.confidence.high.min ? 'high' : conf >= G.confidence.medium.min ? 'medium' : 'low'; }
267
+
268
+ root.KGTHEME = root.KG = { THEMES, GEOMETRY: G, schemaColor, band, oklchToHex };
269
+ })(typeof window !== 'undefined' ? window : globalThis);
keygraph/summary.py ADDED
@@ -0,0 +1,60 @@
1
+ """Per-table relation summaries grouped by column and area (feeds the Table view and `describe`).
2
+
3
+ A group is one column (or column set) of this table, or one far-side column for incoming edges.
4
+ Its `count` is the number of distinct far tables, and `collapsed` is set when that count exceeds
5
+ GROUP_COLLAPSE. `edges` lists every edge id in the group.
6
+
7
+ A hub label collects this table's outgoing hub or role edges to one target table. Its `count` is
8
+ the number of edges to that target (not distinct columns or tables), and `columns` lists the
9
+ table's own columns used on those edges.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from keygraph.areas import area_of
15
+ from keygraph.graph import Graph, edge_id
16
+
17
+ GROUP_COLLAPSE = 8
18
+
19
+
20
+ def relation_summary(graph: Graph, table: str, areas: list[dict], min_conf: float = 0.6,
21
+ edges: list[dict] | None = None, of: dict[str, str] | None = None) -> dict:
22
+ """edges/of: optional precomputed edges touching the table and table->area map (docs pass them to stay linear)."""
23
+ tid = graph.resolve(table)
24
+ of = of if of is not None else area_of(areas)
25
+ out: dict[tuple, dict] = {}
26
+ inc: dict[tuple, dict] = {}
27
+ hub_labels: dict[str, dict] = {}
28
+ for e in graph.edges if edges is None else edges:
29
+ if e["confidence"] < min_conf:
30
+ continue
31
+ if e["from"] == tid and (e.get("hub") or e.get("role")):
32
+ h = hub_labels.setdefault(e["to"], {"to": e["to"], "columns": [], "count": 0})
33
+ h["columns"] += [c for c in e["from_columns"] if c not in h["columns"]]
34
+ h["count"] += 1
35
+ continue
36
+ if e["from"] == tid:
37
+ key, far, col = ("out", ", ".join(e["from_columns"]), of.get(e["to"])), e["to"], ", ".join(e["from_columns"])
38
+ bucket = out
39
+ elif e["to"] == tid:
40
+ key, far, col = ("in", ", ".join(e["from_columns"]), of.get(e["from"])), e["from"], ", ".join(e["from_columns"])
41
+ bucket = inc
42
+ else:
43
+ continue
44
+ g = bucket.setdefault(key, {"column": col, "area": key[2], "count": 0, "tables": [], "edges": [], "_c": {}})
45
+ g["count"] += 1
46
+ g["_c"][far] = max(g["_c"].get(far, 0), e["confidence"])
47
+ g["edges"].append(edge_id(e))
48
+
49
+ def finish(b):
50
+ res = []
51
+ for g in b.values():
52
+ g["tables"] = sorted(g.pop("_c").items(), key=lambda kv: (-kv[1], kv[0]))
53
+ g["tables"] = [t for t, _ in g["tables"]]
54
+ g["count"] = len(g["tables"])
55
+ g["collapsed"] = g["count"] > GROUP_COLLAPSE
56
+ res.append(g)
57
+ return sorted(res, key=lambda g: (-g["count"], g["column"]))
58
+
59
+ return {"table": tid, "area": of.get(tid), "out": finish(out), "in": finish(inc),
60
+ "hub_labels": sorted(hub_labels.values(), key=lambda h: (-h["count"], h["to"]))}