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.
- keygraph/__init__.py +8 -0
- keygraph/__main__.py +3 -0
- keygraph/adapters/__init__.py +33 -0
- keygraph/adapters/base.py +57 -0
- keygraph/adapters/clickhouse.py +67 -0
- keygraph/adapters/dump.py +169 -0
- keygraph/adapters/fetch.py +93 -0
- keygraph/adapters/live.py +50 -0
- keygraph/adapters/metabase.py +78 -0
- keygraph/adapters/mongo.py +93 -0
- keygraph/adapters/mysql.py +30 -0
- keygraph/adapters/postgres.py +68 -0
- keygraph/adapters/sqlite.py +24 -0
- keygraph/adapters/sqlserver.py +65 -0
- keygraph/areas.py +225 -0
- keygraph/cli.py +497 -0
- keygraph/context.py +387 -0
- keygraph/docs_out.py +199 -0
- keygraph/explorer.py +128 -0
- keygraph/graph.py +163 -0
- keygraph/hubs.py +48 -0
- keygraph/infer.py +344 -0
- keygraph/mcp_server.py +147 -0
- keygraph/mirrors.py +109 -0
- keygraph/model.py +149 -0
- keygraph/notes.py +114 -0
- keygraph/overrides.py +112 -0
- keygraph/probe.py +119 -0
- keygraph/profile.py +262 -0
- keygraph/query.py +376 -0
- keygraph/report.py +92 -0
- keygraph/search.py +175 -0
- keygraph/static/DAGRE_LICENSE +19 -0
- keygraph/static/OFL-Inter.txt +93 -0
- keygraph/static/OFL-JetBrainsMono.txt +93 -0
- keygraph/static/dagre-1.1.4.min.js +801 -0
- keygraph/static/explorer.html +69 -0
- keygraph/static/explorer.js +1076 -0
- keygraph/static/favicon.svg +1 -0
- keygraph/static/keygraph-logo.svg +1 -0
- keygraph/static/keygraph-mark.svg +1 -0
- keygraph/static/keygraph.inline.css +843 -0
- keygraph/static/kg-diagram.js +267 -0
- keygraph/static/kg-theme.js +269 -0
- keygraph/summary.py +60 -0
- keygraph-0.2.0.dist-info/METADATA +334 -0
- keygraph-0.2.0.dist-info/RECORD +50 -0
- keygraph-0.2.0.dist-info/WHEEL +4 -0
- keygraph-0.2.0.dist-info/entry_points.txt +2 -0
- 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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"]))}
|