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),
			}
		}
	}
}