From 1a1b39aac5dcef8621ebb89a0cf612a1a933e617 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sat, 5 Sep 2026 21:29:17 +0300 Subject: [PATCH] Plan graph: fit scales small graphs UP to use the panel (wave-2 item 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fit zoom was capped at 1×, so any graph smaller than the panel rendered at natural size, centered, with big empty margins above and below — the common case for early-session chains in the 384px panel. The fit now scales up to fill (FIT_ZOOM_MAX 1.5 — a lone node doesn't become a billboard), floor-clamped as before. Math extracted into fitZoomFor() and unit-tested (scale-up, cap, width/height-bound, floor, unmeasured viewport). --- components/tree-panel.tsx | 40 +++++++++++++++++++++++--------- next-env.d.ts | 4 ++-- tests/render-tree-panel.test.tsx | 37 +++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 13 deletions(-) diff --git a/components/tree-panel.tsx b/components/tree-panel.tsx index 393bc1a..1de4af3 100644 --- a/components/tree-panel.tsx +++ b/components/tree-panel.tsx @@ -541,6 +541,13 @@ const GRAPH_ZOOM_BTN = * nothing — fully zoomed out IS the fit), never above 2×. */ const ZOOM_MAX = 2; const ZOOM_FLOOR = 0.15; +/** The fit view's breathing room. The fit is the default view — slim + * margins fit more graph. */ +const FIT_PAD = 16; +/** The fit may scale a SMALL graph up to fill the panel (wave-2 fix: the + * old ≤1× cap left small diagrams tiny with big empty margins), but + * never past this — a lone node shouldn't become a billboard. */ +const FIT_ZOOM_MAX = 1.5; /** The graph's view transform: zoom + translation (viewport px). */ export interface GraphView { @@ -549,6 +556,24 @@ export interface GraphView { y: number; } +/** The zoom that fits a bw×bh graph into a w×h viewport: scales DOWN + * large graphs (floor-clamped) and UP small ones (FIT_ZOOM_MAX-capped) + * so the panel is used, not padded with empty margins. */ +export function fitZoomFor( + box: { w: number; h: number }, + viewport: { w: number; h: number }, +): number { + if (!viewport.w || !viewport.h || box.w === 0) return 1; + return Math.max( + ZOOM_FLOOR, + Math.min( + (viewport.w - FIT_PAD) / box.w, + (viewport.h - FIT_PAD) / box.h, + FIT_ZOOM_MAX, + ), + ); +} + /** The fit-centered view: the whole bw×bh graph centered in the w×h * viewport — fully zoomed out is EXACTLY this, nothing to pan. */ export function fitGraphView( @@ -607,20 +632,13 @@ export function TreeGraph({ tree }: { tree: PlanTree }) { return () => ro.disconnect(); }, []); - // The zoom floor: fully zoomed out, the whole diagram fits the panel. - // Pad 16: the fit view IS the default — slim margins fit more graph. - const fitZoom = useMemo(() => { - const pad = 16; - if (!size.w || !size.h || layout.width === 0) return 1; - return Math.max( - ZOOM_FLOOR, - Math.min((size.w - pad) / layout.width, (size.h - pad) / layout.height, 1), - ); - }, [size, layout]); + // The zoom floor: fully zoomed out, the whole diagram fills the panel + // (small graphs scale UP to use the space, capped at FIT_ZOOM_MAX). + const box = useMemo(() => ({ w: layout.width, h: layout.height }), [layout]); + const fitZoom = useMemo(() => fitZoomFor(box, size), [box, size]); const [view, setView] = useState({ x: 0, y: 0, zoom: 1 }); const userAdjustedRef = useRef(false); - const box = useMemo(() => ({ w: layout.width, h: layout.height }), [layout]); // Epsilon: zoom is float; "at the floor" needs a tolerance. const atFloor = view.zoom <= fitZoom + 1e-9; diff --git a/next-env.d.ts b/next-env.d.ts index ce4e94a..a419cbe 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,7 +1,7 @@ /// /// -import "./.next/types/routes.d.ts"; -import "./.next/types/root-params.d.ts"; +import "./.next/dev/types/routes.d.ts"; +import "./.next/dev/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/tests/render-tree-panel.test.tsx b/tests/render-tree-panel.test.tsx index 9986d65..3014e35 100644 --- a/tests/render-tree-panel.test.tsx +++ b/tests/render-tree-panel.test.tsx @@ -459,3 +459,40 @@ describe("fitGraphView / zoomedGraphView", () => { assert.equal(next.zoom, 2); }); }); + +// ─── fit sizing (wave-2: small graphs must USE the panel, not float tiny) ───── + +import { fitZoomFor } from "../components/tree-panel.tsx"; + +describe("fitZoomFor", () => { + it("a graph smaller than the panel scales UP to use it — no tiny diagram with empty margins", () => { + // 5-node chain: 176 wide × 448 tall in a 384×600 panel: height-bound. + // Old bug: the ≤1× cap rendered such graphs at natural size, centered, + // tiny, with big empty areas above and below. + const z = fitZoomFor({ w: 176, h: 448 }, { w: 384, h: 600 }); + assert.equal(z, (600 - 16) / 448, "height-bound: fills the tall panel"); + assert.ok(z > 1, "scales UP past 1×"); + }); + + it("the scale-up is capped: a lone node doesn't become a billboard", () => { + const z = fitZoomFor({ w: 176, h: 48 }, { w: 384, h: 600 }); + assert.equal(z, 1.5, "capped at FIT_ZOOM_MAX"); + }); + + it("a wide graph is width-bound; a tall graph is height-bound", () => { + assert.equal(fitZoomFor({ w: 1000, h: 100 }, { w: 384, h: 600 }), (384 - 16) / 1000); + assert.equal(fitZoomFor({ w: 100, h: 1000 }, { w: 384, h: 600 }), (600 - 16) / 1000); + }); + + it("a graph larger than the panel scales DOWN, floor-clamped", () => { + assert.equal(fitZoomFor({ w: 1500, h: 1500 }, { w: 384, h: 600 }), (384 - 16) / 1500); + // absurdly large → clamped at the floor, never disappears + assert.equal(fitZoomFor({ w: 5000, h: 5000 }, { w: 384, h: 600 }), 0.15); + assert.equal(fitZoomFor({ w: 1e6, h: 1e6 }, { w: 384, h: 600 }), 0.15); + }); + + it("an unmeasured viewport (SSR / first paint) stays at 1×", () => { + assert.equal(fitZoomFor({ w: 1000, h: 800 }, { w: 0, h: 0 }), 1); + assert.equal(fitZoomFor({ w: 0, h: 800 }, { w: 384, h: 600 }), 1); + }); +});