@griddle/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +54 -0
  3. package/dist/compaction.d.ts +3 -0
  4. package/dist/compaction.d.ts.map +1 -0
  5. package/dist/compaction.js +85 -0
  6. package/dist/compaction.js.map +1 -0
  7. package/dist/drag.d.ts +52 -0
  8. package/dist/drag.d.ts.map +1 -0
  9. package/dist/drag.js +120 -0
  10. package/dist/drag.js.map +1 -0
  11. package/dist/events.d.ts +9 -0
  12. package/dist/events.d.ts.map +1 -0
  13. package/dist/events.js +22 -0
  14. package/dist/events.js.map +1 -0
  15. package/dist/geometry.d.ts +33 -0
  16. package/dist/geometry.d.ts.map +1 -0
  17. package/dist/geometry.js +164 -0
  18. package/dist/geometry.js.map +1 -0
  19. package/dist/grid.d.ts +100 -0
  20. package/dist/grid.d.ts.map +1 -0
  21. package/dist/grid.js +539 -0
  22. package/dist/grid.js.map +1 -0
  23. package/dist/group-drag.d.ts +41 -0
  24. package/dist/group-drag.d.ts.map +1 -0
  25. package/dist/group-drag.js +97 -0
  26. package/dist/group-drag.js.map +1 -0
  27. package/dist/index.d.ts +12 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +11 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/loop.d.ts +99 -0
  32. package/dist/loop.d.ts.map +1 -0
  33. package/dist/loop.js +188 -0
  34. package/dist/loop.js.map +1 -0
  35. package/dist/movement.d.ts +17 -0
  36. package/dist/movement.d.ts.map +1 -0
  37. package/dist/movement.js +333 -0
  38. package/dist/movement.js.map +1 -0
  39. package/dist/packing.d.ts +16 -0
  40. package/dist/packing.d.ts.map +1 -0
  41. package/dist/packing.js +159 -0
  42. package/dist/packing.js.map +1 -0
  43. package/dist/pan.d.ts +58 -0
  44. package/dist/pan.d.ts.map +1 -0
  45. package/dist/pan.js +174 -0
  46. package/dist/pan.js.map +1 -0
  47. package/dist/positioning.d.ts +117 -0
  48. package/dist/positioning.d.ts.map +1 -0
  49. package/dist/positioning.js +251 -0
  50. package/dist/positioning.js.map +1 -0
  51. package/dist/repack.d.ts +4 -0
  52. package/dist/repack.d.ts.map +1 -0
  53. package/dist/repack.js +179 -0
  54. package/dist/repack.js.map +1 -0
  55. package/dist/types.d.ts +236 -0
  56. package/dist/types.d.ts.map +1 -0
  57. package/dist/types.js +4 -0
  58. package/dist/types.js.map +1 -0
  59. package/dist/virtualize.d.ts +27 -0
  60. package/dist/virtualize.d.ts.map +1 -0
  61. package/dist/virtualize.js +53 -0
  62. package/dist/virtualize.js.map +1 -0
  63. package/package.json +55 -0
  64. package/src/compaction.ts +80 -0
  65. package/src/drag.ts +146 -0
  66. package/src/events.ts +25 -0
  67. package/src/geometry.ts +199 -0
  68. package/src/grid.ts +578 -0
  69. package/src/group-drag.ts +120 -0
  70. package/src/index.ts +78 -0
  71. package/src/loop.ts +246 -0
  72. package/src/movement.ts +363 -0
  73. package/src/packing.ts +169 -0
  74. package/src/pan.ts +217 -0
  75. package/src/positioning.ts +292 -0
  76. package/src/repack.ts +212 -0
  77. package/src/types.ts +262 -0
  78. package/src/virtualize.ts +77 -0
