Papure/src/lib/model/layout.ts
agent 308d6fc846 Place pages edge to edge with no gap
Neighbouring pages now touch, so the tree reads like a grid. Rows with
pages of different sizes are still centred, so it isn't a strict grid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsorPV3JeJRoZ1mD1pdvtL
2026-09-27 09:58:43 +00:00

83 lines
2.5 KiB
TypeScript

// Tree → world-space rectangles (units are PDF points; 1 CSS px = 1 pt in the
// untransformed world layer).
import { branchesOf, type Dir, type Slot, type Tree } from './tree';
export interface Size {
width: number;
height: number;
}
export interface Rect {
x: number;
y: number;
width: number;
height: number;
}
// Pages touch edge to edge; rows of mixed sizes stay centred, so it reads like a loose grid.
export const GAP = 0;
export function layout(tree: Tree, sizeOf: (id: string) => Size): Map<string, Rect> {
const rects = new Map<string, Rect>();
let y = 0;
for (const trunkId of tree.trunk) {
const b = branchesOf(tree, trunkId);
const rowIds = [trunkId, ...b.left, ...b.right];
const rowHeight = Math.max(...rowIds.map((id) => sizeOf(id).height));
const place = (id: string, x: number) => {
const s = sizeOf(id);
const r = { x, y: y + (rowHeight - s.height) / 2, width: s.width, height: s.height };
rects.set(id, r);
return r;
};
const trunk = place(trunkId, -sizeOf(trunkId).width / 2);
let left = trunk.x;
for (const id of b.left) left = place(id, left - GAP - sizeOf(id).width).x;
let right = trunk.x + trunk.width;
for (const id of b.right) {
const r = place(id, right + GAP);
right = r.x + r.width;
}
y += rowHeight + GAP;
}
return rects;
}
/** Where a ghost tile for `slot` sits, sized like `size`. */
export function slotRect(rects: Map<string, Rect>, slot: Slot, size: Size): Rect | null {
const a = rects.get(slot.anchor);
if (!a) return null;
return rectBeside(a, slot.dir, size);
}
export function rectBeside(a: Rect, dir: Dir, size: Size): Rect {
const cx = a.x + a.width / 2;
const cy = a.y + a.height / 2;
switch (dir) {
case 'up':
return { x: cx - size.width / 2, y: a.y - GAP - size.height, ...size };
case 'down':
return { x: cx - size.width / 2, y: a.y + a.height + GAP, ...size };
case 'left':
return { x: a.x - GAP - size.width, y: cy - size.height / 2, ...size };
case 'right':
return { x: a.x + a.width + GAP, y: cy - size.height / 2, ...size };
}
}
export function bounds(rects: Iterable<Rect>): Rect | null {
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
for (const r of rects) {
x1 = Math.min(x1, r.x);
y1 = Math.min(y1, r.y);
x2 = Math.max(x2, r.x + r.width);
y2 = Math.max(y2, r.y + r.height);
}
return x1 === Infinity ? null : { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
}
export function intersects(a: Rect, b: Rect): boolean {
return a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
}