File size: 6,547 Bytes
6f91ce5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 | import type { TLRecord, TLShape } from '@tldraw/tlschema'
import { CARD_SIZE } from '../shared/shapes.ts'
// Layout v0 (T02): agents never send coordinates; a new shape goes on the nearest free spot
// around an anchor. Deterministic, and it never moves anything already on the board. Every
// paper is drawn as a CARD_SIZE card centred on its node (v6), so that is the room it keeps.
export interface Vec {
x: number
y: number
}
export interface Box extends Vec {
w: number
h: number
}
/** Free space kept around every placed box. */
export const MARGIN = 24
const RING_GAP = 40
const MAX_RINGS = 1000
const NOTE_SIZE = 200
// where the first shape on an empty page goes
const EMPTY_PAGE_ANCHOR = { x: 200, y: 200 }
const isShape = (r: TLRecord): r is TLShape => r.typeName === 'shape'
// The server has no geometry engine, so boxes come from props: w/h, notes at 200 × scale, a text
// line at 40 × scale. Arrows, and shapes with no size in their props (draw, line), take no space.
function ownSize(shape: TLShape): { w: number; h: number } | null {
const p = shape.props as Record<string, unknown>
const scale = typeof p.scale === 'number' ? p.scale : 1
if (shape.type === 'arrow') return null
if (shape.type === 'note') {
const growY = typeof p.growY === 'number' ? p.growY : 0
return { w: NOTE_SIZE * scale, h: (NOTE_SIZE + growY) * scale }
}
if (typeof p.w !== 'number') return null
if (typeof p.h === 'number') return { w: p.w, h: p.h }
if (shape.type === 'text') return { w: p.w * scale, h: 40 * scale }
return null
}
/** A paper card keeps at least a card's room, centred on its node, however small the node. */
export function footprint(b: Box): Box {
const w = Math.max(b.w, CARD_SIZE.w)
const h = Math.max(b.h, CARD_SIZE.h)
return { x: b.x - (w - b.w) / 2, y: b.y - (h - b.h) / 2, w, h }
}
/**
* Page-space boxes of the shapes among `records` (children of frames and groups included; rotation
* ignored). A paper card's box is its footprint, centred on the node.
*/
export function shapeBoxes(records: Iterable<TLRecord>): Map<string, Box> {
const shapes = new Map<string, TLShape>()
for (const r of records) if (isShape(r)) shapes.set(r.id, r)
const offset = (shape: TLShape): Vec => {
const parent = shapes.get(shape.parentId)
if (!parent) return { x: shape.x, y: shape.y }
const o = offset(parent)
return { x: o.x + shape.x, y: o.y + shape.y }
}
const boxes = new Map<string, Box>()
for (const shape of shapes.values()) {
const size = ownSize(shape)
if (!size) continue
const box = { ...offset(shape), ...size }
boxes.set(shape.id, shape.type === 'paper-card' ? footprint(box) : box)
}
return boxes
}
export const centre = (b: Box): Vec => ({ x: b.x + b.w / 2, y: b.y + b.h / 2 })
const overlaps = (a: Box, b: Box) => a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
/** True if `box`, grown by MARGIN on every side, touches none of `boxes`. */
export function isFree(boxes: Iterable<Box>, box: Box): boolean {
const grown = { x: box.x - MARGIN, y: box.y - MARGIN, w: box.w + 2 * MARGIN, h: box.h + 2 * MARGIN }
for (const b of boxes) if (overlaps(grown, b)) return false
return true
}
/** Content bounds, or null for an empty page. */
export function bounds(boxes: Iterable<Box>): Box | null {
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let 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 minX === Infinity ? null : { x: minX, y: minY, w: maxX - minX, h: maxY - minY }
}
/**
* Top-left for a new box of `size`: the first spot on rings around `anchor` (radius k × (the
* footprint's long side + 40), 8 angles per ring, nearest first) whose footprint keeps MARGIN
* clear of every shape among `records`. With no anchor, it starts just right of the page's
* content. A batch places shapes one by one, each call seeing the shapes placed before it.
*/
export function placeNear(records: Iterable<TLRecord>, size: { w: number; h: number }, anchor: Vec | null): Vec {
const boxes = [...shapeBoxes(records).values()]
const content = bounds(boxes)
const room = footprint({ x: 0, y: 0, ...size })
const start =
anchor ??
(content
? { x: content.x + content.w + RING_GAP + room.w / 2, y: content.y + room.h / 2 }
: EMPTY_PAGE_ANCHOR)
for (const spot of ringSpots(start, size, MAX_RINGS)) {
if (isFree(boxes, footprint({ ...spot, ...size }))) return spot
}
// unreachable in practice: past the outermost shape every spot is free
return { x: Math.round((content?.x ?? 0) + (content?.w ?? 0) + RING_GAP), y: Math.round(content?.y ?? 0) }
}
/**
* Top-left (page space) for a new box of `size` inside the frame `frameId`, as placeNear does on
* the page: the nearest ring spot around `anchor` whose footprint lies MARGIN inside the frame
* and keeps MARGIN clear of every other shape. Null if the frame has no room left.
*/
export function placeInside(
records: Iterable<TLRecord>,
size: { w: number; h: number },
anchor: Vec | null,
frameId: string
): Vec | null {
const all = shapeBoxes(records)
const frame = all.get(frameId)
if (!frame) return null
all.delete(frameId)
const boxes = [...all.values()]
const inner = { x: frame.x + MARGIN, y: frame.y + MARGIN, w: frame.w - 2 * MARGIN, h: frame.h - 2 * MARGIN }
const inside = (b: Box) =>
b.x >= inner.x && b.y >= inner.y && b.x + b.w <= inner.x + inner.w && b.y + b.h <= inner.y + inner.h
const rings = Math.ceil(Math.hypot(frame.w, frame.h) / ringStep(size)) + 1
for (const spot of ringSpots(anchor ?? centre(frame), size, rings)) {
const box = footprint({ ...spot, ...size })
if (inside(box) && isFree(boxes, box)) return spot
}
return null
}
// a card's long side plus RING_GAP: 172 + 40 = 212 px for every node up to 172 wide
function ringStep(size: { w: number; h: number }) {
const room = footprint({ x: 0, y: 0, ...size })
return Math.max(room.w, room.h) + RING_GAP
}
// Node top-left corners on rings around `start` at radius k × ringStep, 8 angles per ring, nearest first.
function* ringSpots(start: Vec, size: { w: number; h: number }, rings: number): Generator<Vec> {
const step = ringStep(size)
for (let k = 0; k <= rings; k++) {
for (let i = 0; i < (k === 0 ? 1 : 8); i++) {
const angle = (i * Math.PI) / 4
yield {
x: Math.round(start.x + k * step * Math.cos(angle) - size.w / 2),
y: Math.round(start.y + k * step * Math.sin(angle) - size.h / 2),
}
}
}
}
|