zoom multiplies the baseline instead of being swallowed by it
The 2x cap flattened the panel's zoom control into doing nothing, and the reason is worth writing down: `fit` is the SAME NUMBER at every zoom level. CSS `zoom` takes off `offsetWidth` exactly what it adds to the pixel ratio, so the fit is zoom-invariant by construction — and capping it therefore caps every zoom level to the same size. So the cap applies to the baseline, the size at 100%, and zoom multiplies what comes out. 100% stays exactly where it is; zooming in goes past 2x again. Quarter steps now go all the way down rather than whole numbers under 2x. With whole numbers, zooming out from a 2x baseline fell straight to 1x and halved the board in one press. Cells stay whole at every quarter — 20px at 1.25x — which was always the property that mattered, not the size of the step. The resulting ladder over the panel's 70-160% range: 1.25, 1.5, 1.75, 2.0, 2.0, 2.25, 2.5, 2.75, 3.0, 3.0. Eight of the ten presses move it; two land on the same quarter, which is the price of keeping the grid even. Also `overflow-hidden` on the box, so zooming past what the panel holds clips rather than shoving the status line out of the way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
This commit is contained in:
+23
-17
@@ -28,13 +28,14 @@ const BUTTON_MASK: Record<number, number> = { 0: 1, 1: 2, 2: 4 };
|
||||
const MARGIN_DEVICE_PX = 24;
|
||||
|
||||
/**
|
||||
* The ceiling on how large the game is drawn, whatever room the panel has.
|
||||
* The ceiling on the size at 100%, whatever room the panel has.
|
||||
*
|
||||
* A taste decision, not a limit of the method: fitting the panel is not the same as looking right, and
|
||||
* past 2x this stops reading as a window on a desktop and starts reading as a wall. Raise it and the
|
||||
* quarter steps below take over again.
|
||||
* past 2x this stops reading as a window on a desktop and starts reading as a wall.
|
||||
*
|
||||
* The panel header has a zoom control, so this is a default rather than a verdict.
|
||||
* It caps the BASELINE, not the result. The panel's zoom multiplies what comes out of here, so zooming
|
||||
* in still goes past 2x — the first version of this cap did not make that distinction and flattened the
|
||||
* zoom control into doing nothing, because the fit it capped is the same number at every zoom level.
|
||||
*/
|
||||
const MAX_SCALE = 2;
|
||||
|
||||
@@ -75,7 +76,7 @@ export const MinesweeperPanel = () => {
|
||||
const boxRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const [frame, setFrame] = useState<{ width: number; height: number } | null>(null);
|
||||
const [box, setBox] = useState<{ width: number; height: number; dpr: number } | null>(null);
|
||||
const [box, setBox] = useState<{ width: number; height: number; dpr: number; zoom: number } | null>(null);
|
||||
const [status, setStatus] = useState<SessionStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
@@ -98,6 +99,7 @@ export const MinesweeperPanel = () => {
|
||||
width: node.offsetWidth,
|
||||
height: node.offsetHeight,
|
||||
dpr: (window.devicePixelRatio || 1) * zoom,
|
||||
zoom,
|
||||
});
|
||||
});
|
||||
observer.observe(node);
|
||||
@@ -117,14 +119,20 @@ export const MinesweeperPanel = () => {
|
||||
* it removes the blur, not the unevenness. A fractional devicePixelRatio produces this at what looks
|
||||
* like 1:1, which is the case that is easiest to miss.
|
||||
*
|
||||
* QUARTER steps are allowed from 2x up, and that is not a compromise on the above. The unit that has
|
||||
* to stay regular is the CELL, not the pixel: the board is a 16px grid, so at 2.25x a cell is exactly
|
||||
* 36 screen pixels — as even as at 2x or 3x — and only sub-cell pixels alternate. Below 2x that
|
||||
* alternation is too large a share of the pixel, so it stays whole-number there.
|
||||
* QUARTER steps, and that is not a compromise on the above. The unit that has to stay regular is the
|
||||
* CELL, not the pixel: the board is a 16px grid, so every quarter step gives a whole cell — 20px at
|
||||
* 1.25x, 36px at 2.25x — and only sub-cell pixels alternate. Below 1x there is nothing to floor to, so
|
||||
* it takes a unit fraction and the rows that survive are at least evenly spaced.
|
||||
*
|
||||
* Quarters rather than halves because the steps are what make the size QUANTISED, and coarse steps
|
||||
* overshoot: with halves this went 2x to 2.5x, from too small to too large with nothing in between.
|
||||
* Finer steps let the picture track the panel rather than lurch between two sizes.
|
||||
* Quarters all the way down rather than whole numbers under 2x, because the zoom control has to have
|
||||
* somewhere to go: with whole numbers below 2x, zooming out from a 2x baseline fell straight to 1x and
|
||||
* halved the board in one press.
|
||||
*
|
||||
* ── zoom multiplies, it does not compete ──
|
||||
*
|
||||
* `fit` is the same number at every zoom level — the panel's CSS `zoom` shrinks `offsetWidth` by
|
||||
* exactly what it adds to the pixel ratio — so zoom has to be applied deliberately or it does nothing
|
||||
* at all. The baseline is what fits (capped), and zoom moves the picture up and down from there.
|
||||
*/
|
||||
const stage = useMemo(() => {
|
||||
if (!frame || !box || box.width <= 0 || box.height <= 0) return null;
|
||||
@@ -133,10 +141,8 @@ export const MinesweeperPanel = () => {
|
||||
const usableW = Math.max(1, box.width * box.dpr - MARGIN_DEVICE_PX * 2);
|
||||
const usableH = Math.max(1, box.height * box.dpr - MARGIN_DEVICE_PX * 2);
|
||||
const fit = Math.min(usableW / frame.width, usableH / frame.height);
|
||||
// Below 1 there is nothing to floor to, so take a unit fraction: the rows that survive are then
|
||||
// evenly spaced instead of dropped in clumps.
|
||||
const snapped = fit < 1 ? 1 / Math.ceil(1 / fit) : fit < 2 ? 1 : Math.floor(fit * 4) / 4;
|
||||
const scale = Math.min(snapped, MAX_SCALE);
|
||||
const wanted = Math.min(fit, MAX_SCALE) * box.zoom;
|
||||
const scale = wanted < 1 ? 1 / Math.ceil(1 / wanted) : Math.floor(wanted * 4) / 4;
|
||||
return { width: (frame.width * scale) / box.dpr, height: (frame.height * scale) / box.dpr };
|
||||
}, [frame, box]);
|
||||
|
||||
@@ -297,7 +303,7 @@ export const MinesweeperPanel = () => {
|
||||
return (
|
||||
<div className="flex size-full flex-col items-center justify-center gap-3 bg-muted/30 p-4">
|
||||
{/* min-h-0 on the row, so the canvas gives way to the status line rather than overflowing it. */}
|
||||
<div ref={boxRef} className="flex min-h-0 w-full flex-1 items-center justify-center">
|
||||
<div ref={boxRef} className="flex min-h-0 w-full flex-1 items-center justify-center overflow-hidden">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={PLACEHOLDER.width}
|
||||
|
||||
Reference in New Issue
Block a user