diff --git a/src/core/panzoom.js b/src/core/panzoom.js index 2e84684..fddfc2b 100644 --- a/src/core/panzoom.js +++ b/src/core/panzoom.js @@ -12,6 +12,21 @@ export function fitBox(gw, gh, pad = 0.04) { return { x: -px, y: -py, w: gw + 2 * px, h: gh + 2 * py }; } +/** + * Initial viewBox that fills the container's WIDTH with the `gw × gh` graph and + * lets the height overflow (the user pans/scrolls down). The box width is the + * padded graph width; its height is set to the container's aspect ratio so + * `preserveAspectRatio … meet` maps width 1:1 with no horizontal letterboxing. + * Anchored at the top. Falls back to the graph height when the container size is + * unknown (e.g. not yet laid out). + */ +export function fitWidthBox(gw, gh, cw, ch, pad = 0.04) { + const px = gw * pad; + const w = gw + 2 * px; + const h = cw > 0 && ch > 0 ? w * (ch / cw) : gh + 2 * px; + return { x: -px, y: -px, w, h }; +} + /** * Zoom by `factor` (>1 = zoom in) keeping the svg-space point `(cx, cy)` fixed. * Width is clamped to `[minW, maxW]`; height scales by the same ratio so the diff --git a/src/ui/explain-graph.js b/src/ui/explain-graph.js index a817e91..3e44ce1 100644 --- a/src/ui/explain-graph.js +++ b/src/ui/explain-graph.js @@ -11,9 +11,10 @@ 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, zoomBox, panBox, viewBoxStr } from '../core/panzoom.js'; +import { fitBox, fitWidthBox, zoomBox, panBox, viewBoxStr } from '../core/panzoom.js'; -const ZOOM_STEP = 1.2; // per wheel notch / button press +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 /** A centred message shown in place of a graph (no nodes / nothing to draw). */ const placeholder = (msg) => h('div', { class: 'placeholder' }, h('div', null, msg)); @@ -41,6 +42,9 @@ function attachPanZoom(container, svg, dims, opts = {}) { // selects a node (schema graph) instead of grabbing the canvas. The cursor then // stays default (see .schema-graph-view CSS) rather than the grab hand. const modifierPan = !!opts.modifierPan; + // 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; svg.setAttribute('width', '100%'); svg.setAttribute('height', '100%'); svg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); @@ -48,9 +52,13 @@ function attachPanZoom(container, svg, dims, opts = {}) { // wide graph can still be zoomed to a legible node, not just to width/8. const minW = Math.min(dims.width / 8, 600); const maxW = dims.width * 3; - let vb = fitBox(dims.width, dims.height); + const computeFit = () => { + if (fitWidth) { const r = container.getBoundingClientRect(); return fitWidthBox(dims.width, dims.height, r.width, r.height); } + return fitBox(dims.width, dims.height); + }; + let vb = computeFit(); const apply = () => svg.setAttribute('viewBox', viewBoxStr(vb)); - const fit = () => { vb = fitBox(dims.width, dims.height); apply(); }; + const fit = () => { vb = computeFit(); apply(); }; const toSvg = (cx, cy) => { const r = container.getBoundingClientRect(); return { x: vb.x + ((cx - r.left) / r.width) * vb.w, y: vb.y + ((cy - r.top) / r.height) * vb.h }; @@ -67,7 +75,7 @@ function attachPanZoom(container, svg, dims, opts = {}) { container.addEventListener('wheel', (e) => { e.preventDefault(); - if (e.ctrlKey || e.metaKey) zoomAt(e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP, e.clientX, e.clientY); + if (e.ctrlKey || e.metaKey) zoomAt(e.deltaY < 0 ? WHEEL_ZOOM_STEP : 1 / WHEEL_ZOOM_STEP, e.clientX, e.clientY); else panBy(-e.deltaX, -e.deltaY); }); let drag = null; @@ -256,9 +264,9 @@ function schemaLegend() { * buttons; Esc / ✕ / backdrop close). `build()` returns `{svg,width,height,nodeCount}` * — shared by the pipeline and schema graphs. `extra` is an optional overlay node * (e.g. the schema legend); `note` an optional banner shown in the bar (e.g. a - * truncation warning). + * truncation warning); `pzOpts` extra options for attachPanZoom (e.g. fitWidth). */ -function openGraphFullscreen(app, title, build, extra, emptyMsg, note) { +function openGraphFullscreen(app, title, build, extra, emptyMsg, note, pzOpts) { const doc = (app && app.document) || document; const built = build(); const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } }; @@ -273,7 +281,7 @@ function openGraphFullscreen(app, title, build, extra, emptyMsg, note) { } else { canvas.appendChild(built.svg); if (extra) canvas.appendChild(extra); - const pz = attachPanZoom(canvas, built.svg, built); + const pz = attachPanZoom(canvas, built.svg, built, pzOpts || {}); bar.appendChild(h('div', { class: 'graph-overlay-zoom' }, h('button', { class: 'res-act', title: 'Zoom out', onclick: pz.zoomOut }, Icon.minus()), h('button', { class: 'res-act', title: 'Zoom in', onclick: pz.zoomIn }, Icon.plus()), @@ -315,7 +323,7 @@ export function openSchemaFullscreen(app, graph) { const note = graph && graph.truncated ? 'Lineage truncated — showing ' + (((graph.nodes && graph.nodes.length) || 0)) + ' objects' : null; - return openGraphFullscreen(app, 'Schema', () => buildRichSchemaSvg(graph, app && app.Dagre, schemaDetailClick(app)), schemaLegend(), schemaEmptyMessage(graph), note); + return openGraphFullscreen(app, 'Schema', () => buildRichSchemaSvg(graph, app && app.Dagre, schemaDetailClick(app)), schemaLegend(), schemaEmptyMessage(graph), note, { fitWidth: true }); } /** diff --git a/tests/unit/explain-graph.test.js b/tests/unit/explain-graph.test.js index 04e1e34..2fe2d7e 100644 --- a/tests/unit/explain-graph.test.js +++ b/tests/unit/explain-graph.test.js @@ -277,6 +277,15 @@ describe('schema lineage graph', () => { expect(note.textContent).toMatch(/truncated/i); }); + it('fitWidth: the schema fullscreen frames the graph to fill the container width (viewBox aspect = container)', () => { + const overlay = openSchemaFullscreen({ document, Dagre: dagre, actions: { openNodeDetail: vi.fn() } }, GRAPH); + const canvas = overlay.querySelector('.graph-overlay-canvas'); + canvas.getBoundingClientRect = () => ({ left: 0, top: 0, width: 400, height: 200, right: 400, bottom: 200 }); + canvas.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); // fit → fitWidthBox with a real container size + const vb = canvas.querySelector('svg.explain-graph').getAttribute('viewBox').split(' ').map(Number); + expect(vb[2] / vb[3]).toBeCloseTo(400 / 200, 4); // width:height aspect matches the container → no horizontal letterbox + }); + it('clicking an external (ext:) leaf in the fullscreen graph is a no-op (no detail pane)', () => { const actions = { openNodeDetail: vi.fn() }; const g = { diff --git a/tests/unit/panzoom.test.js b/tests/unit/panzoom.test.js index ba8a992..d3e783f 100644 --- a/tests/unit/panzoom.test.js +++ b/tests/unit/panzoom.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { fitBox, zoomBox, panBox, viewBoxStr } from '../../src/core/panzoom.js'; +import { fitBox, fitWidthBox, zoomBox, panBox, viewBoxStr } from '../../src/core/panzoom.js'; describe('fitBox', () => { it('frames the graph with fractional padding on every side', () => { @@ -12,6 +12,19 @@ describe('fitBox', () => { }); }); +describe('fitWidthBox', () => { + it('fills the padded width and matches the container aspect (so width has no letterbox)', () => { + const vb = fitWidthBox(1000, 4000, 800, 400); // tall graph in a wide container + expect(vb.w).toBeCloseTo(1080); // 1000 + 2*(1000*0.04) + expect(vb.w / vb.h).toBeCloseTo(800 / 400); // aspect == container → width fills + expect(vb.x).toBeCloseTo(-40); + expect(vb.y).toBeCloseTo(-40); // anchored at the top + }); + it('falls back to the graph height when the container size is unknown', () => { + expect(fitWidthBox(1000, 500, 0, 0).h).toBeCloseTo(580); // gh + 2*px + }); +}); + describe('zoomBox', () => { const vb = { x: 0, y: 0, w: 100, h: 100 }; it('zooms in around a point, keeping it fixed', () => {