Files
feuerwerk/src/editor/framing.ts
T
adminandClaude Opus 5.5 021a6dcb56
game-maker / gate (push) Successful in 2m58s
game-maker / suite (push) Failing after 8m34s
Zoom the tile tab in the same steps as the scene view
The tile tab zoomed freely, so the pixels of a tile could end up
unevenly wide at odd factors. Scene view, container tab and animation
preview already snap to FREE_ZOOM_STEPS. The tile tab now does the
same, so every pixel editor zooms alike.

- The wheel steps through FREE_ZOOM_STEPS, counted in device pixels.
  The board position is rounded to whole device pixels.
- New wheelSteps() in src/viewport/view.ts turns wheel events into
  steps: one notch is one step, a trackpad gathers 50 px per step.
  FreeViewport uses it too, so the rule exists only once.
- Fit takes the largest step at which the whole board fits. Refitting
  on a new tab or board size stays as before.
- panAndZoom takes an optional zoom rule instead of limits. Graph and
  node board keep zooming freely.
- A saved view from before the steps snaps to the nearest step.
- The cap parameter of frameBoxes from 4027afc has no user anymore.
  It is removed together with its unit test.

Fit rounds down, and below 100 percent the steps are far apart. In the
small e2e window the 48 px board lands at 25 instead of 41 percent,
the 16 px board at 100 instead of 124 percent.

Tests:
- check.sh: 161 files, 2370 tests, exit 0 (-1 cap test,
  +1 wheelSteps test)
- e2e 1-5 and 39: all passed, 169 checks. Three checks in 39 now
  expect steps: Fit on a step with the next one too large, one notch
  goes 0.25 to 0.5, 16 px tiles refit to at least 100 percent.
- Counter check: a free Fit factor fails exactly those three checks
  (zoom 0.413 and 1.2396).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:30:41 +02:00

