quickdbd-cli 0.3.2__py3-none-any.whl → 0.3.4__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.
- quickdbd_cli/__init__.py +1 -1
- quickdbd_cli/agent.py +1 -7
- quickdbd_cli/assets/render.js +52 -0
- quickdbd_cli/assets/style.css +39 -2
- quickdbd_cli/assets/viewer.js +205 -27
- quickdbd_cli/cli.py +6 -2
- quickdbd_cli/export.py +15 -0
- quickdbd_cli/html.py +1 -0
- quickdbd_cli/layout.py +20 -1
- {quickdbd_cli-0.3.2.dist-info → quickdbd_cli-0.3.4.dist-info}/METADATA +6 -6
- {quickdbd_cli-0.3.2.dist-info → quickdbd_cli-0.3.4.dist-info}/RECORD +17 -17
- {quickdbd_cli-0.3.2.dist-info → quickdbd_cli-0.3.4.dist-info}/WHEEL +1 -1
- {quickdbd_cli-0.3.2.dist-info → quickdbd_cli-0.3.4.dist-info}/entry_points.txt +0 -0
- {quickdbd_cli-0.3.2.dist-info → quickdbd_cli-0.3.4.dist-info}/licenses/LICENSE +0 -0
- {quickdbd_cli-0.3.2.dist-info → quickdbd_cli-0.3.4.dist-info}/licenses/NOTICE +0 -0
- {quickdbd_cli-0.3.2.dist-info → quickdbd_cli-0.3.4.dist-info}/licenses/src/quickdbd_cli/assets/Inter-LICENSE.txt +0 -0
- {quickdbd_cli-0.3.2.dist-info → quickdbd_cli-0.3.4.dist-info}/licenses/src/quickdbd_cli/assets/elk-LICENSE.md +0 -0
quickdbd_cli/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.3.
|
|
1
|
+
__version__ = "0.3.4"
|
quickdbd_cli/agent.py
CHANGED
|
@@ -638,12 +638,6 @@ def diff_schema(ws: Workspace, args: dict) -> Result:
|
|
|
638
638
|
return Result("\n".join(clip(_header(c) + text.splitlines(), "lines")))
|
|
639
639
|
|
|
640
640
|
|
|
641
|
-
def _svg_size(svg: str) -> tuple[float, float]:
|
|
642
|
-
w = re.search(r'\bwidth="([\d.]+)', svg)
|
|
643
|
-
h = re.search(r'\bheight="([\d.]+)', svg)
|
|
644
|
-
return (float(w.group(1)) if w else 1000.0, float(h.group(1)) if h else 1000.0)
|
|
645
|
-
|
|
646
|
-
|
|
647
641
|
def render_diagram(ws: Workspace, args: dict) -> Result:
|
|
648
642
|
c = ws.compile(ws.target(args.get("project")))
|
|
649
643
|
tables = selected(c, args.get("select"))
|
|
@@ -679,7 +673,7 @@ def render_diagram(ws: Workspace, args: dict) -> Result:
|
|
|
679
673
|
raise ToolError("the layout took too long; narrow with `select` or `focus`") from None
|
|
680
674
|
pos = {k: [v[0], v[1]] for k, v in laid.items()}
|
|
681
675
|
svg = export.render_svg(c.model, pos, only=ids, mode=mode, embed_font=False)
|
|
682
|
-
scale = min(2.0, IMAGE_SIDE / max(
|
|
676
|
+
scale = min(2.0, IMAGE_SIDE / max(export.svg_size(svg)))
|
|
683
677
|
if scale >= MIN_SCALE[mode]:
|
|
684
678
|
png = export.svg_to_png(svg, scale)
|
|
685
679
|
names = ", ".join(t["name"] for t in tables[:40]) + (", …" if n > 40 else "")
|
quickdbd_cli/assets/render.js
CHANGED
|
@@ -547,6 +547,54 @@
|
|
|
547
547
|
return lines.join("\n");
|
|
548
548
|
}
|
|
549
549
|
|
|
550
|
+
/* ------------------------------------------------------------------ lit relationships (viewer only, never exported) */
|
|
551
|
+
var CARD_LABEL = { one: "1", zeroOne: "0..1", many: "*", zeroMany: "0..*" };
|
|
552
|
+
var CARD_WORD = { one: "one", zeroOne: "zero or one", many: "many", zeroMany: "zero or many" };
|
|
553
|
+
|
|
554
|
+
/* Cardinality labels for a lit line: one just past each end, on the side the line leaves from. a end first. */
|
|
555
|
+
function edgeLabels(model, pos, mode, rel, geo) {
|
|
556
|
+
var e = edgeGeom(model, geo || geometry(model, mode), pos, rel);
|
|
557
|
+
if (!e) return null;
|
|
558
|
+
function spot(x, y, dir, card) {
|
|
559
|
+
return { x: n(x + dir * 8), y: n(y - 9), anchor: dir > 0 ? "start" : "end", text: CARD_LABEL[card] || "" };
|
|
560
|
+
}
|
|
561
|
+
return [spot(e.xa, e.ya, e.da, rel.a.card), spot(e.xb, e.yb, e.db, rel.b.card)];
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/* Text of the relationship card for model.rels[i]: the child (FK / many) side first, a plain-English sentence and
|
|
565
|
+
* the JOIN, built like `qdbd path` so quoting and composite keys match. */
|
|
566
|
+
function relCard(model, i) {
|
|
567
|
+
var r = model.rels[i], byId = {};
|
|
568
|
+
model.tables.forEach(function (t) { byId[t.id] = t; });
|
|
569
|
+
var aMany = r.a.card === "many" || r.a.card === "zeroMany", bMany = r.b.card === "many" || r.b.card === "zeroMany";
|
|
570
|
+
var aIsChild = aMany || !bMany, child = aIsChild ? r.a : r.b, parent = aIsChild ? r.b : r.a;
|
|
571
|
+
var pairs = stepPairs(model, i, child.t);
|
|
572
|
+
function side(end, k) {
|
|
573
|
+
var t = byId[end.t], cols = pairs.map(function (p) {
|
|
574
|
+
var c = t.cols.filter(function (x) { return x.id === p[k]; })[0];
|
|
575
|
+
return c ? c.name : p[k];
|
|
576
|
+
});
|
|
577
|
+
return t.name + (cols.length > 1 ? ".(" + cols.join(", ") + ")" : cols.length ? "." + cols[0] : "");
|
|
578
|
+
}
|
|
579
|
+
var plural = child.card === "many" || child.card === "zeroMany";
|
|
580
|
+
return {
|
|
581
|
+
from: side(child, 0),
|
|
582
|
+
to: side(parent, 1),
|
|
583
|
+
cardFrom: CARD_LABEL[child.card] || "",
|
|
584
|
+
cardTo: CARD_LABEL[parent.card] || "",
|
|
585
|
+
sentence: "Each " + byId[parent.t].name + " row has " + CARD_WORD[child.card] + " " + byId[child.t].name + (plural ? " rows" : " row"),
|
|
586
|
+
sql: child.t === parent.t ? selfJoin(byId[child.t], pairs) : pathSql(model, child.t, [{ from: child, to: parent, pairs: pairs }]),
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
/* A table joined to itself needs an alias, or every column is ambiguous. */
|
|
590
|
+
function selfJoin(t, pairs) {
|
|
591
|
+
var name = sqlIdent(t.name), alias = sqlPart(t.name.split(".").pop() + "_2");
|
|
592
|
+
function col(c) { var x = t.cols.filter(function (k) { return k.id === c; })[0]; return sqlPart(x ? x.name : c); }
|
|
593
|
+
var on = pairs.map(function (p) { return alias + "." + col(p[1]) + " = " + name + "." + col(p[0]); });
|
|
594
|
+
return "FROM " + name + "\nJOIN " + name + " AS " + alias + " ON " +
|
|
595
|
+
(on.length ? on.join(" AND ") : "... -- table-level relationship: join columns not specified");
|
|
596
|
+
}
|
|
597
|
+
|
|
550
598
|
root.qdbd = {
|
|
551
599
|
findPath: findPath,
|
|
552
600
|
stepPairs: stepPairs,
|
|
@@ -563,8 +611,12 @@
|
|
|
563
611
|
renderSvg: renderSvg,
|
|
564
612
|
bounds: bounds,
|
|
565
613
|
neighbourhood: neighbourhood,
|
|
614
|
+
edgeLabels: edgeLabels,
|
|
615
|
+
relCard: relCard,
|
|
566
616
|
esc: esc,
|
|
567
617
|
};
|
|
618
|
+
root.qdbdEdgeLabels = edgeLabels;
|
|
619
|
+
root.qdbdRelCard = relCard;
|
|
568
620
|
root.qdbdRenderSvg = renderSvg;
|
|
569
621
|
root.qdbdSelect = select;
|
|
570
622
|
root.qdbdRenderDrawio = renderDrawio;
|
quickdbd_cli/assets/style.css
CHANGED
|
@@ -34,6 +34,8 @@
|
|
|
34
34
|
--t-edge: #b8bcc2;
|
|
35
35
|
--row-hl: #dbe6f6;
|
|
36
36
|
--row-hover: #eef3fa;
|
|
37
|
+
--lit: #4361ee;
|
|
38
|
+
--lit-deep: #2a3fb8;
|
|
37
39
|
}
|
|
38
40
|
/* Dark theme. viewer.js sets data-theme from the toggle, or from prefers-color-scheme when it's on auto. */
|
|
39
41
|
:root[data-theme="dark"] {
|
|
@@ -71,6 +73,8 @@
|
|
|
71
73
|
--t-edge: #5b6472;
|
|
72
74
|
--row-hl: #24344d;
|
|
73
75
|
--row-hover: #23282f;
|
|
76
|
+
--lit: #8aa0ff;
|
|
77
|
+
--lit-deep: #c3ceff;
|
|
74
78
|
}
|
|
75
79
|
::selection { background: var(--pick); color: var(--ink); }
|
|
76
80
|
:focus-visible { outline: 2px solid var(--amber); outline-offset: 1px; }
|
|
@@ -82,6 +86,7 @@ html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink);
|
|
|
82
86
|
#stage { position: relative; flex: 1; overflow: hidden; }
|
|
83
87
|
#canvas { width: 100%; height: 100%; display: block; cursor: grab; touch-action: none; user-select: none; }
|
|
84
88
|
#canvas.panning { cursor: grabbing; }
|
|
89
|
+
#canvas.lod .t text { display: none; } /* big diagram, zoomed out: see LOD_TABLES in viewer.js */
|
|
85
90
|
|
|
86
91
|
/* diagram colours: CSS beats the SVG presentation attributes, so the page can theme them */
|
|
87
92
|
.tb { fill: var(--t-body); stroke: var(--t-border); }
|
|
@@ -104,6 +109,36 @@ html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink);
|
|
|
104
109
|
.r.hl .rb { fill: var(--row-hl); fill-opacity: 1; }
|
|
105
110
|
.r:hover .rb { fill: var(--row-hover); fill-opacity: 1; }
|
|
106
111
|
.t.flash .tb { stroke: #f0a020; stroke-width: 3; }
|
|
112
|
+
.t .tb { transition: filter .18s; }
|
|
113
|
+
.t:hover .tb { filter: drop-shadow(0 6px 14px rgba(16, 24, 40, .16)); }
|
|
114
|
+
|
|
115
|
+
/* lit relationships: hovered or selected table, hovered or clicked line (viewer.js light()) */
|
|
116
|
+
.e.lit .ep { stroke: var(--lit); stroke-width: 2.2; }
|
|
117
|
+
.e.lit path, .e.lit .mc { stroke: var(--lit); }
|
|
118
|
+
.r.lit .rb { fill: var(--row-hl); fill-opacity: 1; }
|
|
119
|
+
.efocus .t:not(.on) { opacity: .18; }
|
|
120
|
+
.efocus .e:not(.lit) { opacity: .07; }
|
|
121
|
+
.fx { pointer-events: none; }
|
|
122
|
+
.fx .flow { fill: none; stroke: var(--lit-deep); stroke-width: 4; stroke-dasharray: 4 18; animation: qdbd-flow 1.1s linear infinite; }
|
|
123
|
+
.fx .flow.amber { stroke: #b86e00; }
|
|
124
|
+
@keyframes qdbd-flow { to { stroke-dashoffset: -22; } }
|
|
125
|
+
.fx .lbl { font: 600 11px Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill: var(--lit); paint-order: stroke;
|
|
126
|
+
stroke: var(--bg); stroke-width: 4px; stroke-linejoin: round; animation: qdbd-fade .2s ease-out; }
|
|
127
|
+
@keyframes qdbd-fade { from { opacity: 0; } }
|
|
128
|
+
.hit path { fill: none; stroke: transparent; stroke-width: 14; pointer-events: stroke; cursor: pointer; }
|
|
129
|
+
/* relationship card (a clicked line) */
|
|
130
|
+
.rcard { position: absolute; z-index: 6; display: none; min-width: 240px; max-width: min(460px, calc(100% - 16px)); padding: 10px 12px;
|
|
131
|
+
background: var(--panel); border: 1px solid var(--line); border-radius: 10px; box-shadow: var(--shadow); font-size: 12px;
|
|
132
|
+
color: var(--ink); transform-origin: top left; }
|
|
133
|
+
.rcard.open { display: block; animation: qdbd-cardin .16s ease-out; }
|
|
134
|
+
@keyframes qdbd-cardin { from { opacity: 0; transform: scale(.96) translateY(-4px); } }
|
|
135
|
+
.rcard .rt { font-weight: 600; margin-bottom: 2px; word-break: break-word; }
|
|
136
|
+
.rcard .rt b { color: var(--lit); font-weight: 600; padding: 0 6px; white-space: nowrap; }
|
|
137
|
+
.rcard .rk { color: var(--muted); margin-bottom: 8px; }
|
|
138
|
+
.rcard pre { margin: 0 0 8px; padding: 7px 8px; background: var(--bg); border: 1px solid var(--line); border-radius: 7px;
|
|
139
|
+
font: 11px ui-monospace, Menlo, Consolas, monospace; white-space: pre-wrap; user-select: text; }
|
|
140
|
+
.rcard .acts a { color: var(--blue); cursor: pointer; margin-right: 12px; }
|
|
141
|
+
@media (prefers-reduced-motion: reduce) { .fx .flow, .fx .lbl, .rcard.open { animation: none; } }
|
|
107
142
|
.t { cursor: grab; }
|
|
108
143
|
#canvas.panning .t { cursor: grabbing; }
|
|
109
144
|
|
|
@@ -176,8 +211,10 @@ html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink);
|
|
|
176
211
|
/* details drawer (right) */
|
|
177
212
|
.details { position: absolute; top: 64px; right: 16px; bottom: 16px; width: 380px; background: var(--panel);
|
|
178
213
|
border: 1px solid var(--line); border-radius: 10px; box-shadow: var(--shadow); overflow: auto;
|
|
179
|
-
|
|
180
|
-
.
|
|
214
|
+
z-index: 3; padding: 16px 18px; visibility: hidden; opacity: 0; transform: translateX(16px); pointer-events: none;
|
|
215
|
+
transition: opacity .2s, transform .2s, visibility 0s .2s; }
|
|
216
|
+
.details.open { visibility: visible; opacity: 1; transform: none; pointer-events: auto; transition: opacity .2s, transform .2s; }
|
|
217
|
+
@media (prefers-reduced-motion: reduce) { .details, .details.open { transition: none; } }
|
|
181
218
|
.has-details .minimap { right: 412px; }
|
|
182
219
|
.details .close { position: absolute; top: 10px; right: 10px; width: 28px; height: 28px; border: 0; border-radius: 6px;
|
|
183
220
|
background: transparent; display: flex; align-items: center; justify-content: center; color: var(--muted); cursor: pointer; }
|
quickdbd_cli/assets/viewer.js
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
shown: {},
|
|
17
17
|
regions: !!data.model.regions,
|
|
18
18
|
path: null, // {to: id, steps: [...]} from the selected table
|
|
19
|
+
edge: null, // the clicked (locked) relationship line: "a|ac|b|bc", like the .e data attributes
|
|
20
|
+
hover: null, // {t: table id} or {e: line key} under the mouse: lights lines without dimming anything
|
|
19
21
|
};
|
|
20
22
|
var svg = document.getElementById("canvas");
|
|
21
23
|
var vp = document.getElementById("vp");
|
|
@@ -29,7 +31,12 @@
|
|
|
29
31
|
|
|
30
32
|
/* ------------------------------------------------------------- render */
|
|
31
33
|
function render() {
|
|
32
|
-
vp.innerHTML = Q.renderInner(S.vm, S.pos, S.mode, { docMarks: true, regions: S.regions })
|
|
34
|
+
vp.innerHTML = Q.renderInner(S.vm, S.pos, S.mode, { docMarks: true, regions: S.regions }) +
|
|
35
|
+
'<g class="fx"></g><g class="hit"></g>';
|
|
36
|
+
vp.appendChild(vp.querySelector(".tables")); // lit overlay and line hit targets sit under the tables
|
|
37
|
+
relByKey = {};
|
|
38
|
+
S.vm.rels.forEach(function (r, i) { relByKey[[r.a.t, r.a.c, r.b.t, r.b.c].join("|")] = i; });
|
|
39
|
+
buildHits();
|
|
33
40
|
applyFocus();
|
|
34
41
|
drawMinimap();
|
|
35
42
|
}
|
|
@@ -37,12 +44,121 @@
|
|
|
37
44
|
function renderEdges() {
|
|
38
45
|
var g = vp.querySelector(".edges");
|
|
39
46
|
if (g) g.innerHTML = Q.renderEdges(S.vm, S.pos, S.mode);
|
|
47
|
+
buildHits();
|
|
40
48
|
applyFocus();
|
|
41
49
|
}
|
|
42
50
|
|
|
51
|
+
/* ------------------------------------------------------------- lit relationships
|
|
52
|
+
* A lit line gets the --lit colour, flowing dashes and cardinality labels (drawn in .fx), and its rows are
|
|
53
|
+
* highlighted. Lines light up under a hovered table or line, for the selected table, and for the clicked line. */
|
|
54
|
+
var relByKey = {};
|
|
55
|
+
function edgeKey(e) {
|
|
56
|
+
return [e.getAttribute("data-a"), e.getAttribute("data-ac"), e.getAttribute("data-b"), e.getAttribute("data-bc")].join("|");
|
|
57
|
+
}
|
|
58
|
+
/* Wide transparent copies of every line, so a 1px curve can be hovered and clicked. */
|
|
59
|
+
function buildHits() {
|
|
60
|
+
var hit = vp.querySelector(".hit");
|
|
61
|
+
if (!hit) return;
|
|
62
|
+
hit.innerHTML = [].map.call(vp.querySelectorAll(".e"), function (e) {
|
|
63
|
+
return '<path data-k="' + Q.esc(edgeKey(e)) + '" d="' + e.querySelector(".ep").getAttribute("d") + '"/>';
|
|
64
|
+
}).join("");
|
|
65
|
+
}
|
|
66
|
+
function light() {
|
|
67
|
+
var fx = vp.querySelector(".fx");
|
|
68
|
+
if (!fx) return;
|
|
69
|
+
if (S.edge && relByKey[S.edge] === undefined) S.edge = null; // the clicked line was filtered away or edited out
|
|
70
|
+
if (!S.edge) closeCard();
|
|
71
|
+
vp.querySelectorAll(".lit, .t.on").forEach(function (el) { el.classList.remove("lit", "on"); });
|
|
72
|
+
svg.classList.toggle("efocus", !!S.edge);
|
|
73
|
+
var focused = svg.classList.contains("focus"), h = S.hover || {}, geo = null, parts = [];
|
|
74
|
+
vp.querySelectorAll(".e").forEach(function (e) {
|
|
75
|
+
var k = edgeKey(e), a = e.getAttribute("data-a"), b = e.getAttribute("data-b"), d = e.querySelector(".ep").getAttribute("d");
|
|
76
|
+
var dk = ' data-k="' + Q.esc(k) + '"';
|
|
77
|
+
if (e.classList.contains("ph")) { parts.push('<path class="flow amber"' + dk + ' d="' + d + '"/>'); return; }
|
|
78
|
+
if (!(k === S.edge || h.e === k || h.t === a || h.t === b || e.classList.contains("hl") ||
|
|
79
|
+
(focused && !S.path && !e.classList.contains("dim")))) return;
|
|
80
|
+
var r = S.vm.rels[relByKey[k]];
|
|
81
|
+
if (!r) return;
|
|
82
|
+
e.classList.add("lit");
|
|
83
|
+
[[r.a.t, r.a.c], [r.b.t, r.b.c]].forEach(function (end) {
|
|
84
|
+
var t = vp.querySelector('.t[data-id="' + cssEsc(end[0]) + '"]');
|
|
85
|
+
if (!t) return;
|
|
86
|
+
if (k === S.edge) t.classList.add("on");
|
|
87
|
+
var row = t.querySelector('.r[data-c="' + cssEsc(end[1]) + '"]');
|
|
88
|
+
if (row) row.classList.add("lit");
|
|
89
|
+
});
|
|
90
|
+
parts.push('<path class="flow"' + dk + ' d="' + d + '"/>');
|
|
91
|
+
(Q.edgeLabels(S.vm, S.pos, S.mode, r, geo = geo || Q.geometry(S.vm, S.mode)) || []).forEach(function (l) {
|
|
92
|
+
parts.push('<text class="lbl"' + dk + ' x="' + l.x + '" y="' + l.y + '" text-anchor="' + l.anchor + '">' + Q.esc(l.text) + "</text>");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
fx.innerHTML = parts.join("");
|
|
96
|
+
}
|
|
97
|
+
var lightRaf = null;
|
|
98
|
+
function scheduleLight() {
|
|
99
|
+
if (!lightRaf) lightRaf = requestAnimationFrame(function () { lightRaf = null; light(); });
|
|
100
|
+
}
|
|
101
|
+
vp.addEventListener("mouseover", function (ev) {
|
|
102
|
+
var hp = ev.target.closest && ev.target.closest(".hit path"), t = ev.target.closest && ev.target.closest(".t");
|
|
103
|
+
var next = hp ? { e: hp.getAttribute("data-k") } : t ? { t: t.getAttribute("data-id") } : null;
|
|
104
|
+
if (JSON.stringify(next) !== JSON.stringify(S.hover)) { S.hover = next; scheduleLight(); }
|
|
105
|
+
});
|
|
106
|
+
/* A click on a line locks it: everything else dims. A second click on it, Esc or a click elsewhere clears it. */
|
|
107
|
+
function lockEdge(k, x, y) {
|
|
108
|
+
select(null);
|
|
109
|
+
S.edge = k;
|
|
110
|
+
light();
|
|
111
|
+
openCard(k, x, y);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* The relationship card: both ends with their cardinality, a plain-English sentence and the JOIN. */
|
|
115
|
+
var rcard = document.getElementById("rcard"), cardInfo = null;
|
|
116
|
+
function openCard(k, x, y) {
|
|
117
|
+
var r = S.vm.rels[relByKey[k]], c = Q.relCard(S.vm, relByKey[k]);
|
|
118
|
+
cardInfo = { sql: c.sql, tables: [r.a.t, r.b.t] };
|
|
119
|
+
rcard.innerHTML = '<div class="rt">' + Q.esc(c.from) + "<b>" + Q.esc(c.cardFrom) + " — " + Q.esc(c.cardTo) + "</b>" + Q.esc(c.to) +
|
|
120
|
+
'</div><div class="rk">' + Q.esc(c.sentence) + "</div><pre>" + Q.esc(c.sql) +
|
|
121
|
+
'</pre><div class="acts"><a id="rcopy">Copy JOIN</a><a id="rboth">Show both tables</a></div>';
|
|
122
|
+
rcard.classList.remove("open");
|
|
123
|
+
void rcard.offsetWidth; // restart the open animation
|
|
124
|
+
rcard.classList.add("open");
|
|
125
|
+
var st = document.getElementById("stage").getBoundingClientRect();
|
|
126
|
+
rcard.style.left = Math.max(8, Math.min(x - st.left + 12, st.width - rcard.offsetWidth - 8)) + "px";
|
|
127
|
+
rcard.style.top = Math.max(8, Math.min(y - st.top + 12, st.height - rcard.offsetHeight - 8)) + "px";
|
|
128
|
+
}
|
|
129
|
+
function closeCard() { rcard.classList.remove("open"); }
|
|
130
|
+
rcard.addEventListener("click", function (ev) {
|
|
131
|
+
if (ev.target.id === "rcopy" && cardInfo) copyText(cardInfo.sql, "JOIN copied", rcard.querySelector("pre"));
|
|
132
|
+
else if (ev.target.id === "rboth" && cardInfo) {
|
|
133
|
+
var names = cardInfo.tables.filter(function (t, i, all) { return all.indexOf(t) === i; }).map(function (t) { return byId[t].name; });
|
|
134
|
+
closeCard();
|
|
135
|
+
applyFilter(names.map(function (n) { return n.replace(/[\s,+!]/g, "?"); }).join(" "));
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
document.addEventListener("pointerdown", function (ev) {
|
|
139
|
+
if (!rcard.contains(ev.target) && !(ev.target.closest && ev.target.closest(".hit path"))) closeCard();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
/* The clipboard is blocked in some frames (the VS Code preview): select the text instead and say how to copy it. */
|
|
143
|
+
function copyText(text, done, pre) {
|
|
144
|
+
function fallback() {
|
|
145
|
+
var sel = window.getSelection(), range = document.createRange();
|
|
146
|
+
range.selectNodeContents(pre);
|
|
147
|
+
sel.removeAllRanges();
|
|
148
|
+
sel.addRange(range);
|
|
149
|
+
showToast("Press Ctrl/Cmd+C");
|
|
150
|
+
}
|
|
151
|
+
if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(text).then(function () { showToast(done); }, fallback);
|
|
152
|
+
else fallback();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Zoomed out on a big diagram, table text is unreadable but repainting it made every zoom step take ~150 ms
|
|
156
|
+
// at 1000 tables, so it is hidden there. Smaller diagrams always show it.
|
|
157
|
+
var LOD_TABLES = 300, LOD_ZOOM = 0.3;
|
|
43
158
|
function applyView() {
|
|
44
159
|
var v = S.view;
|
|
45
160
|
vp.setAttribute("transform", "translate(" + v.x + "," + v.y + ") scale(" + v.k + ")");
|
|
161
|
+
svg.classList.toggle("lod", v.k < LOD_ZOOM && S.vm.tables.length > LOD_TABLES);
|
|
46
162
|
zoomval.textContent = Math.round(v.k * 100) + "%";
|
|
47
163
|
drawViewportRect();
|
|
48
164
|
scheduleHash();
|
|
@@ -53,7 +169,28 @@
|
|
|
53
169
|
return [r.width || 800, r.height || 600];
|
|
54
170
|
}
|
|
55
171
|
|
|
56
|
-
|
|
172
|
+
/* Zoom, fit and jump glide instead of snapping. The next pointer press or wheel stops a glide. Instant with
|
|
173
|
+
* prefers-reduced-motion, on load and while a shared link is applied. */
|
|
174
|
+
var glide = null, glideTarget = null, reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
175
|
+
function glideTo(v, ms) {
|
|
176
|
+
cancelGlide();
|
|
177
|
+
if (!ms || reduceMotion.matches || applyingHash) { S.view = v; applyView(); return; }
|
|
178
|
+
var from = { x: S.view.x, y: S.view.y, k: S.view.k }, t0 = performance.now();
|
|
179
|
+
glideTarget = v;
|
|
180
|
+
(function step(now) {
|
|
181
|
+
var f = Math.min(1, (now - t0) / ms), e = 1 - Math.pow(1 - f, 3);
|
|
182
|
+
S.view = { x: from.x + (v.x - from.x) * e, y: from.y + (v.y - from.y) * e, k: from.k + (v.k - from.k) * e };
|
|
183
|
+
applyView();
|
|
184
|
+
glide = f < 1 ? requestAnimationFrame(step) : null;
|
|
185
|
+
if (!glide) glideTarget = null;
|
|
186
|
+
})(t0);
|
|
187
|
+
}
|
|
188
|
+
function cancelGlide() {
|
|
189
|
+
if (glide) cancelAnimationFrame(glide);
|
|
190
|
+
glide = glideTarget = null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function fit(only, animate) {
|
|
57
194
|
var b = Q.bounds(only ? Q.subset(S.vm, only) : S.vm, S.pos, S.mode), sz = stageSize(), pad = 40;
|
|
58
195
|
if (S.regions && !only) b = [b[0] - 30, b[1] - 60, b[2] + 30, b[3] + 30]; // room for region borders and labels
|
|
59
196
|
if (only) sz = [sz[0] - (details.classList.contains("open") ? 400 : 0), sz[1]]; // keep clear of the details panel
|
|
@@ -61,24 +198,21 @@
|
|
|
61
198
|
var top = 70, bottom = sz[0] < 900 ? 70 : pad, availH = sz[1] - top - bottom;
|
|
62
199
|
var k = Math.min((sz[0] - 2 * pad) / (b[2] - b[0]), availH / (b[3] - b[1]), 1.5);
|
|
63
200
|
k = Math.max(k, 0.03); // same floor as manual zoom, so wide diagrams still fit a phone screen
|
|
64
|
-
|
|
65
|
-
applyView();
|
|
201
|
+
glideTo({ k: k, x: (sz[0] - (b[2] - b[0]) * k) / 2 - b[0] * k, y: top + (availH - (b[3] - b[1]) * k) / 2 - b[1] * k }, animate ? 350 : 0);
|
|
66
202
|
}
|
|
67
203
|
|
|
68
|
-
function zoomAt(factor, cx, cy) {
|
|
69
|
-
var v = S.view, k = Math.min(4, Math.max(0.03, v.k * factor));
|
|
70
|
-
|
|
71
|
-
applyView();
|
|
204
|
+
function zoomAt(factor, cx, cy, ms) {
|
|
205
|
+
var v = glideTarget || S.view, k = Math.min(4, Math.max(0.03, v.k * factor)); // quick clicks compound
|
|
206
|
+
glideTo({ k: k, x: cx - (cx - v.x) * (k / v.k), y: cy - (cy - v.y) * (k / v.k) }, ms);
|
|
72
207
|
}
|
|
73
208
|
|
|
74
|
-
function centerOn(id) {
|
|
209
|
+
function centerOn(id, animate) {
|
|
75
210
|
var t = byId[id], p = S.pos[id];
|
|
76
211
|
if (!t || !p) return;
|
|
77
212
|
var g = Q.geometry(S.vm, S.mode)[id], sz = stageSize();
|
|
78
213
|
if (!g) return;
|
|
79
214
|
var k = Math.max(S.view.k, 0.9);
|
|
80
|
-
|
|
81
|
-
applyView();
|
|
215
|
+
glideTo({ k: k, x: sz[0] / 2 - (p[0] + g.w / 2) * k, y: sz[1] / 2 - (p[1] + g.h / 2) * k }, animate ? 350 : 0);
|
|
82
216
|
var el = vp.querySelector('.t[data-id="' + cssEsc(id) + '"]');
|
|
83
217
|
if (el) {
|
|
84
218
|
el.classList.add("flash");
|
|
@@ -100,6 +234,10 @@
|
|
|
100
234
|
}
|
|
101
235
|
|
|
102
236
|
function applyFocus() {
|
|
237
|
+
applyFocusClasses();
|
|
238
|
+
light();
|
|
239
|
+
}
|
|
240
|
+
function applyFocusClasses() {
|
|
103
241
|
vp.querySelectorAll(".ph").forEach(function (el) { el.classList.remove("ph"); });
|
|
104
242
|
if (S.path && S.focus && S.path.steps) {
|
|
105
243
|
var on = {}, edges = [];
|
|
@@ -157,7 +295,7 @@
|
|
|
157
295
|
if (t) t.classList.add("hl");
|
|
158
296
|
});
|
|
159
297
|
});
|
|
160
|
-
vp.addEventListener("mouseleave", function () { clearHover(); hideTip(); });
|
|
298
|
+
vp.addEventListener("mouseleave", function () { clearHover(); hideTip(); S.hover = null; scheduleLight(); });
|
|
161
299
|
|
|
162
300
|
/* ------------------------------------------------------------- doc-comment tooltips */
|
|
163
301
|
var tip = document.getElementById("tip");
|
|
@@ -205,17 +343,21 @@
|
|
|
205
343
|
/* ------------------------------------------------------------- pointer: pan, click, drag */
|
|
206
344
|
var drag = null;
|
|
207
345
|
svg.addEventListener("pointerdown", function (ev) {
|
|
346
|
+
cancelGlide();
|
|
208
347
|
if (ev.button !== 0) return;
|
|
209
348
|
var tEl = ev.target.closest && ev.target.closest(".t");
|
|
210
|
-
|
|
349
|
+
var hp = !tEl && ev.target.closest && ev.target.closest(".hit path");
|
|
350
|
+
drag = { sx: ev.clientX, sy: ev.clientY, moved: false, table: tEl ? tEl.getAttribute("data-id") : null, el: tEl,
|
|
351
|
+
edge: hp ? hp.getAttribute("data-k") : null };
|
|
211
352
|
if (drag.table) { // tables are draggable everywhere; only `qdbd serve` saves the new position
|
|
212
353
|
drag.start = S.pos[drag.table].slice();
|
|
354
|
+
dragPrep(drag);
|
|
213
355
|
tEl.parentNode.appendChild(tEl); // bring the grabbed table to the front
|
|
214
356
|
} else {
|
|
215
357
|
drag.view = { x: S.view.x, y: S.view.y };
|
|
216
358
|
}
|
|
217
359
|
svg.classList.add("panning");
|
|
218
|
-
svg.setPointerCapture(ev.pointerId);
|
|
360
|
+
try { svg.setPointerCapture(ev.pointerId); } catch (e) { /* synthetic events (the qdbd.dev tour) have no pointer to capture */ }
|
|
219
361
|
});
|
|
220
362
|
var raf = null;
|
|
221
363
|
svg.addEventListener("pointermove", function (ev) {
|
|
@@ -227,19 +369,20 @@
|
|
|
227
369
|
var p = [Math.round(drag.start[0] + dx / S.view.k), Math.round(drag.start[1] + dy / S.view.k)];
|
|
228
370
|
S.pos[drag.table] = p;
|
|
229
371
|
drag.el.setAttribute("transform", "translate(" + p[0] + "," + p[1] + ")");
|
|
230
|
-
if (!raf) raf = requestAnimationFrame(function () { raf = null;
|
|
372
|
+
if (!raf) raf = requestAnimationFrame(function () { raf = null; if (drag) dragEdges(drag); });
|
|
231
373
|
} else if (drag.view) {
|
|
232
374
|
S.view.x = drag.view.x + dx;
|
|
233
375
|
S.view.y = drag.view.y + dy;
|
|
234
376
|
applyView();
|
|
235
377
|
}
|
|
236
378
|
});
|
|
237
|
-
svg.addEventListener("pointerup", function () {
|
|
379
|
+
svg.addEventListener("pointerup", function (ev) {
|
|
238
380
|
if (!drag) return;
|
|
239
381
|
svg.classList.remove("panning");
|
|
240
382
|
var d = drag;
|
|
241
383
|
drag = null;
|
|
242
384
|
if (!d.moved) {
|
|
385
|
+
if (d.edge) { if (d.edge === S.edge) select(null); else lockEdge(d.edge, ev.clientX, ev.clientY); return; }
|
|
243
386
|
// Clicking the selected table toggles it off, unless it was selected quietly: then show its details.
|
|
244
387
|
if (d.table && d.table === S.focus && !details.classList.contains("open")) showDetails(d.table);
|
|
245
388
|
else select(d.table && S.focus !== d.table ? d.table : null);
|
|
@@ -247,14 +390,45 @@
|
|
|
247
390
|
}
|
|
248
391
|
if (d.start) {
|
|
249
392
|
if (S.regions) render(); // regions are sized from table positions
|
|
393
|
+
else renderEdges();
|
|
250
394
|
drawMinimap();
|
|
251
395
|
var name = byId[d.table].name, body = {};
|
|
252
396
|
body[name] = S.pos[d.table];
|
|
253
397
|
if (window.qdbdServe) window.qdbdServe.saveLayout(body);
|
|
254
398
|
}
|
|
255
399
|
});
|
|
400
|
+
/* While a table is dragged only its own lines are redrawn (a full redraw per frame was slow at 200+ tables). */
|
|
401
|
+
function dragPrep(d) {
|
|
402
|
+
var on = function (k) { var p = k.split("|"); return p[0] === d.table || p[2] === d.table; };
|
|
403
|
+
d.geo = Q.geometry(S.vm, S.mode);
|
|
404
|
+
d.rels = S.vm.rels.filter(function (r) { return r.a.t === d.table || r.b.t === d.table; });
|
|
405
|
+
d.els = {};
|
|
406
|
+
d.hits = {};
|
|
407
|
+
vp.querySelectorAll(".edges .e").forEach(function (e) { var k = edgeKey(e); if (on(k)) d.els[k] = e; });
|
|
408
|
+
vp.querySelectorAll(".hit path").forEach(function (h) { var k = h.getAttribute("data-k"); if (on(k)) d.hits[k] = h; });
|
|
409
|
+
}
|
|
410
|
+
function dragEdges(d) {
|
|
411
|
+
var tmp = document.createElementNS("http://www.w3.org/2000/svg", "g"), fx = vp.querySelector(".fx");
|
|
412
|
+
tmp.innerHTML = Q.renderEdges({ style: S.vm.style, tables: S.vm.tables, rels: d.rels }, S.pos, S.mode, d.geo);
|
|
413
|
+
[].slice.call(tmp.children).forEach(function (e) {
|
|
414
|
+
var k = edgeKey(e), old = d.els[k], path = e.querySelector(".ep").getAttribute("d");
|
|
415
|
+
if (!old) return;
|
|
416
|
+
e.setAttribute("class", old.getAttribute("class")); // keep dim / lit / ph
|
|
417
|
+
old.replaceWith(e);
|
|
418
|
+
d.els[k] = e;
|
|
419
|
+
if (d.hits[k]) d.hits[k].setAttribute("d", path);
|
|
420
|
+
var items = fx ? fx.querySelectorAll('[data-k="' + cssEsc(k) + '"]') : [], labels = null, li = 0;
|
|
421
|
+
[].forEach.call(items, function (it) {
|
|
422
|
+
if (it.tagName === "path") { it.setAttribute("d", path); return; }
|
|
423
|
+
labels = labels || Q.edgeLabels(S.vm, S.pos, S.mode, S.vm.rels[relByKey[k]], d.geo) || [];
|
|
424
|
+
var l = labels[li++];
|
|
425
|
+
if (l) { it.setAttribute("x", l.x); it.setAttribute("y", l.y); it.setAttribute("text-anchor", l.anchor); }
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
}
|
|
256
429
|
svg.addEventListener("wheel", function (ev) {
|
|
257
430
|
ev.preventDefault();
|
|
431
|
+
cancelGlide();
|
|
258
432
|
var r = svg.getBoundingClientRect();
|
|
259
433
|
var factor = Math.exp(-ev.deltaY * (ev.ctrlKey ? 0.01 : 0.0015));
|
|
260
434
|
zoomAt(factor, ev.clientX - r.left, ev.clientY - r.top);
|
|
@@ -265,9 +439,9 @@
|
|
|
265
439
|
|
|
266
440
|
/* ------------------------------------------------------------- toolbar */
|
|
267
441
|
function center() { var s = stageSize(); return [s[0] / 2, s[1] / 2]; }
|
|
268
|
-
document.getElementById("zin").onclick = function () { var c = center(); zoomAt(1.25, c[0], c[1]); };
|
|
269
|
-
document.getElementById("zout").onclick = function () { var c = center(); zoomAt(0.8, c[0], c[1]); };
|
|
270
|
-
document.getElementById("zfit").onclick = function () { fit(); };
|
|
442
|
+
document.getElementById("zin").onclick = function () { var c = center(); zoomAt(1.25, c[0], c[1], 200); };
|
|
443
|
+
document.getElementById("zout").onclick = function () { var c = center(); zoomAt(0.8, c[0], c[1], 200); };
|
|
444
|
+
document.getElementById("zfit").onclick = function () { fit(null, true); };
|
|
271
445
|
document.querySelectorAll("#modes button").forEach(function (b) {
|
|
272
446
|
b.onclick = function () {
|
|
273
447
|
setMode(b.getAttribute("data-mode"));
|
|
@@ -321,13 +495,14 @@
|
|
|
321
495
|
/* opts.center: pan to it. opts.quiet: don't open the details panel (only refresh it if it's already open). */
|
|
322
496
|
function select(id, opts) {
|
|
323
497
|
if ((id || null) !== S.focus) S.path = null;
|
|
498
|
+
S.edge = null; // a table (or nothing) replaces the clicked line
|
|
324
499
|
S.focus = id && byId[id] ? id : null;
|
|
325
500
|
scheduleHash();
|
|
326
501
|
applyFocus();
|
|
327
502
|
if (S.focus && (!(opts && opts.quiet) || details.classList.contains("open"))) showDetails(S.focus);
|
|
328
503
|
else closeDetails();
|
|
329
504
|
markTree();
|
|
330
|
-
if (S.focus && opts && opts.center) centerOn(S.focus);
|
|
505
|
+
if (S.focus && opts && opts.center) centerOn(S.focus, true);
|
|
331
506
|
}
|
|
332
507
|
|
|
333
508
|
/* ------------------------------------------------------------- explorer: search + table tree */
|
|
@@ -461,7 +636,7 @@
|
|
|
461
636
|
drawTree();
|
|
462
637
|
if (S.focus) showDetails(S.focus);
|
|
463
638
|
else closeDetails();
|
|
464
|
-
if (!keepView) fit();
|
|
639
|
+
if (!keepView) fit(null, true);
|
|
465
640
|
scheduleHash();
|
|
466
641
|
}
|
|
467
642
|
finput.addEventListener("input", function () {
|
|
@@ -586,7 +761,7 @@
|
|
|
586
761
|
S.path = { to: target, steps: Q.findPath(S.vm, S.focus, target) };
|
|
587
762
|
showDetails(S.focus);
|
|
588
763
|
applyFocus();
|
|
589
|
-
if (S.path.steps && !applyingHash) fit([S.focus].concat(S.path.steps.map(function (st) { return st.to.t; })));
|
|
764
|
+
if (S.path.steps && !applyingHash) fit([S.focus].concat(S.path.steps.map(function (st) { return st.to.t; })), true);
|
|
590
765
|
scheduleHash();
|
|
591
766
|
return true;
|
|
592
767
|
}
|
|
@@ -602,9 +777,7 @@
|
|
|
602
777
|
// Selector syntax treats spaces, commas, + and a leading ! specially; match those characters with "?".
|
|
603
778
|
applyFilter(names.map(function (n) { return n.replace(/[\s,+!]/g, "?"); }).join(" "));
|
|
604
779
|
} else if (ev.target.id === "pathsql" && S.path && S.path.steps) {
|
|
605
|
-
|
|
606
|
-
if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(sql).then(function () { showToast("SQL copied"); });
|
|
607
|
-
else window.prompt("Copy SQL:", sql);
|
|
780
|
+
copyText(Q.pathSql(S.vm, S.focus, S.path.steps), "SQL copied", dbody.querySelector("#pathres pre"));
|
|
608
781
|
}
|
|
609
782
|
});
|
|
610
783
|
|
|
@@ -645,6 +818,7 @@
|
|
|
645
818
|
r.setAttribute("height", sz[1] / v.k);
|
|
646
819
|
}
|
|
647
820
|
function miniPan(ev) {
|
|
821
|
+
cancelGlide();
|
|
648
822
|
var pt = mini.createSVGPoint();
|
|
649
823
|
pt.x = ev.clientX;
|
|
650
824
|
pt.y = ev.clientY;
|
|
@@ -654,7 +828,11 @@
|
|
|
654
828
|
applyView();
|
|
655
829
|
}
|
|
656
830
|
var miniDrag = false;
|
|
657
|
-
mini.addEventListener("pointerdown", function (ev) {
|
|
831
|
+
mini.addEventListener("pointerdown", function (ev) {
|
|
832
|
+
miniDrag = true;
|
|
833
|
+
try { mini.setPointerCapture(ev.pointerId); } catch (e) { /* synthetic event */ }
|
|
834
|
+
miniPan(ev);
|
|
835
|
+
});
|
|
658
836
|
mini.addEventListener("pointermove", function (ev) { if (miniDrag) miniPan(ev); });
|
|
659
837
|
mini.addEventListener("pointerup", function () { miniDrag = false; });
|
|
660
838
|
|
|
@@ -806,7 +984,7 @@
|
|
|
806
984
|
try {
|
|
807
985
|
if (p.has("m")) setMode(p.get("m"));
|
|
808
986
|
if (p.has("r")) setRegions(p.get("r") === "1");
|
|
809
|
-
applyFilter(p.get("f") || "", true);
|
|
987
|
+
if ((p.get("f") || "") !== S.filter) applyFilter(p.get("f") || "", true); // else already rendered
|
|
810
988
|
var name = p.get("t"), id = null;
|
|
811
989
|
if (name) S.model.tables.forEach(function (t) { if (t.name === name) id = t.id; });
|
|
812
990
|
select(id && S.shown[id] ? id : null);
|
quickdbd_cli/cli.py
CHANGED
|
@@ -216,7 +216,11 @@ def cmd_export(args) -> int:
|
|
|
216
216
|
if fmt == "svg":
|
|
217
217
|
out.write_text(svg, encoding="utf-8")
|
|
218
218
|
else:
|
|
219
|
-
|
|
219
|
+
scale = export.png_scale(svg, args.scale)
|
|
220
|
+
if scale < args.scale:
|
|
221
|
+
note = f"--scale lowered to {scale:.2f} to keep the PNG within {export.MAX_PNG_SIDE} px"
|
|
222
|
+
print(f"note: {note}", file=sys.stderr)
|
|
223
|
+
out.write_bytes(export.svg_to_png(svg, scale))
|
|
220
224
|
print(f"Wrote {out} ({len(only) if only else len(model['tables'])} tables)")
|
|
221
225
|
return 0
|
|
222
226
|
|
|
@@ -532,7 +536,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
532
536
|
action="append",
|
|
533
537
|
metavar="SELECTOR",
|
|
534
538
|
help="dbt-style: name glob, +name (and what it references), name+ (and what references it), "
|
|
535
|
-
"N+name+N (hops), group:/file:/col: methods, a,b intersects (repeatable)",
|
|
539
|
+
"N+name+N (hops), group:/file:/col:/tag: methods, a,b intersects (repeatable)",
|
|
536
540
|
)
|
|
537
541
|
sp.add_argument("--exclude", action="append", metavar="SELECTOR", help="drop tables matching (repeatable)")
|
|
538
542
|
|
quickdbd_cli/export.py
CHANGED
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import base64
|
|
6
|
+
import re
|
|
6
7
|
from functools import cache
|
|
7
8
|
from importlib import resources
|
|
8
9
|
|
|
9
10
|
from . import layout
|
|
10
11
|
from .jsrt import runtime
|
|
11
12
|
|
|
13
|
+
# A 200-table diagram at scale 2 is 17506x15274 px: 15 s to encode, and too big for many image viewers.
|
|
14
|
+
MAX_PNG_SIDE = 16384
|
|
15
|
+
|
|
12
16
|
|
|
13
17
|
def _asset_bytes(name: str) -> bytes:
|
|
14
18
|
return resources.files("quickdbd_cli").joinpath(f"assets/{name}").read_bytes()
|
|
@@ -85,6 +89,17 @@ def focus_positions(model: dict, positions: dict, ids: list[str]) -> dict:
|
|
|
85
89
|
return {k: [v[0], v[1]] for k, v in pos.items()}
|
|
86
90
|
|
|
87
91
|
|
|
92
|
+
def svg_size(svg: str) -> tuple[float, float]:
|
|
93
|
+
w = re.search(r'\bwidth="([\d.]+)', svg)
|
|
94
|
+
h = re.search(r'\bheight="([\d.]+)', svg)
|
|
95
|
+
return (float(w.group(1)) if w else 1000.0, float(h.group(1)) if h else 1000.0)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def png_scale(svg: str, scale: float) -> float:
|
|
99
|
+
"""``scale``, lowered so the PNG's longest side stays within MAX_PNG_SIDE pixels."""
|
|
100
|
+
return min(scale, MAX_PNG_SIDE / max(svg_size(svg)))
|
|
101
|
+
|
|
102
|
+
|
|
88
103
|
def svg_to_png(svg: str, scale: float = 2.0) -> bytes:
|
|
89
104
|
import resvg_py
|
|
90
105
|
|
quickdbd_cli/html.py
CHANGED
|
@@ -90,6 +90,7 @@ BODY = """
|
|
|
90
90
|
<svg id="mini" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet"></svg>
|
|
91
91
|
</div>
|
|
92
92
|
<div class="tip" id="tip" role="tooltip"></div>
|
|
93
|
+
<div class="rcard" id="rcard" role="dialog" aria-label="Relationship"></div>
|
|
93
94
|
<div class="toast" id="toast" role="status"></div>
|
|
94
95
|
<div class="diffbar" id="diffbar"></div>
|
|
95
96
|
<div class="hint">{hint}</div>
|
quickdbd_cli/layout.py
CHANGED
|
@@ -32,6 +32,10 @@ OVERLAP_OPTIONS = {
|
|
|
32
32
|
"elk.spacing.nodeNode": "40",
|
|
33
33
|
"elk.randomSeed": "1",
|
|
34
34
|
}
|
|
35
|
+
# Overlap removal takes most of a big layout (25 of 37 s at 1000 tables). Above BIG_LAYOUT tables its
|
|
36
|
+
# iterations are capped, and the few tables it leaves overlapping are moved by `_separate`.
|
|
37
|
+
BIG_LAYOUT = 300
|
|
38
|
+
BIG_OVERLAP_OPTIONS = {**OVERLAP_OPTIONS, "org.eclipse.elk.overlapRemoval.maxIterations": "8"}
|
|
35
39
|
|
|
36
40
|
MARGIN = 30
|
|
37
41
|
Positions = dict[str, tuple[int, int]]
|
|
@@ -64,9 +68,10 @@ def elk_layout(tables: list[dict], rels: list[dict], timeout_sec: float | None =
|
|
|
64
68
|
}
|
|
65
69
|
first = rt.call_async("qdbdElkLayout", graph, timeout_sec=timeout_sec)
|
|
66
70
|
first = _landscape(tables, {k: (v[0], v[1]) for k, v in first.items()})
|
|
71
|
+
big = len(tables) > BIG_LAYOUT
|
|
67
72
|
graph = {
|
|
68
73
|
"id": "root",
|
|
69
|
-
"layoutOptions": OVERLAP_OPTIONS,
|
|
74
|
+
"layoutOptions": BIG_OVERLAP_OPTIONS if big else OVERLAP_OPTIONS,
|
|
70
75
|
"children": [
|
|
71
76
|
{"id": t["id"], "width": t["w"], "height": t["h"], "x": first[t["id"]][0], "y": first[t["id"]][1]}
|
|
72
77
|
for t in tables
|
|
@@ -74,6 +79,8 @@ def elk_layout(tables: list[dict], rels: list[dict], timeout_sec: float | None =
|
|
|
74
79
|
"edges": [],
|
|
75
80
|
}
|
|
76
81
|
raw = rt.call_async("qdbdElkLayout", graph, timeout_sec=timeout_sec)
|
|
82
|
+
if big:
|
|
83
|
+
raw = _separate(tables, raw)
|
|
77
84
|
min_x = min(v[0] for v in raw.values())
|
|
78
85
|
min_y = min(v[1] for v in raw.values())
|
|
79
86
|
return {k: (int(v[0] - min_x), int(v[1] - min_y)) for k, v in raw.items()}
|
|
@@ -155,6 +162,18 @@ def _free_spot(cx: float, cy: float, w: float, h: float, rects, step: int = 30)
|
|
|
155
162
|
return int(round(x0)), int(round(y0))
|
|
156
163
|
|
|
157
164
|
|
|
165
|
+
def _separate(tables: list[dict], pos: dict) -> dict:
|
|
166
|
+
"""Move each table that overlaps an earlier one to the nearest free spot."""
|
|
167
|
+
out, rects = {}, []
|
|
168
|
+
for t in tables:
|
|
169
|
+
x, y = pos[t["id"]]
|
|
170
|
+
if _overlaps(x, y, t["w"], t["h"], rects):
|
|
171
|
+
x, y = _free_spot(x + t["w"] / 2, y + t["h"] / 2, t["w"], t["h"], rects)
|
|
172
|
+
out[t["id"]] = (x, y)
|
|
173
|
+
rects.append((x, y, t["w"], t["h"]))
|
|
174
|
+
return out
|
|
175
|
+
|
|
176
|
+
|
|
158
177
|
def place_missing(tables: list[dict], rels: list[dict], positions: Positions) -> Positions:
|
|
159
178
|
"""Positions for every table. Existing positions never move.
|
|
160
179
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: quickdbd-cli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: Compile QuickDBD-syntax schema files into interactive HTML ER diagrams, with no table limit.
|
|
5
5
|
Project-URL: Homepage, https://qdbd.dev/
|
|
6
6
|
Project-URL: Documentation, https://qdbd.dev/guide/
|
|
@@ -43,7 +43,7 @@ Description-Content-Type: text/markdown
|
|
|
43
43
|
</picture>
|
|
44
44
|
</p>
|
|
45
45
|
|
|
46
|
-
<p align="center"><b>Interactive ER diagrams from QuickDBD text or a dbt project, readable at 200+ tables</b></p>
|
|
46
|
+
<p align="center"><b>Interactive ER diagrams from QuickDBD text, DBML or a dbt project, readable at 200+ tables</b></p>
|
|
47
47
|
|
|
48
48
|
<p align="center">
|
|
49
49
|
<a href="https://pypi.org/project/quickdbd-cli/"><img src="https://img.shields.io/pypi/v/quickdbd-cli?label=PyPI" alt="PyPI version"></a>
|
|
@@ -89,7 +89,7 @@ qdbd is not affiliated with QuickDBD or dbdiagram.io. It reads the same text syn
|
|
|
89
89
|
- **QuickDBD-compatible**: the parser mirrors QuickDBD's own, error messages included. A file that passes `qdbd check --strict` pastes into QuickDBD unchanged.
|
|
90
90
|
- **dbt import**: `qdbd import dbt` reads `manifest.json`. Primary keys come from `unique` + `not_null` tests or constraints, relationships from `relationships` tests, and `--infer-fks` fills the gaps from column names.
|
|
91
91
|
- **Readable at 200+ tables**: automatic layout, search, dbt-style selectors (`+orders+1`, `group:marts`, `tag:fact`), shaded regions per file, collapsed views and a minimap.
|
|
92
|
-
- **Review changes in pull requests**: `qdbd diff` reports what a branch changed, the GitHub Action posts it as a PR comment, and `--diff-base` colours the changes in the diagram.
|
|
92
|
+
- **Review changes in pull requests**: `qdbd diff` reports what a branch changed, the GitHub Action posts it as a PR comment, and `--diff-base` colours the changes in the diagram. To propose a column change, tag the column `# @tag: added` or `# @tag: removed` and it's drawn green or red ([column tags](https://qdbd.dev/tags/#columns)).
|
|
93
93
|
- **VS Code extension**: highlighting, errors as you type, outline, F12 across files, and the diagram beside the editor, following your cursor, or in your browser on localhost. Export, share, compare with git, convert to and from DBML, and dbt import from the sidebar or the Command Palette, with no terminal: it installs the CLI itself. Get it from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=MarkPhamm.quickdbd) or, for Cursor, Windsurf and VSCodium, [Open VSX](https://open-vsx.org/extension/MarkPhamm/quickdbd) ([docs](https://qdbd.dev/vscode/)).
|
|
94
94
|
- **AI agents**: `qdbd mcp` gives Claude, Copilot and Cursor your schema as tools: tables and columns, join paths as SQL (composite keys included), checks for their edits and diffs against `main` ([docs](https://qdbd.dev/mcp/)).
|
|
95
95
|
- **One schema, many outputs**: an HTML page, PNG, SVG, editable draw.io, or a shareable link. Installs with pip: no Node, Graphviz or browser needed.
|
|
@@ -186,7 +186,7 @@ jobs:
|
|
|
186
186
|
permissions: { contents: read, pull-requests: write }
|
|
187
187
|
steps:
|
|
188
188
|
- uses: actions/checkout@v4
|
|
189
|
-
- uses: MarkPhamm/quickdbd-cli@v0.
|
|
189
|
+
- uses: MarkPhamm/quickdbd-cli@v0.3.4
|
|
190
190
|
with:
|
|
191
191
|
path: erd/ # schema file(s) or folder(s)
|
|
192
192
|
output: site/index.html
|
|
@@ -198,7 +198,7 @@ dbt projects, GitHub Pages, pre-commit and GitLab CI are covered in [CI and Auto
|
|
|
198
198
|
|
|
199
199
|
```text
|
|
200
200
|
dbt project ──qdbd import dbt──┐
|
|
201
|
-
├──▶ schema/*.txt (QuickDBD syntax, in git)
|
|
201
|
+
├──▶ schema/*.txt, *.dbml (QuickDBD syntax or DBML, in git)
|
|
202
202
|
hand-written / pasted ─────────┘ │
|
|
203
203
|
├──▶ qdbd check validation, QuickDBD's error messages ──▶ CI gate
|
|
204
204
|
├──▶ qdbd serve live editor, drag to arrange ──▶ schema.layout.json
|
|
@@ -240,7 +240,7 @@ qdbd build schema/ -o erd.html
|
|
|
240
240
|
|
|
241
241
|
| | qdbd | QuickDBD |
|
|
242
242
|
|---|---|---|
|
|
243
|
-
| Text syntax | QuickDBD's | QuickDBD's |
|
|
243
|
+
| Text syntax | QuickDBD's or DBML | QuickDBD's |
|
|
244
244
|
| Tables per diagram | no limit | 10 on the free plan |
|
|
245
245
|
| Where it runs | your terminal and CI; the result is a static HTML file | the QuickDBD web app |
|
|
246
246
|
| Where the schema lives | `.txt` / `.dbml` files in your git repo | the web app |
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
quickdbd_cli/__init__.py,sha256=
|
|
1
|
+
quickdbd_cli/__init__.py,sha256=oYLGMpySamd16KLiaBTfRyrAS7_oyp-TOEHmzmeumwg,22
|
|
2
2
|
quickdbd_cli/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
|
|
3
|
-
quickdbd_cli/agent.py,sha256=
|
|
4
|
-
quickdbd_cli/cli.py,sha256=
|
|
3
|
+
quickdbd_cli/agent.py,sha256=LEuReTkwWDlRLs-GfQQMpMosHDFWbin0EAXRo73j-dA,37202
|
|
4
|
+
quickdbd_cli/cli.py,sha256=h9pTnGnprYOhyuEpjdd32QyAqR-skJRTBvoipIyCZS0,29522
|
|
5
5
|
quickdbd_cli/convert.py,sha256=1AYFm4YmEav_wZp7xCAcL7TsD3qC17Y1I5g6V_fyAU0,16682
|
|
6
6
|
quickdbd_cli/dbml.py,sha256=tHJOtMm1PHx4-yqs6rncPqdDfbwPcW8F54j_kKlWkx0,59413
|
|
7
7
|
quickdbd_cli/dbt.py,sha256=AMHzERmc8WfL1tBLX4bJdPVghIHrqrfx8nMDbZMrueY,23100
|
|
8
8
|
quickdbd_cli/diff.py,sha256=UBiCE3dk_cN-xSRZFQoLiIIFh_FAVZUpLwsdtHG9eBo,9986
|
|
9
|
-
quickdbd_cli/export.py,sha256=
|
|
10
|
-
quickdbd_cli/html.py,sha256=
|
|
9
|
+
quickdbd_cli/export.py,sha256=XbSvWqFmhQHPcBtPHPit1SmhJ6qXrPyGlHYt7FpIMyo,3539
|
|
10
|
+
quickdbd_cli/html.py,sha256=pn4Jlcce5nniH7NtGoZjzrTH_dTw4qIhio4bWXGupVw,8031
|
|
11
11
|
quickdbd_cli/jsrt.py,sha256=_sRkmUbgHDTwK272jeTNUwojfjUKAkv4UeD2E2u3H1A,3464
|
|
12
|
-
quickdbd_cli/layout.py,sha256=
|
|
12
|
+
quickdbd_cli/layout.py,sha256=2wrryJ-SSJbMlOdxmtCTzC2_zkTtvRVs-OuOBS1bGEQ,10856
|
|
13
13
|
quickdbd_cli/mcp.py,sha256=ibUg1a50BBco-hZABdxTexGZi_gclFCdcBDalTvFar8,12350
|
|
14
14
|
quickdbd_cli/measure.py,sha256=Nyk47FiJokPvTStKz23mgXkO0HnXKWOts90kp1gCjng,5969
|
|
15
15
|
quickdbd_cli/model.py,sha256=lQcj89gDg2y4v_xhJX0_PWoWKGx0rg2emREVL_LA6uQ,3352
|
|
@@ -28,15 +28,15 @@ quickdbd_cli/assets/editor.js,sha256=Gare_MeAOKlc2QZpTR9nD8Ty-U6JBY3HRWAeMnDkCsU
|
|
|
28
28
|
quickdbd_cli/assets/elk-LICENSE.md,sha256=Y36B9KG2tAeVNcSZ_KBeI4z3YFwf9bdtYNfafOlnAMk,14443
|
|
29
29
|
quickdbd_cli/assets/elk.bundled.js,sha256=EiLkT5U853Rq8jgB5yNwj45vQ2uLN3pqX8dVLzSjB7M,1609707
|
|
30
30
|
quickdbd_cli/assets/font-metrics.json,sha256=sXSiElxQSK8md8IEePLwh16TTdAqKt9rV140pvMlVS8,8654
|
|
31
|
-
quickdbd_cli/assets/render.js,sha256=
|
|
32
|
-
quickdbd_cli/assets/style.css,sha256=
|
|
31
|
+
quickdbd_cli/assets/render.js,sha256=WzxX_sUgdz0O4GgiaupKl8J_LI4YCqF9PUxzRbwebwo,31891
|
|
32
|
+
quickdbd_cli/assets/style.css,sha256=ttyGKLti1sU-NMp9CBTDgaznfeU14XjWquFJr9XGsaA,18877
|
|
33
33
|
quickdbd_cli/assets/syntax-reference.md,sha256=WERcsWjyrfeHQPgxQriWKBsqFJnmWTQ2oRZjKYAHhKc,5322
|
|
34
|
-
quickdbd_cli/assets/viewer.js,sha256=
|
|
35
|
-
quickdbd_cli-0.3.
|
|
36
|
-
quickdbd_cli-0.3.
|
|
37
|
-
quickdbd_cli-0.3.
|
|
38
|
-
quickdbd_cli-0.3.
|
|
39
|
-
quickdbd_cli-0.3.
|
|
40
|
-
quickdbd_cli-0.3.
|
|
41
|
-
quickdbd_cli-0.3.
|
|
42
|
-
quickdbd_cli-0.3.
|
|
34
|
+
quickdbd_cli/assets/viewer.js,sha256=3rKXI80TNv9jF1dJNQa3Gpy0042yTcGfTz_8qg4h5WU,49676
|
|
35
|
+
quickdbd_cli-0.3.4.dist-info/METADATA,sha256=ubcoaxo_WRsI_-5TAdjNoR1G9kWcfsRTT_ibmIXdAzA,17195
|
|
36
|
+
quickdbd_cli-0.3.4.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
37
|
+
quickdbd_cli-0.3.4.dist-info/entry_points.txt,sha256=nRhiYYug-XOY95nWUmi4oanico3Zjkfqk2OHtIE6SQ0,47
|
|
38
|
+
quickdbd_cli-0.3.4.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
39
|
+
quickdbd_cli-0.3.4.dist-info/licenses/NOTICE,sha256=pyv3BfIYiGzKLU4w-GcBe7GXoXxHqZ97Mf6Zpx_3_i0,2067
|
|
40
|
+
quickdbd_cli-0.3.4.dist-info/licenses/src/quickdbd_cli/assets/Inter-LICENSE.txt,sha256=JiSB6ERSGzJvXs0FPlm5jIstp4yO4b27boF0MF5Uk1o,4380
|
|
41
|
+
quickdbd_cli-0.3.4.dist-info/licenses/src/quickdbd_cli/assets/elk-LICENSE.md,sha256=Y36B9KG2tAeVNcSZ_KBeI4z3YFwf9bdtYNfafOlnAMk,14443
|
|
42
|
+
quickdbd_cli-0.3.4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|