Files
feuerwerk/src/editor/framing.ts
T
adminandClaude Opus 5 d3563897d8
game-maker / suite (push) Failing after 2m22s
game-maker / gate (push) Successful in 2m53s
Two compilers that walk a graph the same way walk it through one emitter (#85)
Refactoring round two: the particle and shader compilers share emitGraph.ts (cycle detection,
duplicate names, emit order) and keep only their rules. FontLibrary and TextureLibrary sit on
FileLibrary (base URL, loading/failed bookkeeping, listeners); SoundLibrary keeps its own
contract. The three Create*Command share ImportFileCommand, the three renames share
RenameCommand - class names stay. bandBounds() in framing.ts and portNameField() in
portSection.ts replace in-file clones, and three comments that told history now state the rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:28:28 +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,
};
}