Plan graph: fit scales small graphs UP to use the panel (wave-2 item 1)
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).
This commit is contained in:
+29
-11
@@ -541,6 +541,13 @@ const GRAPH_ZOOM_BTN =
|
|||||||
* nothing — fully zoomed out IS the fit), never above 2×. */
|
* nothing — fully zoomed out IS the fit), never above 2×. */
|
||||||
const ZOOM_MAX = 2;
|
const ZOOM_MAX = 2;
|
||||||
const ZOOM_FLOOR = 0.15;
|
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). */
|
/** The graph's view transform: zoom + translation (viewport px). */
|
||||||
export interface GraphView {
|
export interface GraphView {
|
||||||
@@ -549,6 +556,24 @@ export interface GraphView {
|
|||||||
y: number;
|
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
|
/** The fit-centered view: the whole bw×bh graph centered in the w×h
|
||||||
* viewport — fully zoomed out is EXACTLY this, nothing to pan. */
|
* viewport — fully zoomed out is EXACTLY this, nothing to pan. */
|
||||||
export function fitGraphView(
|
export function fitGraphView(
|
||||||
@@ -607,20 +632,13 @@ export function TreeGraph({ tree }: { tree: PlanTree }) {
|
|||||||
return () => ro.disconnect();
|
return () => ro.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// The zoom floor: fully zoomed out, the whole diagram fits the panel.
|
// The zoom floor: fully zoomed out, the whole diagram fills the panel
|
||||||
// Pad 16: the fit view IS the default — slim margins fit more graph.
|
// (small graphs scale UP to use the space, capped at FIT_ZOOM_MAX).
|
||||||
const fitZoom = useMemo(() => {
|
const box = useMemo(() => ({ w: layout.width, h: layout.height }), [layout]);
|
||||||
const pad = 16;
|
const fitZoom = useMemo(() => fitZoomFor(box, size), [box, size]);
|
||||||
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]);
|
|
||||||
|
|
||||||
const [view, setView] = useState<GraphView>({ x: 0, y: 0, zoom: 1 });
|
const [view, setView] = useState<GraphView>({ x: 0, y: 0, zoom: 1 });
|
||||||
const userAdjustedRef = useRef(false);
|
const userAdjustedRef = useRef(false);
|
||||||
const box = useMemo(() => ({ w: layout.width, h: layout.height }), [layout]);
|
|
||||||
// Epsilon: zoom is float; "at the floor" needs a tolerance.
|
// Epsilon: zoom is float; "at the floor" needs a tolerance.
|
||||||
const atFloor = view.zoom <= fitZoom + 1e-9;
|
const atFloor = view.zoom <= fitZoom + 1e-9;
|
||||||
|
|
||||||
|
|||||||
Vendored
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
import "./.next/types/root-params.d.ts";
|
import "./.next/dev/types/root-params.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -459,3 +459,40 @@ describe("fitGraphView / zoomedGraphView", () => {
|
|||||||
assert.equal(next.zoom, 2);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user