package/src/index.ts ADDED
@@ -0,0 +1,78 @@
1
+ // @griddle/core public API.
2
+
3
+ export { Grid } from './grid.js';
4
+ export type {
5
+ CellPos,
6
+ CellRect,
7
+ Corner,
8
+ Direction8,
9
+ Face,
10
+ Footprint,
11
+ Gravity,
12
+ GridChangeEvent,
13
+ GridConfig,
14
+ GridSnapshot,
15
+ LoopConfig,
16
+ LoopPhysicsConfig,
17
+ StickyConfig,
18
+ StickyEdge,
19
+ Tile,
20
+ TilePosition,
21
+ } from './types.js';
22
+ export {
23
+ rectsOverlap,
24
+ rectsAdjacent,
25
+ rectContains,
26
+ rectEquals,
27
+ priorityDirections,
28
+ faceClosestToOrigin,
29
+ oppositeFace,
30
+ classifyOrigin,
31
+ translateRect,
32
+ offsetRect,
33
+ directionStep,
34
+ tileRect,
35
+ footprintEquals,
36
+ } from './geometry.js';
37
+ export {
38
+ visibleRange,
39
+ visibleTiles,
40
+ gridContentSize,
41
+ type Viewport,
42
+ type VisibleRange,
43
+ } from './virtualize.js';
44
+ export {
45
+ loopEnabled,
46
+ loopInteraction,
47
+ assertLoopable,
48
+ wrapValue,
49
+ wrapCell,
50
+ loopBounds,
51
+ loopPeriod,
52
+ loopShift,
53
+ loopInstances,
54
+ resolveLoop,
55
+ type LoopBounds,
56
+ type LoopPattern,
57
+ type LoopTileInstance,
58
+ type ResolvedLoop,
59
+ } from './loop.js';
60
+ export {
61
+ PanController,
62
+ type CameraState,
63
+ type PanPhysicsOptions,
64
+ } from './pan.js';
65
+ export { Emitter } from './events.js';
66
+ export { DragController, type DragUpdateResult } from './drag.js';
67
+ export { GroupDragController, type GroupDragUpdateResult } from './group-drag.js';
68
+ export {
69
+ isInFlow,
70
+ isOutOfFlow,
71
+ pinnedToPixels,
72
+ pixelsToPin,
73
+ offsetToPixels,
74
+ pixelsToOffset,
75
+ computeTileLayout,
76
+ resolveStickyStacking,
77
+ } from './positioning.js';
78
+ export type { TileLayout, TileLayoutInput } from './positioning.js';
package/src/loop.ts ADDED
@@ -0,0 +1,246 @@
1
+ // Loop mode ("object looping") — pure wrap math, no DOM, no state.
2
+ //
3
+ // The *packed content* (the bounding box of the in-flow tiles, not the
4
+ // configured cols x rows) is one period of an infinite plane. Adapters render
5
+ // *tile instances*: copies of each tile offset by whole periods so that
6
+ // wherever the camera is, the plane appears endless with no dead space
7
+ // between repeats.
8
+ //
9
+ // Rendering model (no native scroll):
10
+ // - The viewport is an overflow-hidden box. A zero-sized "plane" element
11
+ // inside it carries `transform: translate(-cameraX, -cameraY)`.
12
+ // - The camera is an unbounded PanController offset fed by wheel deltas and
13
+ // (optionally) drag-to-pan. Nothing ever has scrollable overflow, so there
14
+ // are no scrollbars to grow and no layout feedback with the page.
15
+ // - Instances are virtualized: only copies intersecting the camera window
16
+ // (plus a small buffer) are rendered, keyed by their world period indices.
17
+
18
+ import type { CellPos, GridConfig, LoopConfig, Tile } from './types.js';
19
+ import { isInFlow } from './positioning.js';
20
+
21
+ /** Cell-space size of the loop period: the content's bounding box. */
22
+ export interface LoopBounds {
23
+ cols: number;
24
+ rows: number;
25
+ }
26
+
27
+ /** Repeat pattern: aligned grid, brick (row shift), or drop (column shift). */
28
+ export type LoopPattern = 'grid' | 'brick' | 'drop';
29
+
30
+ /** True when loop mode is on. */
31
+ export function loopEnabled(config: GridConfig): boolean {
32
+ return config.loop?.enabled === true;
33
+ }
34
+
35
+ /** Effective interaction mode for a loop config. Default 'pan'. */
36
+ export function loopInteraction(config: GridConfig): 'pan' | 'edit' {
37
+ return config.loop?.interaction ?? 'pan';
38
+ }
39
+
40
+ /**
41
+ * Validate that a config can loop. Loop mode needs a finite grid to derive
42
+ * its period from — it is incompatible with infiniteX/infiniteY.
43
+ */
44
+ export function assertLoopable(config: GridConfig): void {
45
+ if (!loopEnabled(config)) return;
46
+ if (
47
+ config.infiniteX ||
48
+ config.infiniteY ||
49
+ !Number.isFinite(config.cols) ||
50
+ !Number.isFinite(config.rows)
51
+ ) {
52
+ throw new Error(
53
+ 'Griddle: loop mode requires finite cols/rows and is incompatible with infiniteX/infiniteY',
54
+ );
55
+ }
56
+ }
57
+
58
+ /** Positive modulo: wraps `v` into [0, period). */
59
+ export function wrapValue(v: number, period: number): number {
60
+ const m = v % period;
61
+ return m < 0 ? m + period : m;
62
+ }
63
+
64
+ /** Wrap a cell position into the base period [0,cols) x [0,rows). */
65
+ export function wrapCell(pos: CellPos, bounds: LoopBounds): CellPos {
66
+ return {
67
+ col: wrapValue(pos.col, bounds.cols),
68
+ row: wrapValue(pos.row, bounds.rows),
69
+ };
70
+ }
71
+
72
+ /**
73
+ * The loop period in cells: the bounding box of the in-flow tiles. Deriving
74
+ * the period from content (rather than the configured cols x rows) is what
75
+ * makes repeats butt up against each other instead of repeating the empty
76
+ * remainder of the grid.
77
+ */
78
+ export function loopBounds(tiles: Tile[]): LoopBounds {
79
+ let cols = 0;
80
+ let rows = 0;
81
+ for (const t of tiles) {
82
+ if (!isInFlow(t)) continue;
83
+ cols = Math.max(cols, t.col + t.w);
84
+ rows = Math.max(rows, t.row + t.h);
85
+ }
86
+ return { cols: Math.max(1, cols), rows: Math.max(1, rows) };
87
+ }
88
+
89
+ /** Pixel size of one period (the packed content, gaps included). */
90
+ export function loopPeriod(
91
+ config: GridConfig,
92
+ tiles: Tile[],
93
+ ): { width: number; height: number } {
94
+ const b = loopBounds(tiles);
95
+ const gap = config.gap ?? 0;
96
+ return {
97
+ width: b.cols * (config.unitWidth + gap),
98
+ height: b.rows * (config.unitHeight + gap),
99
+ };
100
+ }
101
+
102
+ /** One rendered copy of a tile, offset by (kx, ky) whole periods. */
103
+ export interface LoopTileInstance {
104
+ tile: Tile;
105
+ /** World-stable key for this copy: `${tile.id}@${kx},${ky}`. */
106
+ key: string;
107
+ /** World period indices (0 = the base copy at the origin). */
108
+ kx: number;
109
+ ky: number;
110
+ /** World-space pixel layout (gap-aware, same math as static layout). */
111
+ left: number;
112
+ top: number;
113
+ width: number;
114
+ height: number;
115
+ }
116
+
117
+ /**
118
+ * Pattern shift in pixels per repeat index, rounded to whole cells so gaps
119
+ * stay aligned. Brick shifts each repeat *row* horizontally; drop shifts
120
+ * each repeat *column* vertically; grid shifts nothing.
121
+ */
122
+ export function loopShift(
123
+ config: GridConfig,
124
+ tiles: Tile[],
125
+ ): { x: number; y: number } {
126
+ const loop = resolveLoop(config);
127
+ if (!loop || loop.pattern === 'grid') return { x: 0, y: 0 };
128
+ const gap = config.gap ?? 0;
129
+ const b = loopBounds(tiles);
130
+ if (loop.pattern === 'brick') {
131
+ return { x: Math.round(loop.offset * b.cols) * (config.unitWidth + gap), y: 0 };
132
+ }
133
+ return { x: 0, y: Math.round(loop.offset * b.rows) * (config.unitHeight + gap) };
134
+ }
135
+
136
+ /**
137
+ * Compute the tile instances visible in `view` (a world-space pixel rect,
138
+ * typically the camera window padded by a small buffer). Each in-flow tile
139
+ * yields one instance per period copy that intersects the view — the same
140
+ * tile can appear more than once when the view is larger than the period.
141
+ *
142
+ * Copy origins form a (possibly sheared) lattice:
143
+ * left = base.x + kx * periodW + ky * shiftX (shiftX != 0 for 'brick')
144
+ * top = base.y + ky * periodH + kx * shiftY (shiftY != 0 for 'drop')
145
+ * The base copy (kx = 0, ky = 0) is always unshifted.
146
+ *
147
+ * Out-of-flow tiles (`absolute`/`fixed`/`sticky`) do not loop; they are
148
+ * excluded here and should be layered by the adapter as usual.
149
+ */
150
+ export function loopInstances(
151
+ config: GridConfig,
152
+ tiles: Tile[],
153
+ view: { x: number; y: number; width: number; height: number },
154
+ ): LoopTileInstance[] {
155
+ const gap = config.gap ?? 0;
156
+ const halfGap = gap / 2;
157
+ const colSize = config.unitWidth + gap;
158
+ const rowSize = config.unitHeight + gap;
159
+ const period = loopPeriod(config, tiles);
160
+ const shift = loopShift(config, tiles);
161
+
162
+ // Smallest k with copy end > lo and largest k with copy start < hi
163
+ // (half-open view rect, hence the +1 / -1).
164
+ const kRange = (start: number, size: number, step: number, lo: number, hi: number) => ({
165
+ k0: Math.floor((lo - start - size) / step) + 1,
166
+ k1: Math.ceil((hi - start) / step) - 1,
167
+ });
168
+
169
+ const out: LoopTileInstance[] = [];
170
+ for (const tile of tiles) {
171
+ if (!isInFlow(tile)) continue;
172
+ const baseLeft = tile.col * colSize + halfGap;
173
+ const baseTop = tile.row * rowSize + halfGap;
174
+ const width = tile.w * config.unitWidth + (tile.w - 1) * gap;
175
+ const height = tile.h * config.unitHeight + (tile.h - 1) * gap;
176
+
177
+ const push = (kx: number, ky: number) => {
178
+ out.push({
179
+ tile,
180
+ key: `${tile.id}@${kx},${ky}`,
181
+ kx,
182
+ ky,
183
+ left: baseLeft + kx * period.width + ky * shift.x,
184
+ top: baseTop + ky * period.height + kx * shift.y,
185
+ width,
186
+ height,
187
+ });
188
+ };
189
+
190
+ if (shift.y !== 0) {
191
+ // 'drop': vertical placement depends on kx — outer loop over columns.
192
+ const xs = kRange(baseLeft, width, period.width, view.x, view.x + view.width);
193
+ for (let kx = xs.k0; kx <= xs.k1; kx++) {
194
+ const top0 = baseTop + kx * shift.y;
195
+ const ys = kRange(top0, height, period.height, view.y, view.y + view.height);
196
+ for (let ky = ys.k0; ky <= ys.k1; ky++) push(kx, ky);
197
+ }
198
+ } else {
199
+ // 'grid' and 'brick': horizontal placement may depend on ky.
200
+ const ys = kRange(baseTop, height, period.height, view.y, view.y + view.height);
201
+ for (let ky = ys.k0; ky <= ys.k1; ky++) {
202
+ const left0 = baseLeft + ky * shift.x;
203
+ const xs = kRange(left0, width, period.width, view.x, view.x + view.width);
204
+ for (let kx = xs.k0; kx <= xs.k1; kx++) push(kx, ky);
205
+ }
206
+ }
207
+ }
208
+ return out;
209
+ }
210
+
211
+ /** Resolved loop settings with defaults applied. */
212
+ export interface ResolvedLoop {
213
+ interaction: 'pan' | 'edit';
214
+ pattern: LoopPattern;
215
+ /** Shift fraction for brick/drop, clamped to [0, 1]. */
216
+ offset: number;
217
+ repack: 'toggle' | 'structural';
218
+ dragPan: boolean;
219
+ friction: number;
220
+ ease: number;
221
+ maxVelocity: number;
222
+ }
223
+
224
+ /**
225
+ * Apply defaults to a LoopConfig. Returns null when loop mode is off.
226
+ *
227
+ * `dragPan` defaults to true in both interactions: in 'edit' mode the base
228
+ * copy's tiles capture their own pointerdown (drag-n-drop) and the ghost
229
+ * repeats are pointer-transparent, so drag-to-pan engages everywhere else —
230
+ * without it, touch users couldn't move the plane at all since there is no
231
+ * native scrolling in loop mode.
232
+ */
233
+ export function resolveLoop(config: GridConfig): ResolvedLoop | null {
234
+ const loop: LoopConfig | undefined = config.loop;
235
+ if (!loop?.enabled) return null;
236
+ return {
237
+ interaction: loop.interaction ?? 'pan',
238
+ pattern: loop.pattern ?? 'grid',
239
+ offset: Math.min(1, Math.max(0, loop.offset ?? 0.5)),
240
+ repack: loop.repack ?? 'toggle',
241
+ dragPan: loop.physics?.dragPan ?? true,
242
+ friction: loop.physics?.friction ?? 4,
243
+ ease: loop.physics?.ease ?? 12,
244
+ maxVelocity: loop.physics?.maxVelocity ?? 6000,
245
+ };
246
+ }
@@ -0,0 +1,363 @@
1
+ // Movement engine — implements Rules 1-6 of the drag/drop spec.
2
+ //
3
+ // The engine mutates the grid in place during attempts but takes a snapshot at
4
+ // each rule boundary so it can roll back on failure. If every rule rejects the
5
+ // move (and the BFS fallback can't repack), the original grid is restored and
6
+ // moveTile returns false.
7
+ //
8
+ // Rule overview:
9
+ // 1. Empty target — drop straight in.
10
+ // 2. Same-footprint swap — adjacent partner with identical w×h: swap them.
11
+ // 3-5. Single-step displace — push each overlapping victim by enough cells
12
+ // along a priority direction to clear the dragger's full footprint.
13
+ // Each victim is placed independently; if any victim has no legal slot
14
+ // the rule rejects and we fall through.
15
+ // 6. Cascade push — push the victim AND any blockers it runs into
16
+ // along a priority direction. On infinite axes this loops until the
17
+ // target rect is clear (tiles slide off into space). On fixed grids,
18
+ // the cascade may run out of room — the move falls through to the 0-1
19
+ // BFS repack solver as a last resort.
20
+
21
+ import type { CellPos, CellRect, Direction8, Tile } from './types.js';
22
+ import type { Grid } from './grid.js';
23
+ import {
24
+ directionStep,
25
+ faceClosestToOrigin,
26
+ footprintEquals,
27
+ priorityDirections,
28
+ rectsAdjacent,
29
+ rectsOverlap,
30
+ tileRect,
31
+ } from './geometry.js';
32
+ import { solvePushBFS } from './repack.js';
33
+
34
+ export interface MoveOptions {
35
+ /**
36
+ * Use this rect as the "origin" when computing priority directions, instead of
37
+ * the tile's actual current position. Used by resize, where the resized rect
38
+ * acts as the origin so neighbors are pushed away from the grown footprint.
39
+ */
40
+ forceFromRect?: CellRect;
41
+ }
42
+
43
+ /**
44
+ * Attempt to move `tileId` to `target`. On success, the grid is mutated in
45
+ * place and `true` is returned. On failure, the grid is left unchanged and
46
+ * `false` is returned.
47
+ */
48
+ export function moveTile(
49
+ grid: Grid,
50
+ tileId: string,
51
+ target: CellPos,
52
+ opts: MoveOptions = {},
53
+ ): boolean {
54
+ const tile = grid.getTile(tileId);
55
+ if (!tile) return false;
56
+
57
+ const originRect: CellRect = opts.forceFromRect ?? tileRect(tile);
58
+ const targetRect: CellRect = { col: target.col, row: target.row, w: tile.w, h: tile.h };
59
+
60
+ if (!grid.rectInBounds(targetRect)) return false;
61
+
62
+ const overlap = grid.tilesIn(targetRect, new Set([tileId]));
63
+
64
+ // Rule 1: empty space
65
+ if (overlap.length === 0) {
66
+ grid._setTilePos(tileId, target);
67
+ return true;
68
+ }
69
+
70
+ // Rule 2: adjacent same-footprint swap
71
+ if (
72
+ overlap.length === 1 &&
73
+ rectsAdjacent(originRect, tileRect(overlap[0]!)) &&
74
+ footprintEquals(overlap[0]!, tile)
75
+ ) {
76
+ const other = overlap[0]!;
77
+ const otherPos: CellPos = { col: other.col, row: other.row };
78
+ const originPos: CellPos = { col: originRect.col, row: originRect.row };
79
+ grid._setTilePos(tileId, otherPos);
80
+ grid._setTilePos(other.id, originPos);
81
+ return true;
82
+ }
83
+
84
+ // Rule 3-5: try independent single-displacement of each overlapper.
85
+ //
86
+ // Each victim is placed in turn against the live grid. The ONLY tile we
87
+ // ignore when checking for collisions is the dragger itself — it still
88
+ // sits at originRect at this point and shouldn't block the displacement
89
+ // search. Any victim already moved into its new slot DOES block the next
90
+ // victim's candidate; otherwise two displaced tiles can land on the same
91
+ // cell (visible to the user as one tile "only moving 1 unit" because the
92
+ // overlap collapses two tiles into the same rendered position).
93
+ const snapshot = grid.snapshotTiles();
94
+ const dirs = priorityDirections(originRect, targetRect);
95
+ const draggerOnly: ReadonlySet<string> = new Set([tileId]);
96
+
97
+ let allPlaced = true;
98
+ for (const victim of overlap) {
99
+ const moved = tryDisplaceVictim(grid, victim.id, dirs, targetRect, draggerOnly);
100
+ if (!moved) { allPlaced = false; break; }
101
+ }
102
+
103
+ if (allPlaced) {
104
+ grid._setTilePos(tileId, target);
105
+ return true;
106
+ }
107
+
108
+ grid.restoreTiles(snapshot);
109
+
110
+ // Rule 6: try cascading push along each priority direction.
111
+ // First: if the primary direction is on an infinite axis, use unbounded chain push.
112
+ // Otherwise: try minimum-clearance cascade push along each priority direction in turn.
113
+ const primaryDir = faceClosestToOrigin(originRect, targetRect);
114
+ if (isInfiniteDirection(grid, primaryDir)) {
115
+ if (pushChainInfinite(grid, tileId, target, primaryDir)) return true;
116
+ grid.restoreTiles(snapshot);
117
+ }
118
+
119
+ for (const dir of dirs) {
120
+ if (cascadePushOverlap(grid, tileId, targetRect, dir)) {
121
+ grid._setTilePos(tileId, target);
122
+ return true;
123
+ }
124
+ grid.restoreTiles(snapshot);
125
+ }
126
+
127
+ // Final fallback: 0-1 BFS on fixed grid.
128
+ const solved = solvePushBFS(grid, tileId, target, originRect);
129
+ if (!solved) {
130
+ grid.restoreTiles(snapshot);
131
+ return false;
132
+ }
133
+ return true;
134
+ }
135
+
136
+ /**
137
+ * Displace `victimId` by the minimum cell count along one of `priorityDirs`
138
+ * such that it no longer overlaps `targetRect`, stays in bounds, and lands clear.
139
+ * Cardinal: clear on the moving axis. Diagonal: min(kxClear, kyClear) cells.
140
+ */
141
+ function tryDisplaceVictim(
142
+ grid: Grid,
143
+ victimId: string,
144
+ priorityDirs: Direction8[],
145
+ targetRect: CellRect,
146
+ ignore: ReadonlySet<string>,
147
+ ): boolean {
148
+ const victim = grid.getTile(victimId);
149
+ if (!victim) return false;
150
+ const victimRect = tileRect(victim);
151
+
152
+ for (const dir of priorityDirs) {
153
+ const { dx, dy } = directionStep(dir);
154
+
155
+ let kxClear = Infinity;
156
+ if (dx > 0) {
157
+ kxClear = Math.max(1, targetRect.col + targetRect.w - victimRect.col);
158
+ } else if (dx < 0) {
159
+ kxClear = Math.max(1, victimRect.col + victimRect.w - targetRect.col);
160
+ }
161
+ let kyClear = Infinity;
162
+ if (dy > 0) {
163
+ kyClear = Math.max(1, targetRect.row + targetRect.h - victimRect.row);
164
+ } else if (dy < 0) {
165
+ kyClear = Math.max(1, victimRect.row + victimRect.h - targetRect.row);
166
+ }
167
+
168
+ let k: number;
169
+ if (dx === 0 && dy === 0) continue;
170
+ else if (dx === 0) k = kyClear;
171
+ else if (dy === 0) k = kxClear;
172
+ else k = Math.min(kxClear, kyClear);
173
+
174
+ if (!isFinite(k)) continue;
175
+
176
+ const candidate: CellRect = {
177
+ col: victimRect.col + k * dx,
178
+ row: victimRect.row + k * dy,
179
+ w: victim.w,
180
+ h: victim.h,
181
+ };
182
+ if (!grid.rectInBounds(candidate)) continue;
183
+ const blockIds = new Set(ignore);
184
+ blockIds.add(victimId);
185
+ const hits = grid.tilesIn(candidate, blockIds);
186
+ if (hits.length === 0) {
187
+ grid._setTilePos(victimId, { col: candidate.col, row: candidate.row });
188
+ return true;
189
+ }
190
+ }
191
+ return false;
192
+ }
193
+
194
+ function isInfiniteDirection(grid: Grid, dir: Direction8): boolean {
195
+ const { dx, dy } = directionStep(dir);
196
+ if (dx !== 0 && !grid.config.infiniteX) return false;
197
+ if (dy !== 0 && !grid.config.infiniteY) return false;
198
+ if (dx !== 0 && grid.config.infiniteX) return true;
199
+ if (dy !== 0 && grid.config.infiniteY) return true;
200
+ return false;
201
+ }
202
+
203
+ /**
204
+ * Push every tile that overlaps `targetRect` by enough cells along `dir` to clear it,
205
+ * cascading through any tiles those pushes collide with. Aborts if any chained push
206
+ * would go out of bounds. Mutates the grid; caller restores on failure.
207
+ *
208
+ * For cardinal `dir`: each victim moves k = enough-to-clear cells.
209
+ * For diagonal `dir`: same logic — k = min cell count to clear via either axis.
210
+ */
211
+ function cascadePushOverlap(
212
+ grid: Grid,
213
+ draggerId: string,
214
+ targetRect: CellRect,
215
+ dir: Direction8,
216
+ ): boolean {
217
+ const { dx, dy } = directionStep(dir);
218
+ if (dx === 0 && dy === 0) return false;
219
+
220
+ const initialOverlap = grid.tilesIn(targetRect, new Set([draggerId]));
221
+ if (initialOverlap.length === 0) return true; // already clear
222
+
223
+ // Build the per-tile shift map. Each tile shifts by some multiple of (dx,dy).
224
+ // Start with the overlappers — each needs enough k to clear targetRect.
225
+ const shift = new Map<string, number>(); // tileId -> k cells along (dx,dy)
226
+ const queue: { id: string; k: number }[] = [];
227
+
228
+ function neededKForClearing(victim: Tile, blockRect: CellRect): number {
229
+ let kx = Infinity;
230
+ if (dx > 0) kx = Math.max(1, blockRect.col + blockRect.w - victim.col);
231
+ else if (dx < 0) kx = Math.max(1, victim.col + victim.w - blockRect.col);
232
+ let ky = Infinity;
233
+ if (dy > 0) ky = Math.max(1, blockRect.row + blockRect.h - victim.row);
234
+ else if (dy < 0) ky = Math.max(1, victim.row + victim.h - blockRect.row);
235
+ if (dx === 0) return ky;
236
+ if (dy === 0) return kx;
237
+ return Math.min(kx, ky);
238
+ }
239
+
240
+ for (const v of initialOverlap) {
241
+ const k = neededKForClearing(v, targetRect);
242
+ if (!isFinite(k)) return false;
243
+ shift.set(v.id, k);
244
+ queue.push({ id: v.id, k });
245
+ }
246
+
247
+ // Helper: where will tile `id` end up given current shift map?
248
+ function shiftedRect(t: Tile): CellRect {
249
+ const k = shift.get(t.id) ?? 0;
250
+ return { col: t.col + k * dx, row: t.row + k * dy, w: t.w, h: t.h };
251
+ }
252
+
253
+ const safety = 1000;
254
+ let iters = 0;
255
+ while (queue.length > 0) {
256
+ if (iters++ > safety) return false;
257
+ const { id } = queue.shift()!;
258
+ const tile = grid.getTile(id);
259
+ if (!tile) continue;
260
+ const newRect = shiftedRect(tile);
261
+ if (!grid.rectInBounds(newRect)) return false;
262
+ // Find any non-dragger tiles that this push collides with.
263
+ for (const other of grid.tiles) {
264
+ if (other.id === id || other.id === draggerId) continue;
265
+ const otherShifted = shiftedRect(other);
266
+ if (rectsOverlap(newRect, otherShifted)) {
267
+ // Need to push `other` further along dir to clear newRect.
268
+ const additional = neededKForClearing(other, newRect);
269
+ if (!isFinite(additional)) return false;
270
+ const prevK = shift.get(other.id) ?? 0;
271
+ // The other tile is currently shifted by prevK. After moving `tile` by k,
272
+ // other must be shifted relative to its CURRENT position by `additional` more.
273
+ // But neededKForClearing is computed against `other`'s ORIGINAL position…
274
+ // we need to use its currently-shifted position. Recompute:
275
+ const movedOther: Tile = {
276
+ ...other,
277
+ col: other.col + prevK * dx,
278
+ row: other.row + prevK * dy,
279
+ };
280
+ const extra = neededKForClearing(movedOther, newRect);
281
+ if (!isFinite(extra)) return false;
282
+ const newK = prevK + extra;
283
+ if (newK <= prevK) continue; // no progress, would loop
284
+ shift.set(other.id, newK);
285
+ queue.push({ id: other.id, k: newK });
286
+ }
287
+ }
288
+ }
289
+
290
+ // Validate all final positions in bounds and pairwise non-overlapping (against
291
+ // each other and against the dragger's targetRect — which now must be free).
292
+ const finalById = new Map<string, CellRect>();
293
+ for (const t of grid.tiles) {
294
+ if (t.id === draggerId) continue;
295
+ finalById.set(t.id, shiftedRect(t));
296
+ }
297
+ for (const [id, rect] of finalById) {
298
+ if (!grid.rectInBounds(rect)) return false;
299
+ if (rectsOverlap(rect, targetRect)) return false;
300
+ for (const [otherId, otherRect] of finalById) {
301
+ if (otherId === id) continue;
302
+ if (rectsOverlap(rect, otherRect)) return false;
303
+ }
304
+ }
305
+
306
+ // Commit.
307
+ for (const [id, rect] of finalById) {
308
+ grid._setTilePos(id, { col: rect.col, row: rect.row });
309
+ }
310
+ return true;
311
+ }
312
+
313
+ /**
314
+ * Infinite-axis push chain. While anything overlaps `target`, shove every
315
+ * overlapper one cell along `dir`; any tile a shoved tile then collides with
316
+ * gets queued and shoved on the same pass. The grid grows infinitely along
317
+ * `dir`, so this always terminates with a clear target — though it may slide
318
+ * tiles arbitrarily far. Caller restores the snapshot on failure (e.g. if the
319
+ * safety limit trips).
320
+ */
321
+ function pushChainInfinite(
322
+ grid: Grid,
323
+ tileId: string,
324
+ target: CellPos,
325
+ dir: Direction8,
326
+ ): boolean {
327
+ const dragger = grid.getTile(tileId);
328
+ if (!dragger) return false;
329
+ const targetRect: CellRect = {
330
+ col: target.col,
331
+ row: target.row,
332
+ w: dragger.w,
333
+ h: dragger.h,
334
+ };
335
+ const { dx, dy } = directionStep(dir);
336
+
337
+ const safety = 10_000;
338
+ for (let iter = 0; iter < safety; iter++) {
339
+ const overlap = grid.tilesIn(targetRect, new Set([tileId]));
340
+ if (overlap.length === 0) {
341
+ grid._setTilePos(tileId, target);
342
+ return true;
343
+ }
344
+ const queue: Tile[] = overlap.map((t) => ({ ...t }));
345
+ while (queue.length > 0) {
346
+ const victim = queue.shift()!;
347
+ const cur = grid.getTile(victim.id);
348
+ if (!cur) continue;
349
+ const newRect: CellRect = {
350
+ col: cur.col + dx,
351
+ row: cur.row + dy,
352
+ w: cur.w,
353
+ h: cur.h,
354
+ };
355
+ grid._setTilePos(victim.id, { col: newRect.col, row: newRect.row });
356
+ const collides = grid.tilesIn(newRect, new Set([victim.id, tileId]));
357
+ for (const c of collides) {
358
+ if (!queue.find((q) => q.id === c.id)) queue.push(c);
359
+ }
360
+ }
361
+ }
362
+ return false;
363
+ }