diff --git a/README.md b/README.md index c45d400..8131fb9 100644 --- a/README.md +++ b/README.md @@ -109,13 +109,24 @@ engine-specific lineage: materialized views (`feeds` from sources, `writes` to t target), regular views (`reads` their sources), dictionaries (`dict` from a source table), and `Distributed`/`Buffer`/`Merge` engines pointing at their backing tables. Nodes are coloured by kind (table / view / materialized view / dictionary / -distributed / external) with a legend; edges are coloured and labelled by -relationship. Drag a **database** → the whole-DB lineage (it shows only the tables -that participate in a relationship; a database whose tables aren't linked by any -view/MV/dictionary/Distributed engine shows a "no object relationships" message -rather than a wall of disconnected boxes); drag a **table** → its 1-hop +distributed / buffer / merge / external) with a legend; edges are coloured and +labelled by relationship. Drag a **database** → the whole-DB lineage (it shows only +the tables that participate in a relationship; a database whose tables aren't linked +by any view/MV/dictionary/Distributed engine shows a "no object relationships" +message rather than a wall of disconnected boxes); drag a **table** → its 1-hop neighbourhood. **Click any node** to run `SHOW CREATE` for it into the editor; -**⌘/Ctrl-drag** to pan; **Expand** for a fullscreen pan/zoom view. +**⌘/Ctrl-drag** to pan; **Expand** for the full view. + +The full view opens in a **real browser tab** kept live by the opener (it still +holds the OAuth token, so click-to-detail fetches on demand) — keep the tab open +beside the editor. If a pop-up is blocked it falls back to an in-app overlay. Three +cursor shapes keep the actions distinct: a **pointer** over a card (**click** opens a +detail pane — full columns / keys / partitions / DDL), the **move ✛** cursor when +**⌘/Ctrl** is held over a card (**⌘/Ctrl-drag to move it**, its edges re-route as +straight lines), and the **grab hand** over empty canvas (plain **drag pans**). Wheel +pans, ⌘/Ctrl+wheel zooms, double-click fits, **Esc** closes the detail pane. +Node moves are **undo/redo-able** (⌘/Ctrl+Z, ⌘/Ctrl+Shift+Z or ⌘/Ctrl+Y), and +manually-moved positions persist for as long as that result is open. Discovery is **structured-first, parse-fallback**, because the helpful `system.tables` columns are build-dependent: it prefers `dependencies_table` / diff --git a/build/template.html b/build/template.html index 1e3b93b..324255d 100644 --- a/build/template.html +++ b/build/template.html @@ -1,5 +1,5 @@ - +
diff --git a/src/core/graph-layout.js b/src/core/graph-layout.js new file mode 100644 index 0000000..577c2f7 --- /dev/null +++ b/src/core/graph-layout.js @@ -0,0 +1,87 @@ +// Pure geometry + state helpers for the interactive schema graph: convert a +// pixel drag into svg-user-unit deltas, re-route an edge as a straight line +// clipped to its two node boxes, find a node's incident edges, and apply/record +// manually-moved node positions. No DOM, no globals — the DOM wiring (mousedown +// tracking, attribute writes) lives in src/ui/explain-graph.js. + +/** Centre point of a node box (top-left x/y, w/h). */ +export function nodeCenter(n) { + return { x: n.x + n.w / 2, y: n.y + n.h / 2 }; +} + +// Where the ray from `node`'s centre toward `toward` crosses `node`'s rectangle +// border — so an edge endpoint lands on the box edge, not buried at the centre. +function clipToBox(node, toward) { + const cx = node.x + node.w / 2; + const cy = node.y + node.h / 2; + const dx = toward.x - cx; + const dy = toward.y - cy; + if (dx === 0 && dy === 0) return { x: cx, y: cy }; // coincident centres + let s = Infinity; + if (dx !== 0) s = Math.min(s, (node.w / 2) / Math.abs(dx)); + if (dy !== 0) s = Math.min(s, (node.h / 2) / Math.abs(dy)); + return { x: cx + dx * s, y: cy + dy * s }; +} + +/** + * Two-point polyline for an edge `from → to`, each endpoint clipped to its + * node's rectangle border. Replaces dagre's routed bend points when a node is + * moved (decision: straighten only the incident edges). + */ +export function straightEdgePoints(from, to) { + return [clipToBox(from, nodeCenter(to)), clipToBox(to, nodeCenter(from))]; +} + +/** Indices of the edges incident to `nodeId` (touching it as source or target). */ +export function incidentEdges(edges, nodeId) { + const out = []; + edges.forEach((e, i) => { if (e.from === nodeId || e.to === nodeId) out.push(i); }); + return out; +} + +/** + * Convert a pixel drag delta to svg user units for the current viewBox `vb` + * ({x,y,w,h}) shown in a container of pixel size `rect`. Mirrors the pan algebra + * in attachPanZoom (svgΔ = pxΔ · vb.w/rect.width). + */ +export function dragDeltaToSvg(dxPx, dyPx, vb, rect) { + return { dx: dxPx * (vb.w / (rect.width || 1)), dy: dyPx * (vb.h / (rect.height || 1)) }; +} + +/** + * Overlay remembered `{id: {x,y}}` positions onto laid-out nodes in place (a + * node with no saved position keeps its dagre coordinates). Returns the array. + */ +export function applyPositions(nodes, positions) { + if (!positions) return nodes; + for (const n of nodes) { + const p = positions[n.id]; + if (p) { n.x = p.x; n.y = p.y; } + } + return nodes; +} + +/** Remember a node's moved position (mutates + returns the per-result map). */ +export function recordPosition(positions, id, x, y) { + positions[id] = { x, y }; + return positions; +} + +/** + * A linear undo/redo history of node-move operations. Each op is + * `{ id, from:{x,y}, to:{x,y} }`. record() pushes an op and clears the redo + * branch (standard linear-history semantics). undo()/redo() return the op to + * apply — the caller moves the node to op.from on undo, op.to on redo — or null + * when the respective stack is empty. No DOM; the UI does the repositioning. + */ +export function createMoveHistory() { + const past = []; + const future = []; + return { + record(op) { past.push(op); future.length = 0; }, + undo() { if (!past.length) return null; const op = past.pop(); future.push(op); return op; }, + redo() { if (!future.length) return null; const op = future.pop(); past.push(op); return op; }, + canUndo() { return past.length > 0; }, + canRedo() { return future.length > 0; }, + }; +} diff --git a/src/core/schema-cards.js b/src/core/schema-cards.js index 9b6751c..06752c3 100644 --- a/src/core/schema-cards.js +++ b/src/core/schema-cards.js @@ -20,6 +20,15 @@ export const CARD = { BADGE_W: 26, // approx width of one role badge (PK/SK/PARTITION/SAMPLING) MIN_W: 130, MAX_COLS: 16, + MAX_TYPE: 28, // truncate the displayed column type — a big Enum/Tuple/Map would + // otherwise blow the card (and the whole graph) absurdly wide. +}; + +// Clamp an over-long column type for the card (the full type stays in the detail +// pane). Keeps a giant inline Enum8('a'=1, …) from stretching the layout. +const clampType = (t) => { + const s = String(t == null ? '' : t); + return s.length > CARD.MAX_TYPE ? s.slice(0, CARD.MAX_TYPE - 1) + '…' : s; }; // A ClickHouse UInt8 flag is 1/0, but JSON vs JSONStrings formats deliver it as @@ -51,7 +60,7 @@ export function buildCardModel(node, tableRow, columns, skipIndices) { const summary = engine + ' · ' + formatRows(tr.total_rows) + ' rows · ' + formatBytes(tr.total_bytes); const allCols = columns || []; const cols = allCols.slice(0, CARD.MAX_COLS).map((c) => ({ - name: c.name, type: c.type, roles: columnRoles(c), + name: c.name, type: clampType(c.type), roles: columnRoles(c), })); const overflow = Math.max(0, allCols.length - CARD.MAX_COLS); const idx = skipIndices || []; diff --git a/src/state.js b/src/state.js index 018d3b7..0aca88b 100644 --- a/src/state.js +++ b/src/state.js @@ -43,7 +43,7 @@ export function createState(read = { loadJSON, loadStr }) { const num = (key, dflt, lo, hi) => clamp(parseFloat(read.loadStr(key, String(dflt))), lo, hi); return { nextTabId: 2, - theme: read.loadStr(KEYS.theme, 'dark'), + theme: read.loadStr(KEYS.theme, 'light'), density: 'comfortable', sidebarPx: clamp(parseInt(read.loadStr(KEYS.sidebarPx, '248'), 10), 180, 420), editorPct: num(KEYS.editorPct, 45, 15, 85), diff --git a/src/styles.css b/src/styles.css index 3f33b3e..99fb617 100644 --- a/src/styles.css +++ b/src/styles.css @@ -705,6 +705,8 @@ body { .sg-swatch--mv { background: #8b5cf6; border-color: #8b5cf6; } .sg-swatch--dictionary { background: #3b82f6; border-color: #3b82f6; } .sg-swatch--distributed { background: #f97316; border-color: #f97316; } +.sg-swatch--buffer { background: #eab308; border-color: #eab308; } +.sg-swatch--merge { background: #64748b; border-color: #64748b; } .sg-swatch--external { background: transparent; border-style: dashed; } .res-graph-title { font-size: 11.5px; color: var(--fg-mute); font-weight: 500; padding: 0 4px; } @@ -725,8 +727,14 @@ body { padding: 9px 12px; border-bottom: 1px solid var(--border); flex-shrink: 0; } -.graph-overlay-title { font-size: 12.5px; font-weight: 600; color: var(--fg); } -.graph-overlay-zoom { display: flex; gap: 6px; margin-left: auto; } +.graph-overlay-title { font-size: 12.5px; font-weight: 600; color: var(--fg); flex-shrink: 0; } +.graph-overlay-zoom { display: flex; gap: 6px; } +/* Right-aligned action cluster (theme switcher + zoom + optional close). */ +.graph-overlay-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; flex-shrink: 0; } +/* The colour key sits in the headline (not over the canvas) for the full view. */ +.graph-overlay-bar .schema-graph-legend { + position: static; max-width: none; flex-wrap: wrap; gap: 3px 12px; min-width: 0; +} .graph-overlay-close { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border: none; border-radius: 6px; @@ -741,6 +749,27 @@ body { .graph-overlay-canvas > svg.explain-graph { width: 100%; height: 100%; } .graph-overlay-note { font-size: 11px; color: #eab308; } +/* Full schema view cursor model — three distinct shapes so each action is + unambiguous: + • over a node (no modifier): pointer → click opens the detail pane + • over a node + ⌘/Ctrl: move (✛) → drag relocates the node + • empty canvas + ⌘/Ctrl: grab/grabbing → drag pans the viewport + default elsewhere is the arrow. (Nodes carry an inline cursor:pointer; the + ⌘/Ctrl rule below is a class selector, so it overrides that presentation attr.) */ +.graph-overlay-canvas.schema-canvas { cursor: default; position: relative; } +.graph-overlay-canvas.schema-canvas.modkey { cursor: grab; } +.graph-overlay-canvas.schema-canvas.grabbing { cursor: grabbing; } +/* Whole card reads as clickable (finger) at rest… */ +.graph-overlay-canvas.schema-canvas .eg-card rect, +.graph-overlay-canvas.schema-canvas .eg-card text { cursor: pointer; } +/* …and as movable (✛) while ⌘/Ctrl is held. */ +.graph-overlay-canvas.schema-canvas.modkey .eg-card rect, +.graph-overlay-canvas.schema-canvas.modkey .eg-card text { cursor: move; } + +/* The schema graph's own browser tab: the panel fills the viewport chromelessly. */ +body.schema-tab { margin: 0; height: 100vh; overflow: hidden; background: var(--bg-editor); } +body.schema-tab .graph-overlay-panel { height: 100vh; border: none; border-radius: 0; } + /* ------------ schema node detail pane (fullscreen graph) ------------ */ .schema-detail { flex: 0 0 280px; min-height: 90px; position: relative; @@ -1234,6 +1263,7 @@ body { font-size: 11px; font-family: inherit; cursor: pointer; } .res-act:hover { background: var(--bg-hover); color: var(--fg); border-color: var(--border); } +.res-act:disabled { opacity: .35; cursor: default; pointer-events: none; } .stat .ic { color: var(--fg-faint); display: flex; } .stat .v { color: var(--fg); } diff --git a/src/ui/app.js b/src/ui/app.js index c015862..39ad084 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -28,7 +28,7 @@ import { mountEditor, insertAtCursor, replaceEditor, SCHEMA_GRAPH_MIME } from '. import { renderTabs, selectTab, newTab, closeTab, loadIntoNewTab } from './tabs.js'; import { renderSchema } from './schema.js'; import { renderResults } from './results.js'; -import { openSchemaFullscreen } from './explain-graph.js'; +import { openSchemaView } from './explain-graph.js'; import { openDetailPane } from './schema-detail.js'; import { renderSavedHistory } from './saved-history.js'; import { libraryControls, renderLibraryTitle } from './file-menu.js'; @@ -59,6 +59,11 @@ export function createApp(env = {}) { // Pipeline-graph layout seam: dagre (injected like Chart). The DOT parser and // SVG drawer are ours; dagre only computes node positions + edge bend points. Dagre: env.Dagre || win.dagre, + // The schema graph opens in a real browser tab driven by this window. Both are + // injected seams: openWindow so tests can stub window.open, stylesText so the + // child tab can inline the page's CSS (about:blank ships none of it). + openWindow: env.openWindow || ((...a) => win.open(...a)), + stylesText: env.stylesText || (doc.querySelector('style') ? doc.querySelector('style').textContent : ''), }; // Two ways to be signed in: OAuth (a JWT bearer, the default) or 'basic' — @@ -551,38 +556,50 @@ export function createApp(env = {}) { // result) keeps the inline path's shape frozen and the card data off the hot path. async function expandSchemaGraph(focus) { if (!focus || !focus.db) return; - await ensureConfig(); - if (!(await getToken())) { chCtx.onSignedOut(); return; } - let lineage; + // Pin the result whose Expand was clicked NOW: a tab switch during the async + // fetch must not redirect the saved-positions map to a different tab's result. + const clickedTab = app.activeTab(); + const sg = (clickedTab && clickedTab.result && clickedTab.result.schemaGraph) || null; + // Open the view synchronously so a real tab survives the click gesture (a + // pop-up opened after an await is blocked); fill it once the lineage loads. + const view = openSchemaView(app); + // Everything after the synchronous open is wrapped: a token-refresh rejection, + // a lineage/cards fetch failure, or a graph-build throw must surface in the view + // (fail) instead of leaving the just-opened tab/overlay stranded on "Loading…". try { + await ensureConfig(); + if (!(await getToken())) { chCtx.onSignedOut(); view.fail('Sign in to view the schema graph.'); return; } // Walk lineage transitively across DB boundaries (soft-capped) — pulls in // objects an other database references, instead of dead-ending at the edge. - lineage = await ch.loadLineageTransitive(chCtx, focus); + const lineage = await ch.loadLineageTransitive(chCtx, focus); + const g = buildSchemaGraph(lineage.rows, focus); + const ex = expandLineage(g, focus.db); // closure around focus.db, tags external nodes + // Card metadata for every database the expansion reached (external nodes too). + const dbs = [...new Set(ex.nodes.map((n) => n.db).filter(Boolean))]; + const cards = await ch.loadSchemaCards(chCtx, dbs); + const cardGraph = buildCardGraph({ nodes: ex.nodes, edges: ex.edges }, + { tables: lineage.rows.tables, columnsByKey: cards.columnsByKey, skipByKey: cards.skipByKey }); + // Persist manually-moved node positions per result: the map hangs off the live + // schemaGraph result (captured above) so re-opening keeps the layout. + const positions = (sg && sg.savedPositions) || {}; + if (sg) sg.savedPositions = positions; + view.render({ + nodes: cardGraph.nodes, edges: cardGraph.edges, focus, + tableCount: (lineage.rows.tables || []).length, + truncated: lineage.truncated || ex.truncated, + savedPositions: positions, + }); } catch { - // The inline graph is still on screen; tell the user the expand didn't load. - flashToast('Could not load the schema graph', { document: doc }); - return; + view.fail('Could not load the schema graph'); } - const g = buildSchemaGraph(lineage.rows, focus); - const ex = expandLineage(g, focus.db); // closure around focus.db, tags external nodes - // Card metadata for every database the expansion reached (external nodes too). - const dbs = [...new Set(ex.nodes.map((n) => n.db).filter(Boolean))]; - const cards = await ch.loadSchemaCards(chCtx, dbs); - const cardGraph = buildCardGraph({ nodes: ex.nodes, edges: ex.edges }, - { tables: lineage.rows.tables, columnsByKey: cards.columnsByKey, skipByKey: cards.skipByKey }); - openSchemaFullscreen(app, { - nodes: cardGraph.nodes, edges: cardGraph.edges, focus, - tableCount: (lineage.rows.tables || []).length, - truncated: lineage.truncated || ex.truncated, - }); } // Open the detail pane for a clicked fullscreen node: lazily load the table's full // columns / partitions / DDL (best-effort) and mount the pane in the overlay. - async function openNodeDetail(node) { + async function openNodeDetail(node, targetDoc) { if (!node || !node.db || !node.name) return; const detail = await ch.loadTableDetail(chCtx, node.db, node.name); - openDetailPane(app, node, detail); + openDetailPane(app, node, detail, targetDoc); } // Explain the current query without editing it: run it through the EXPLAIN @@ -777,6 +794,9 @@ export function createApp(env = {}) { doc.documentElement.setAttribute('data-theme', app.state.theme); if (app.dom.themeBtn) app.dom.themeBtn.replaceChildren(app.state.theme === 'dark' ? Icon.sun() : Icon.moon()); } + // Exposed so the schema-view overlay can drive the same toggle (keeps state + + // saved pref + header icon in sync rather than flipping data-theme behind them). + app.toggleTheme = toggleTheme; // --- actions registry -------------------------------------------------- app.actions = { diff --git a/src/ui/dom.js b/src/ui/dom.js index a02e0e3..c49c28d 100644 --- a/src/ui/dom.js +++ b/src/ui/dom.js @@ -5,6 +5,21 @@ const SVG_NS = 'http://www.w3.org/2000/svg'; +// Ambient target document. Normally null → the global `document` (the served +// page). `withDocument(doc, fn)` redirects element creation at `doc` for the +// duration of `fn`, so the same builders can populate a second window (the +// schema graph's new browser tab) without a document parameter on every call. +let DOC = null; +const D = () => DOC || document; +// Realm-agnostic "is this a DOM node?" — `instanceof Node` is false for a node +// from another window (e.g. the schema tab), so we duck-type on nodeType. +const isNode = (c) => c != null && typeof c === 'object' && typeof c.nodeType === 'number'; +export function withDocument(doc, fn) { + const prev = DOC; + DOC = doc; + try { return fn(); } finally { DOC = prev; } +} + // Shared prop/children application — the only difference between h and s is // which document factory creates the element. function apply(el, props, children) { @@ -22,19 +37,23 @@ function apply(el, props, children) { } for (const c of children.flat(Infinity)) { if (c == null || c === false) continue; - el.appendChild(c instanceof Node ? c : document.createTextNode(String(c))); + // Duck-type on nodeType rather than `instanceof Node`: when building into + // another document (the schema tab via withDocument), child elements belong + // to that window's realm and fail the opener's `instanceof Node`, so they'd + // be stringified to "[object HTMLDivElement]". nodeType is realm-agnostic. + el.appendChild(isNode(c) ? c : D().createTextNode(String(c))); } return el; } export function h(tag, props, ...children) { if (typeof tag === 'function') return tag(props || {}, children); - return apply(document.createElement(tag), props, children); + return apply(D().createElement(tag), props, children); } // Build an element in the SVG namespace (same prop rules as h()). export function s(tag, props, ...children) { - return apply(document.createElementNS(SVG_NS, tag), props, children); + return apply(D().createElementNS(SVG_NS, tag), props, children); } // The page's CSS `zoom` factor as seen by `el`: getBoundingClientRect() is in diff --git a/src/ui/explain-graph.js b/src/ui/explain-graph.js index 3e44ce1..66f2507 100644 --- a/src/ui/explain-graph.js +++ b/src/ui/explain-graph.js @@ -5,13 +5,15 @@ // math (parse + layout) is pure in src/core/dot.js + dot-layout.js (dagre seam) // and the viewBox algebra in src/core/panzoom.js; this module only does SVG + DOM. -import { h, s } from './dom.js'; +import { h, s, withDocument } from './dom.js'; import { Icon } from './icons.js'; import { parseDot } from '../core/dot.js'; import { dagreLayout } from '../core/dot-layout.js'; import { buildCardModel, cardSize, CARD } from '../core/schema-cards.js'; import { qualifyIdent } from '../core/format.js'; import { fitBox, fitWidthBox, zoomBox, panBox, viewBoxStr } from '../core/panzoom.js'; +import { straightEdgePoints, incidentEdges, dragDeltaToSvg, applyPositions, recordPosition, createMoveHistory } from '../core/graph-layout.js'; +import { flashToast } from './toast.js'; const ZOOM_STEP = 1.2; // per zoom-button press const WHEEL_ZOOM_STEP = 1.04; // per ⌘/Ctrl+wheel notch — gentle, so trackpad/wheel zoom isn't jumpy @@ -45,6 +47,11 @@ function attachPanZoom(container, svg, dims, opts = {}) { // fitWidth: frame the graph to fill the container's WIDTH and let the height // overflow (pan/scroll down) — used by the schema full view, which can be tall. const fitWidth = !!opts.fitWidth; + // refitOnResize: re-fit when the window resizes. Set for the standalone schema + // tab + the fullscreen overlays (whose container tracks the viewport); left off + // for the small inline result pane, which re-renders often and shouldn't reset + // a user's pan/zoom on every layout change. + const refitOnResize = !!opts.refitOnResize; svg.setAttribute('width', '100%'); svg.setAttribute('height', '100%'); svg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); @@ -67,8 +74,8 @@ function attachPanZoom(container, svg, dims, opts = {}) { // Pan by pixel deltas (drag grabs the content; wheel scrolls the viewport — the // caller passes the appropriate sign). const panBy = (dxPx, dyPx) => { - const r = container.getBoundingClientRect(); - vb = panBox(vb, dxPx * (vb.w / r.width), dyPx * (vb.h / r.height)); + const { dx, dy } = dragDeltaToSvg(dxPx, dyPx, vb, container.getBoundingClientRect()); + vb = panBox(vb, dx, dy); apply(); }; const centre = () => { const r = container.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; }; @@ -93,6 +100,16 @@ function attachPanZoom(container, svg, dims, opts = {}) { container.addEventListener('mouseup', end); container.addEventListener('mouseleave', end); container.addEventListener('dblclick', fit); + // Refit on window resize so the viewBox aspect keeps matching the container — + // otherwise preserveAspectRatio letterboxes and drag/pan stop tracking the + // pointer (notably when the standalone schema tab is resized). The listener + // removes itself once the container leaves the DOM (overlay/tab closed); a + // detached document (defaultView null) never gets one in the first place. + const win = container.ownerDocument.defaultView; + if (win && refitOnResize) { + const onResize = () => { if (container.isConnected) fit(); else win.removeEventListener('resize', onResize); }; + win.addEventListener('resize', onResize); + } apply(); return { fit, zoomIn: () => { const c = centre(); zoomAt(ZOOM_STEP, c.x, c.y); }, zoomOut: () => { const c = centre(); zoomAt(1 / ZOOM_STEP, c.x, c.y); } }; @@ -117,15 +134,23 @@ function graphSvgWithEdges(g, edgeClass, edgeLabel) { id: 'eg-arrow', viewBox: '0 0 10 10', refX: '9', refY: '5', markerWidth: '7', markerHeight: '7', orient: 'auto-start-reverse', }, s('path', { class: 'eg-arrowhead', d: 'M0 0L10 5L0 10z' })))); - for (const e of g.edges) { + g.edges.forEach((e, i) => { const d = 'M' + e.points.map((p) => p.x + ' ' + p.y).join(' L'); - svg.appendChild(s('path', { class: edgeClass(e), d, 'marker-end': 'url(#eg-arrow)' })); + // data-eidx/from/to let the schema-graph move handler find and re-route the + // edges incident to a dragged node (harmless attrs for the pipeline graph). + svg.appendChild(s('path', { class: edgeClass(e), d, 'marker-end': 'url(#eg-arrow)', 'data-eidx': i, 'data-from': e.from, 'data-to': e.to })); const lbl = edgeLabel && edgeLabel(e); if (lbl) { - const mid = e.points[Math.floor(e.points.length / 2)]; - svg.appendChild(s('text', { class: 'eg-edge-label', x: mid.x, y: mid.y - 3, 'text-anchor': 'middle' }, lbl)); + // A straightened (2-point) edge has no real mid-vertex, so points[len/2] + // would land on the target endpoint — use the segment midpoint instead. + // data-lbl-eidx lets the move handler reposition the label with its edge. + const pts = e.points; + const mid = pts.length === 2 + ? { x: (pts[0].x + pts[1].x) / 2, y: (pts[0].y + pts[1].y) / 2 } + : pts[Math.floor(pts.length / 2)]; + svg.appendChild(s('text', { class: 'eg-edge-label', x: mid.x, y: mid.y - 3, 'text-anchor': 'middle', 'data-lbl-eidx': i }, lbl)); } - } + }); return svg; } @@ -141,7 +166,7 @@ function renderGraphSvg(g, opts = {}) { }, n.label); if (opts.onNode) { rect.setAttribute('cursor', 'pointer'); text.setAttribute('cursor', 'pointer'); - const fire = (e) => { e.stopPropagation(); opts.onNode(n); }; + const fire = (e) => { e.stopPropagation(); opts.onNode(n, e); }; rect.addEventListener('click', fire); text.addEventListener('click', fire); } svg.appendChild(rect); svg.appendChild(text); @@ -170,7 +195,7 @@ export function buildSchemaSvg(graph, dagre, onNode) { // cardSize() used to size the node, so no DOM measurement is needed. `model` is // always supplied by renderRichGraphSvg (a header-only model for a card-less node). function renderCardNode(n, model, nodeClass, onNode) { - const g = s('g', { class: 'eg-card' }); + const g = s('g', { class: 'eg-card', 'data-node-id': n.id }); const rect = s('rect', { class: nodeClass(n), x: n.x, y: n.y, width: n.w, height: n.h, rx: '5' }); g.appendChild(rect); const left = n.x + CARD.PAD_X; @@ -192,7 +217,7 @@ function renderCardNode(n, model, nodeClass, onNode) { if (model.skipLine) g.appendChild(s('text', { class: 'eg-skipidx', x: left, y: rowY() }, model.skipLine)); if (onNode) { rect.setAttribute('cursor', 'pointer'); - g.addEventListener('click', (e) => { e.stopPropagation(); onNode(n); }); + g.addEventListener('click', (e) => { e.stopPropagation(); onNode(n, e); }); } return g; } @@ -225,13 +250,26 @@ export function buildRichSchemaSvg(graph, dagre, onNode) { // `external` rides through dagreLayout (like kind/db/name), so the node class can // read it off the laid node — no side-channel needed. const laid = dagreLayout(dagre, { nodes: sized, edges: g.edges || [] }); - return renderRichGraphSvg(laid, { + // Overlay any manually-moved positions remembered for this result, then + // straighten the edges touching a moved node so they still connect on first draw. + const positions = g.savedPositions; + if (positions) { + applyPositions(laid.nodes, positions); + const byId = new Map(laid.nodes.map((n) => [n.id, n])); + for (const e of laid.edges) { + if (positions[e.from] || positions[e.to]) e.points = straightEdgePoints(byId.get(e.from), byId.get(e.to)); + } + } + // Remember each card's drawn origin so a live drag can translate its