fabro/apps/fabro-web/app/components/graph-toolbar.tsx
André Mazoni daa6f4cdbf
Some checks failed
TypeScript / Typecheck (push) Has been cancelled
TypeScript / Test (push) Has been cancelled
TypeScript / Build (push) Has been cancelled
Increase LR graph zoom to 400% and remember zoom per direction (#581)
Raises the LR graph zoom ceiling from 200% to 400%. TB is unchanged at
200%.

Zoom and pan are now tracked separately per direction instead of shared.
Switching LR to TB and back restores the viewport you left in each mode,
so a round trip no longer loses your position. Previously a single
shared zoom value was clamped down whenever you switched into TB, which
meant going LR to TB and back cost you your LR zoom.

`run-overview.tsx` holds two view states, remembered per run under
`<runId>-TB` and `<runId>-LR`. `clampZoom` and `zoomAtPoint` take a
`direction` argument and apply the matching ceiling, so the
clamp-on-direction-change effect is gone. 24 tests in
`graph-viewport.test.ts`.

Requirements:
docs/brainstorms/2026-07-21-graph-zoom-lr-increase-requirements.md
Plan: docs/plans/2026-07-21-graph-zoom-lr-increase-plan.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Fabro <noreply@fabro.sh>
2026-07-21 16:06:25 -04:00

92 lines
3.1 KiB
TypeScript

import { ArrowDownIcon, ArrowRightIcon, MinusIcon, PlusIcon } from "@heroicons/react/20/solid";
import { GRAPH_MAX_ZOOM_TB, GRAPH_MAX_ZOOM_LR, GRAPH_MIN_ZOOM } from "../lib/graph-viewport";
type Direction = "LR" | "TB";
// +/- button step. Zoom-out uses the reciprocal, keeping it symmetric with zoom-in.
const ZOOM_STEP_FACTOR = 1.25;
export function GraphToolbar({
direction,
setDirection,
fitToWindow,
zoom,
onZoomBy,
}: {
direction: Direction;
setDirection: (d: Direction) => void;
fitToWindow: () => void;
zoom: number;
onZoomBy: (factor: number) => void;
}) {
const group =
"flex items-center gap-0.5 px-0.5 [&:not(:first-child)]:border-l [&:not(:first-child)]:border-line-strong [&:not(:first-child)]:pl-1 [&:not(:first-child)]:ml-1";
const btn =
"flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-teal-500";
const btnActive = "bg-overlay-strong text-fg-3";
const btnDisabled =
"disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-fg-muted";
return (
<div
role="toolbar"
aria-label="Graph controls"
className="absolute right-3 top-3 z-10 flex items-center rounded-md border border-line bg-panel p-0.5"
>
<div className={group}>
<button
type="button"
title="Left to right"
onClick={() => setDirection("LR")}
aria-pressed={direction === "LR"}
className={`${btn} ${direction === "LR" ? btnActive : ""}`}
>
<ArrowRightIcon className="size-3.5" aria-hidden="true" />
</button>
<button
type="button"
title="Top to bottom"
onClick={() => setDirection("TB")}
aria-pressed={direction === "TB"}
className={`${btn} ${direction === "TB" ? btnActive : ""}`}
>
<ArrowDownIcon className="size-3.5" aria-hidden="true" />
</button>
</div>
<div className={group}>
<button
type="button"
title="Fit to window"
aria-label="Fit graph to window"
onClick={fitToWindow}
className={btn}
>
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" className="size-3.5" aria-hidden="true">
<rect x="1" y="1" width="12" height="12" rx="1.5" strokeWidth="1.5" strokeDasharray="3 2" />
</svg>
</button>
</div>
<div className={group}>
<button
type="button"
title="Zoom out"
onClick={() => onZoomBy(1 / ZOOM_STEP_FACTOR)}
disabled={zoom <= GRAPH_MIN_ZOOM}
className={`${btn} ${btnDisabled}`}
>
<MinusIcon className="size-4" aria-hidden="true" />
</button>
<button
type="button"
title="Zoom in"
onClick={() => onZoomBy(ZOOM_STEP_FACTOR)}
disabled={zoom >= (direction === "LR" ? GRAPH_MAX_ZOOM_LR : GRAPH_MAX_ZOOM_TB)}
className={`${btn} ${btnDisabled}`}
>
<PlusIcon className="size-4" aria-hidden="true" />
</button>
</div>
</div>
);
}