Plan-graph refinement round 2 (owner): the popup fits, centers, and never clips
Root cause of the popup's fit/center failure: PlanDiagram rendered w-full inside the popup's shrink-to-fit absolute wrapper — the svg resolved to the popup's width regardless of its viewBox extent, so the transform math (1 svg unit = 1 px at zoom 1) was off and the 'fit floor' neither fitted nor centered. - naturalSize: the popup's diagram renders at the viewBox width in px, so fit/pan/zoom math holds. - The popup's fit box is now the PADDED ink box (GRAPH_PAD all around): centered strokes and the glow halo never clip at the frame edge. - fitZoomFor lost its 0.15 artificial floor — for wide graphs it sat ABOVE the true fit and broke 'fully zoomed out = the whole graph visible, whichever axis binds' (owner's rule). The fit itself is the floor; a 0.02 epsilon only guards degenerate viewports.
This commit is contained in:
+31
-11
@@ -12,8 +12,9 @@ import { derivedNodeStatus, derivePlanStatuses } from "@/src/core";
|
|||||||
* the right sidebar is retired.
|
* the right sidebar is retired.
|
||||||
*
|
*
|
||||||
* - kind "full": the whole DAG, BORDERLESS — no wrapping chrome, the SVG
|
* - kind "full": the whole DAG, BORDERLESS — no wrapping chrome, the SVG
|
||||||
* scales to the full column width (viewBox = layout extent, so the
|
* scales to the full column width (viewBox = layout extent + GRAPH_PAD,
|
||||||
* leftmost/rightmost nodes hug the edges), natural height, no cap.
|
* so strokes/glow never clip), natural height, capped at the chat prose
|
||||||
|
* font size (TEXT_CAP_SCALE — fills the column, never oversizes text).
|
||||||
* - Clicking opens the full plan in a full-screen popup overlay —
|
* - Clicking opens the full plan in a full-screen popup overlay —
|
||||||
* the LIVE plan: the latest tree from the transcript with statuses
|
* the LIVE plan: the latest tree from the transcript with statuses
|
||||||
* derived at click time (contracts/plan.ts derivePlanStatuses). Blocks
|
* derived at click time (contracts/plan.ts derivePlanStatuses). Blocks
|
||||||
@@ -164,6 +165,7 @@ export function PlanDiagram({
|
|||||||
viewBoxOverride,
|
viewBoxOverride,
|
||||||
pad: padUnits,
|
pad: padUnits,
|
||||||
textCapScale,
|
textCapScale,
|
||||||
|
naturalSize,
|
||||||
}: {
|
}: {
|
||||||
nodes: readonly LayoutNode[];
|
nodes: readonly LayoutNode[];
|
||||||
edges: ReadonlyArray<{ from: string; to: string }>;
|
edges: ReadonlyArray<{ from: string; to: string }>;
|
||||||
@@ -178,6 +180,10 @@ export function PlanDiagram({
|
|||||||
/** Caps the rendered width at extent × scale — the chat-font-size zoom
|
/** Caps the rendered width at extent × scale — the chat-font-size zoom
|
||||||
* ceiling for the in-chat block (fills the column, never past prose). */
|
* ceiling for the in-chat block (fills the column, never past prose). */
|
||||||
textCapScale?: number;
|
textCapScale?: number;
|
||||||
|
/** Renders at the viewBox width in px (1 svg unit = 1 px at zoom 1) —
|
||||||
|
* the popup's transform math depends on it; w-full inside the popup's
|
||||||
|
* shrink-to-fit wrapper resolved to the popup width and broke fit. */
|
||||||
|
naturalSize?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const layout = useMemo(() => blockLayout(nodes, edges), [nodes, edges]);
|
const layout = useMemo(() => blockLayout(nodes, edges), [nodes, edges]);
|
||||||
// Pad only the full-extent render — the plan window's override viewBox is
|
// Pad only the full-extent render — the plan window's override viewBox is
|
||||||
@@ -190,8 +196,14 @@ export function PlanDiagram({
|
|||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
viewBox={viewBoxOverride ?? `${-pad} ${-pad} ${vbW} ${vbH}`}
|
viewBox={viewBoxOverride ?? `${-pad} ${-pad} ${vbW} ${vbH}`}
|
||||||
className={`block h-auto w-full${textCapScale ? " mx-auto" : ""}`}
|
className={`block h-auto${naturalSize ? "" : " w-full"}${textCapScale ? " mx-auto" : ""}`}
|
||||||
style={textCapScale ? { maxWidth: `${Math.round(vbW * textCapScale * 100) / 100}px` } : undefined}
|
style={
|
||||||
|
textCapScale
|
||||||
|
? { maxWidth: `${Math.round(vbW * textCapScale * 100) / 100}px` }
|
||||||
|
: naturalSize
|
||||||
|
? { width: `${vbW}px` }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Plan diagram"
|
aria-label="Plan diagram"
|
||||||
>
|
>
|
||||||
@@ -318,9 +330,11 @@ export function latestTree(messages: readonly PopupMessage[]): PlanTree | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Zoom bounds: never below fit (fully zoomed out IS the fit), never above 2×. */
|
/** Zoom bounds: never below fit (fully zoomed out IS the fit — the whole
|
||||||
|
* graph visible, whichever axis binds — owner, plan-graph round 2), never
|
||||||
|
* above 2×. The old 0.15 floor could sit ABOVE a true fit for wide graphs
|
||||||
|
* and break that rule; the fit itself is the floor now. */
|
||||||
const ZOOM_MAX = 2;
|
const ZOOM_MAX = 2;
|
||||||
const ZOOM_FLOOR = 0.15;
|
|
||||||
const FIT_PAD = 16;
|
const FIT_PAD = 16;
|
||||||
const FIT_ZOOM_MAX = 1.5;
|
const FIT_ZOOM_MAX = 1.5;
|
||||||
|
|
||||||
@@ -332,10 +346,9 @@ interface GraphView {
|
|||||||
|
|
||||||
function fitZoomFor(box: { w: number; h: number }, viewport: { w: number; h: number }): number {
|
function fitZoomFor(box: { w: number; h: number }, viewport: { w: number; h: number }): number {
|
||||||
if (!viewport.w || !viewport.h || box.w === 0) return 1;
|
if (!viewport.w || !viewport.h || box.w === 0) return 1;
|
||||||
return Math.max(
|
// No artificial floor: a wide graph must fit even below 0.15× — the
|
||||||
ZOOM_FLOOR,
|
// epsilon only guards degenerate viewports.
|
||||||
Math.min((viewport.w - FIT_PAD) / box.w, (viewport.h - FIT_PAD) / box.h, FIT_ZOOM_MAX),
|
return Math.max(0.02, Math.min((viewport.w - FIT_PAD) / box.w, (viewport.h - FIT_PAD) / box.h, FIT_ZOOM_MAX));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fitGraphView(box: { w: number; h: number }, viewport: { w: number; h: number }, zoom: number): GraphView {
|
function fitGraphView(box: { w: number; h: number }, viewport: { w: number; h: number }, zoom: number): GraphView {
|
||||||
@@ -391,7 +404,12 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const layout = useMemo(() => (tree ? blockLayout(tree.nodes, tree.edges) : null), [tree]);
|
const layout = useMemo(() => (tree ? blockLayout(tree.nodes, tree.edges) : null), [tree]);
|
||||||
const box = useMemo(() => (layout ? { w: layout.width, h: layout.height } : { w: 0, h: 0 }), [layout]);
|
// The fit box is the PADDED ink box — the diagram renders with GRAPH_PAD
|
||||||
|
// so strokes/glow never clip, and the fit must account for it.
|
||||||
|
const box = useMemo(
|
||||||
|
() => (layout ? { w: layout.width + 2 * GRAPH_PAD, h: layout.height + 2 * GRAPH_PAD } : { w: 0, h: 0 }),
|
||||||
|
[layout],
|
||||||
|
);
|
||||||
const fitZoom = useMemo(() => fitZoomFor(box, size), [box, size]);
|
const fitZoom = useMemo(() => fitZoomFor(box, size), [box, size]);
|
||||||
|
|
||||||
const [view, setView] = useState<GraphView>({ x: 0, y: 0, zoom: 1 });
|
const [view, setView] = useState<GraphView>({ x: 0, y: 0, zoom: 1 });
|
||||||
@@ -507,6 +525,8 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
|
|||||||
statusOf={statusOf}
|
statusOf={statusOf}
|
||||||
glowId={statuses?.current ?? null}
|
glowId={statuses?.current ?? null}
|
||||||
markerId={markerId}
|
markerId={markerId}
|
||||||
|
pad={GRAPH_PAD}
|
||||||
|
naturalSize
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between p-2">
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between p-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user