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>
173 lines
5.8 KiB
TypeScript
173 lines
5.8 KiB
TypeScript
/** 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,
|
||
};
|
||
}
|