173 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** Geometry of the graph view (rubber band, fit, drag), kept out of the canvas so tests can reach it. */
import { snapToStep } from '../ui/math';
export interface Point {
x: number;
y: number;
}
/**
* The offset a drag adds to one node that started at `start`. A positive `step` lands that node on
* the grid – applied per node, so a whole selection lines up instead of carrying its old spacing
* along. `step <= 0` is "no snapping". A node nobody drags never moves either way.
*/
export function dragOffset(start: Point, dx: number, dy: number, step: number): Point {
if (!(step > 0)) return { x: Math.round(dx), y: Math.round(dy) };
return {
x: snapToStep(start.x + dx, step) - start.x,
y: snapToStep(start.y + dy, step) - start.y,
};
}
export interface Box {
id: string;
x: number;
y: number;
w: number;
h: number;
}
/** Grows a snapped size by whole steps until it clears `min`, so the far edge stays on the grid. */
function growToMin(size: number, min: number, step: number): number {
return size >= min ? size : size + Math.ceil((min - size) / step) * step;
}
/**
* The size a resize drag gives a box that started as `start`. With a positive `step` the corner
* being dragged – the far one, since the near one stays put – lands on the grid, the same way
* `dragOffset` puts a dragged node's own corner there. `step <= 0` is "no snapping". `min` is
* honoured in whole steps, so a box that hits its minimum is still grid-aligned.
*/
export function resizeSize(
start: Omit<Box, 'id'>,
dx: number,
dy: number,
step: number,
min: { w: number; h: number },
): { w: number; h: number } {
const w = start.w + dx;
const h = start.h + dy;
if (!(step > 0)) return { w, h };
return {
w: growToMin(snapToStep(start.x + w, step) - start.x, min.w, step),
h: growToMin(snapToStep(start.y + h, step) - start.y, min.h, step),
};
}
export interface Band {
x1: number;
y1: number;
x2: number;
y2: number;
}
export interface Framing {
zoom: number;
panX: number;
panY: number;
}
function bandBounds(band: Band): { minX: number; maxX: number; minY: number; maxY: number } {
return {
minX: Math.min(band.x1, band.x2),
maxX: Math.max(band.x1, band.x2),
minY: Math.min(band.y1, band.y2),
maxY: Math.max(band.y1, band.y2),
};
}
/**
* Ids of every box the band overlaps. Nodes are large rectangles, so – unlike the scene view,
* which tests an object's position – touching counts: a band drawn across a node that visibly
* caught it would otherwise leave it out.
*/
export function boxesInBand(boxes: readonly Box[], band: Band): string[] {
const { minX, maxX, minY, maxY } = bandBounds(band);
return boxes
.filter((b) => b.x <= maxX && b.x + b.w >= minX && b.y <= maxY && b.y + b.h >= minY)
.map((b) => b.id);
}
/**
* Ids of every box the band encloses whole. This is the rule for groups and comments, not the
* touching one: a group wraps the nodes it holds, so it overlaps every band drawn over any of
* them – and since a group drags its nodes along, catching it by accident would be felt at once.
* Enclosing it is a deliberate gesture, and it is the one the eye reads as "this box, too".
*/
export function boxesWithinBand(boxes: readonly Box[], band: Band): string[] {
const { minX, maxX, minY, maxY } = bandBounds(band);
return boxes
.filter((b) => b.x >= minX && b.x + b.w <= maxX && b.y >= minY && b.y + b.h <= maxY)
.map((b) => b.id);
}
/**
* Ids of the boxes a group carries: those whose centre lies inside it. The centre, not an overlap,
* because a group and the node next to it always touch a little – and because the answer must be
* the one the eye gives. Computed fresh whenever the group is grabbed, so nothing is remembered
* about a node that has been dragged out.
*/
export function boxesCentredIn(boxes: readonly Box[], rect: Box | Omit<Box, 'id'>): string[] {
return boxes
.filter((b) => {
const cx = b.x + b.w / 2;
const cy = b.y + b.h / 2;
return cx >= rect.x && cx <= rect.x + rect.w && cy >= rect.y && cy <= rect.y + rect.h;
})
.map((b) => b.id);
}
export interface Bounds {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
/**
* The rectangle enclosing every box, or null for none. `seed` starts the sum off – the scene view
* passes the origin, because that is where a container is grabbed when placed and a template drawn
* far to one side would otherwise hide it.
*/
export function boundsOf(boxes: readonly Box[], seed?: Bounds): Bounds | null {
let { minX, minY, maxX, maxY } = seed ?? {
minX: Infinity,
minY: Infinity,
maxX: -Infinity,
maxY: -Infinity,
};
for (const b of boxes) {
minX = Math.min(minX, b.x);
minY = Math.min(minY, b.y);
maxX = Math.max(maxX, b.x + b.w);
maxY = Math.max(maxY, b.y + b.h);
}
return Number.isFinite(minX) ? { minX, minY, maxX, maxY } : null;
}
/**
* The view that shows every box at once, centred, with `margin` screen pixels to spare.
* Never zooms past 1: a two-node graph blown up to fill the screen helps nobody. The scene view
* deliberately does not share that cap, which is why only the bounds sum is common.
*/
export function frameBoxes(
boxes: readonly Box[],
view: { w: number; h: number },
margin: number,
limits: { min: number; max: number },
): Framing {
const bounds = boundsOf(boxes);
if (!bounds) return { zoom: 1, panX: view.w / 2, panY: view.h / 2 };
const { minX, minY, maxX, maxY } = bounds;
const width = Math.max(maxX - minX, 1);
const height = Math.max(maxY - minY, 1);
const fitted = Math.min((view.w - margin * 2) / width, (view.h - margin * 2) / height, 1);
const zoom = Math.min(Math.max(fitted, limits.min), limits.max);
return {
zoom,
panX: view.w / 2 - ((minX + maxX) / 2) * zoom,
panY: view.h / 2 - ((minY + maxY) / 2) * zoom,
};
}