Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/core/panzoom.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 17 additions & 9 deletions src/ui/explain-graph.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -41,16 +42,23 @@ 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');
// Smallest viewBox (most zoomed-in). Cap at an absolute pixel floor so a very
// 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 };
Expand All @@ -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;
Expand Down Expand Up @@ -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(); } };
Expand All @@ -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()),
Expand Down Expand Up @@ -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 });
}

/**
Expand Down
9 changes: 9 additions & 0 deletions tests/unit/explain-graph.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
15 changes: 14 additions & 1 deletion tests/unit/panzoom.test.js
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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', () => {
Expand Down
Loading