@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/dist/grid.d.ts ADDED
@@ -0,0 +1,100 @@
1
+ import type { CellPos, CellRect, Footprint, GridChangeEvent, GridConfig, GridSnapshot, Tile } from './types.js';
2
+ import { Emitter } from './events.js';
3
+ export declare class Grid {
4
+ config: GridConfig;
5
+ private tilesById;
6
+ readonly changes: Emitter<GridChangeEvent>;
7
+ constructor(config: GridConfig, initialTiles?: Tile[]);
8
+ updateConfig(patch: Partial<GridConfig>): void;
9
+ get tiles(): Tile[];
10
+ getTile(id: string): Tile | undefined;
11
+ /**
12
+ * All tiles whose grid rect overlaps `rect`. Out-of-flow tiles
13
+ * (`position: 'absolute' | 'fixed'`) are skipped — they don't participate
14
+ * in collision/displacement queries even if their stored col/row happens
15
+ * to overlap.
16
+ */
17
+ tilesIn(rect: CellRect, exclude?: ReadonlySet<string>): Tile[];
18
+ rectInBounds(rect: CellRect): boolean;
19
+ addTile(tile: Tile): void;
20
+ /**
21
+ * Add a tile, displacing any overlapping tiles using the same push logic as
22
+ * `resizeTile`. Returns false (and leaves the grid unchanged) if the rect is
23
+ * out of bounds or any victim cannot be placed.
24
+ */
25
+ addTileWithDisplacement(tile: Tile): boolean;
26
+ removeTile(id: string): void;
27
+ /**
28
+ * Re-pack after a structural change (resize/add/remove) while looping,
29
+ * when opted in via `loop.repack: 'structural'`. Plain moves never repack.
30
+ */
31
+ private _structuralRepack;
32
+ _setTilePos(id: string, pos: CellPos): void;
33
+ _setTileRect(id: string, rect: CellRect): void;
34
+ /**
35
+ * Move an in-flow tile to `target`, running the rules engine (displacement,
36
+ * cascade push, BFS repack). For out-of-flow tiles (`absolute`/`fixed`) this
37
+ * is a no-op — use `setTilePinned` instead.
38
+ */
39
+ moveTile(id: string, target: CellPos): boolean;
40
+ /**
41
+ * Move a group of in-flow tiles by a uniform cell delta, preserving their
42
+ * relative positions. Non-group tiles that overlap after the shift are
43
+ * displaced using the single-tile rules engine. Returns true if the entire
44
+ * group landed successfully; on failure, the grid is left unchanged.
45
+ */
46
+ moveGroup(ids: string[], delta: {
47
+ dcol: number;
48
+ drow: number;
49
+ }): boolean;
50
+ /** Bounding rect of a group of tiles at their target positions. */
51
+ private _groupBounds;
52
+ /**
53
+ * Update `tile.pinned` for an `absolute` or `fixed` tile. Returns false if
54
+ * the tile is missing or is in flow (use `moveTile` for those). Does NOT
55
+ * trigger the rules engine — pinned coords are independent of grid layout.
56
+ */
57
+ setTilePinned(id: string, pinned: {
58
+ x: number;
59
+ y: number;
60
+ }): boolean;
61
+ /**
62
+ * Change a tile's CSS-like positioning mode. When transitioning into
63
+ * `absolute`/`fixed` you can pass a `pinned` start position; switching back
64
+ * to `static`/`relative`/`sticky` keeps the tile's existing col/row so it
65
+ * snaps back into the grid where it logically belongs.
66
+ */
67
+ setTilePosition(id: string, position: NonNullable<Tile['position']>, opts?: {
68
+ pinned?: {
69
+ x: number;
70
+ y: number;
71
+ };
72
+ offset?: {
73
+ x: number;
74
+ y: number;
75
+ };
76
+ sticky?: NonNullable<Tile['sticky']>;
77
+ }): boolean;
78
+ resizeTile(id: string, size: Footprint): boolean;
79
+ snapshotTiles(): Map<string, Tile>;
80
+ restoreTiles(snap: Map<string, Tile>): void;
81
+ compactAll(): void;
82
+ /**
83
+ * Repack all in-flow tiles into a dense block (no holes when a hole-free
84
+ * tiling exists — exact bounded search, then a gap-minimizing greedy
85
+ * fallback; see packing.ts). A layout that is already perfectly dense is
86
+ * left exactly as the user arranged it (translated to the origin if
87
+ * offset). Out-of-flow tiles are untouched. Returns true if any tile moved.
88
+ */
89
+ pack(): boolean;
90
+ toJSON(): GridSnapshot;
91
+ /**
92
+ * Replace this grid's config and tiles with a snapshot, in bulk. Unlike
93
+ * add/remove/resize this has no side effects (no gravity compaction, no
94
+ * structural repack, no pack-on-toggle) — the stored layout is restored
95
+ * verbatim. Emits a single 'load' event.
96
+ */
97
+ loadJSON(snap: GridSnapshot): void;
98
+ static fromJSON(snap: GridSnapshot): Grid;
99
+ }
100
+ //# sourceMappingURL=grid.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"grid.d.ts","sourceRoot":"","sources":["../src/grid.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACV,OAAO,EACP,QAAQ,EACR,SAAS,EACT,eAAe,EACf,UAAU,EACV,YAAY,EACZ,IAAI,EACL,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAuDtC,qBAAa,IAAI;IACf,MAAM,EAAE,UAAU,CAAC;IACnB,OAAO,CAAC,SAAS,CAA2B;IAC5C,QAAQ,CAAC,OAAO,2BAAkC;gBAEtC,MAAM,EAAE,UAAU,EAAE,YAAY,GAAE,IAAI,EAAO;IAWzD,YAAY,CAAC,KAAK,EAAE,OAAO,CAAC,UAAU,CAAC,GAAG,IAAI;IAa9C,IAAI,KAAK,IAAI,IAAI,EAAE,CAElB;IAED,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS;IAIrC;;;;;OAKG;IACH,OAAO,CAAC,IAAI,EAAE,QAAQ,EAAE,OAAO,GAAE,WAAW,CAAC,MAAM,CAAa,GAAG,IAAI,EAAE;IAUzE,YAAY,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO;IAiBrC,OAAO,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI;IAYzB;;;;OAIG;IACH,uBAAuB,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO;IAoD5C,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI;IAU5B;;;OAGG;IACH,OAAO,CAAC,iBAAiB;IAMzB,WAAW,CAAC,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,GAAG,IAAI;IAO3C,YAAY,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,GAAG,IAAI;IAS9C;;;;OAIG;IACH,QAAQ,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAG,OAAO;IAe9C;;;;;OAKG;IACH,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO;IAyExE,mEAAmE;IACnE,OAAO,CAAC,YAAY;IAYpB;;;;OAIG;IACH,aAAa,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO;IASpE;;;;;OAKG;IACH,eAAe,CACb,EAAE,EAAE,MAAM,EACV,QAAQ,EAAE,WAAW,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,EACvC,IAAI,GAAE;QACJ,MAAM,CAAC,EAAE;YAAE,CAAC,EAAE,MAAM,CAAC;YAAC,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;QAClC,MAAM,CAAC,EAAE;YAAE,CAAC,EAAE,MAAM,CAAC;YAAC,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;QAClC,MAAM,CAAC,EAAE,WAAW,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC;KACjC,GACL,OAAO;IAWV,UAAU,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,GAAG,OAAO;IAuEhD,aAAa,IAAI,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC;IAMlC,YAAY,CAAC,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,GAAG,IAAI;IAO3C,UAAU,IAAI,IAAI;IAOlB;;;;;;OAMG;IACH,IAAI,IAAI,OAAO;IAoDf,MAAM,IAAI,YAAY;IAQtB;;;;;OAKG;IACH,QAAQ,CAAC,IAAI,EAAE,YAAY,GAAG,IAAI;IAYlC,MAAM,CAAC,QAAQ,CAAC,IAAI,EAAE,YAAY,GAAG,IAAI;CAQ1C"}
package/dist/grid.js ADDED
@@ -0,0 +1,539 @@
1
+ // Grid: the central state holder. Holds config + tiles and exposes a mutation API.
2
+ // All geometry/movement/compaction logic lives in sibling modules; Grid just orchestrates.
3
+ import { Emitter } from './events.js';
4
+ import { directionStep, priorityDirections, rectsOverlap, tileRect, } from './geometry.js';
5
+ import { moveTile as moveEngine } from './movement.js';
6
+ import { compact as compactEngine } from './compaction.js';
7
+ import { isInFlow } from './positioning.js';
8
+ import { assertLoopable, loopEnabled } from './loop.js';
9
+ import { computePack } from './packing.js';
10
+ function defaultConfig(c) {
11
+ return {
12
+ infiniteX: c.infiniteX ?? c.cols === Infinity,
13
+ infiniteY: c.infiniteY ?? c.rows === Infinity,
14
+ gap: c.gap ?? 0,
15
+ gravity: c.gravity ?? 'none',
16
+ resizeHandles: c.resizeHandles ?? ['se'],
17
+ snapDuringDrag: c.snapDuringDrag ?? true,
18
+ maxRepackHops: c.maxRepackHops ?? 64,
19
+ ...c,
20
+ };
21
+ }
22
+ function findNearestFreeCell(grid, victim, avoid, selfId) {
23
+ const visited = new Set();
24
+ const queue = [{ col: victim.col, row: victim.row }];
25
+ let iters = 0;
26
+ while (queue.length > 0 && iters++ < 512) {
27
+ const p = queue.shift();
28
+ const key = `${p.col},${p.row}`;
29
+ if (visited.has(key))
30
+ continue;
31
+ visited.add(key);
32
+ const rect = { col: p.col, row: p.row, w: victim.w, h: victim.h };
33
+ if (grid.rectInBounds(rect)) {
34
+ const hits = grid.tilesIn(rect, new Set([victim.id, selfId]));
35
+ if (hits.length === 0 && !rectsOverlap(rect, avoid)) {
36
+ return p;
37
+ }
38
+ }
39
+ queue.push({ col: p.col + 1, row: p.row });
40
+ queue.push({ col: p.col - 1, row: p.row });
41
+ queue.push({ col: p.col, row: p.row + 1 });
42
+ queue.push({ col: p.col, row: p.row - 1 });
43
+ }
44
+ return null;
45
+ }
46
+ export class Grid {
47
+ constructor(config, initialTiles = []) {
48
+ this.tilesById = new Map();
49
+ this.changes = new Emitter();
50
+ this.config = defaultConfig(config);
51
+ assertLoopable(this.config);
52
+ for (const t of initialTiles)
53
+ this.tilesById.set(t.id, { ...t });
54
+ // Note: no auto-pack here even when loop starts enabled — packing fires
55
+ // only on the loop off->on toggle (see updateConfig). Constructing or
56
+ // loading a snapshot respects the stored layout as-is.
57
+ }
58
+ // ---- config -------------------------------------------------------------
59
+ updateConfig(patch) {
60
+ const wasLooping = loopEnabled(this.config);
61
+ const next = defaultConfig({ ...this.config, ...patch });
62
+ assertLoopable(next);
63
+ this.config = next;
64
+ this.changes.emit({ type: 'config', tileIds: [] });
65
+ // Loop repeats the content's bounding box; holes inside it would repeat
66
+ // too, so entering loop mode compacts the layout into a dense block.
67
+ if (!wasLooping && loopEnabled(next))
68
+ this.pack();
69
+ }
70
+ // ---- tile queries -------------------------------------------------------
71
+ get tiles() {
72
+ return Array.from(this.tilesById.values());
73
+ }
74
+ getTile(id) {
75
+ return this.tilesById.get(id);
76
+ }
77
+ /**
78
+ * All tiles whose grid rect overlaps `rect`. Out-of-flow tiles
79
+ * (`position: 'absolute' | 'fixed'`) are skipped — they don't participate
80
+ * in collision/displacement queries even if their stored col/row happens
81
+ * to overlap.
82
+ */
83
+ tilesIn(rect, exclude = new Set()) {
84
+ const out = [];
85
+ for (const t of this.tilesById.values()) {
86
+ if (exclude.has(t.id))
87
+ continue;
88
+ if (!isInFlow(t))
89
+ continue;
90
+ if (rectsOverlap(rect, tileRect(t)))
91
+ out.push(t);
92
+ }
93
+ return out;
94
+ }
95
+ rectInBounds(rect) {
96
+ const { cols, rows, infiniteX, infiniteY } = this.config;
97
+ if (!infiniteX) {
98
+ if (rect.col < 0 || rect.col + rect.w > cols)
99
+ return false;
100
+ }
101
+ else {
102
+ if (rect.col < 0)
103
+ return false;
104
+ }
105
+ if (!infiniteY) {
106
+ if (rect.row < 0 || rect.row + rect.h > rows)
107
+ return false;
108
+ }
109
+ else {
110
+ if (rect.row < 0)
111
+ return false;
112
+ }
113
+ return true;
114
+ }
115
+ // ---- mutations ---------------------------------------------------------
116
+ addTile(tile) {
117
+ if (this.tilesById.has(tile.id)) {
118
+ throw new Error(`Griddle: duplicate tile id "${tile.id}"`);
119
+ }
120
+ this.tilesById.set(tile.id, { ...tile });
121
+ this.changes.emit({ type: 'add', tileIds: [tile.id] });
122
+ if (this.config.gravity && this.config.gravity !== 'none') {
123
+ this.compactAll();
124
+ }
125
+ this._structuralRepack();
126
+ }
127
+ /**
128
+ * Add a tile, displacing any overlapping tiles using the same push logic as
129
+ * `resizeTile`. Returns false (and leaves the grid unchanged) if the rect is
130
+ * out of bounds or any victim cannot be placed.
131
+ */
132
+ addTileWithDisplacement(tile) {
133
+ if (this.tilesById.has(tile.id)) {
134
+ throw new Error(`Griddle: duplicate tile id "${tile.id}"`);
135
+ }
136
+ const newRect = { col: tile.col, row: tile.row, w: tile.w, h: tile.h };
137
+ if (!this.rectInBounds(newRect))
138
+ return false;
139
+ const snapshot = this.snapshotTiles();
140
+ this.tilesById.set(tile.id, { ...tile });
141
+ const overlapping = this.tilesIn(newRect, new Set([tile.id]));
142
+ for (const victim of overlapping) {
143
+ const pDirs = priorityDirections(newRect, tileRect(victim));
144
+ const steps = pDirs.map(directionStep);
145
+ let placed = false;
146
+ for (const step of steps) {
147
+ if (step.dx === 0 && step.dy === 0)
148
+ continue;
149
+ let cursor = { col: victim.col, row: victim.row };
150
+ for (let k = 0; k < 128 && !placed; k++) {
151
+ cursor = { col: cursor.col + step.dx, row: cursor.row + step.dy };
152
+ const rect = { col: cursor.col, row: cursor.row, w: victim.w, h: victim.h };
153
+ if (!this.rectInBounds(rect))
154
+ break;
155
+ const blocked = this.tilesIn(rect, new Set([victim.id, tile.id]));
156
+ if (blocked.length === 0 && !rectsOverlap(rect, newRect)) {
157
+ this._setTilePos(victim.id, cursor);
158
+ placed = true;
159
+ }
160
+ }
161
+ if (placed)
162
+ break;
163
+ }
164
+ if (!placed) {
165
+ const fallback = findNearestFreeCell(this, victim, newRect, tile.id);
166
+ if (fallback) {
167
+ this._setTilePos(victim.id, fallback);
168
+ placed = true;
169
+ }
170
+ }
171
+ if (!placed) {
172
+ this.restoreTiles(snapshot);
173
+ return false;
174
+ }
175
+ }
176
+ this.changes.emit({ type: 'add', tileIds: [tile.id] });
177
+ if (this.config.gravity && this.config.gravity !== 'none')
178
+ this.compactAll();
179
+ this._structuralRepack();
180
+ return true;
181
+ }
182
+ removeTile(id) {
183
+ if (!this.tilesById.has(id))
184
+ return;
185
+ this.tilesById.delete(id);
186
+ this.changes.emit({ type: 'remove', tileIds: [id] });
187
+ if (this.config.gravity && this.config.gravity !== 'none') {
188
+ this.compactAll();
189
+ }
190
+ this._structuralRepack();
191
+ }
192
+ /**
193
+ * Re-pack after a structural change (resize/add/remove) while looping,
194
+ * when opted in via `loop.repack: 'structural'`. Plain moves never repack.
195
+ */
196
+ _structuralRepack() {
197
+ if (!loopEnabled(this.config))
198
+ return;
199
+ if ((this.config.loop?.repack ?? 'toggle') !== 'structural')
200
+ return;
201
+ this.pack();
202
+ }
203
+ _setTilePos(id, pos) {
204
+ const t = this.tilesById.get(id);
205
+ if (!t)
206
+ return;
207
+ t.col = pos.col;
208
+ t.row = pos.row;
209
+ }
210
+ _setTileRect(id, rect) {
211
+ const t = this.tilesById.get(id);
212
+ if (!t)
213
+ return;
214
+ t.col = rect.col;
215
+ t.row = rect.row;
216
+ t.w = rect.w;
217
+ t.h = rect.h;
218
+ }
219
+ /**
220
+ * Move an in-flow tile to `target`, running the rules engine (displacement,
221
+ * cascade push, BFS repack). For out-of-flow tiles (`absolute`/`fixed`) this
222
+ * is a no-op — use `setTilePinned` instead.
223
+ */
224
+ moveTile(id, target) {
225
+ const tile = this.tilesById.get(id);
226
+ if (!tile)
227
+ return false;
228
+ if (!isInFlow(tile))
229
+ return false;
230
+ if (tile.col === target.col && tile.row === target.row)
231
+ return true;
232
+ const ok = moveEngine(this, id, target);
233
+ if (ok) {
234
+ this.changes.emit({ type: 'move', tileIds: this.tiles.map((t) => t.id) });
235
+ if (this.config.gravity && this.config.gravity !== 'none') {
236
+ this.compactAll();
237
+ }
238
+ }
239
+ return ok;
240
+ }
241
+ /**
242
+ * Move a group of in-flow tiles by a uniform cell delta, preserving their
243
+ * relative positions. Non-group tiles that overlap after the shift are
244
+ * displaced using the single-tile rules engine. Returns true if the entire
245
+ * group landed successfully; on failure, the grid is left unchanged.
246
+ */
247
+ moveGroup(ids, delta) {
248
+ if (ids.length === 0)
249
+ return true;
250
+ if (delta.dcol === 0 && delta.drow === 0)
251
+ return true;
252
+ const groupSet = new Set(ids);
253
+ const groupTiles = [];
254
+ for (const id of ids) {
255
+ const tile = this.tilesById.get(id);
256
+ if (!tile || !isInFlow(tile))
257
+ return false;
258
+ groupTiles.push(tile);
259
+ }
260
+ // Compute target positions and validate bounds.
261
+ const targets = new Map();
262
+ for (const tile of groupTiles) {
263
+ const target = {
264
+ col: tile.col + delta.dcol,
265
+ row: tile.row + delta.drow,
266
+ };
267
+ const rect = { col: target.col, row: target.row, w: tile.w, h: tile.h };
268
+ if (!this.rectInBounds(rect))
269
+ return false;
270
+ targets.set(tile.id, target);
271
+ }
272
+ const snapshot = this.snapshotTiles();
273
+ // Place all group tiles at their new positions.
274
+ for (const [id, pos] of targets) {
275
+ this._setTilePos(id, pos);
276
+ }
277
+ // Collect non-group tiles that now overlap with any group member.
278
+ const victims = new Set();
279
+ for (const tile of groupTiles) {
280
+ const target = targets.get(tile.id);
281
+ const targetRect = { col: target.col, row: target.row, w: tile.w, h: tile.h };
282
+ for (const hit of this.tilesIn(targetRect, groupSet)) {
283
+ victims.add(hit.id);
284
+ }
285
+ }
286
+ // Try to displace each victim using the single-tile movement engine
287
+ // against the combined group bounding box as origin.
288
+ const groupBounds = this._groupBounds(groupTiles, targets);
289
+ for (const victimId of victims) {
290
+ const victim = this.tilesById.get(victimId);
291
+ if (!victim)
292
+ continue;
293
+ const victimTarget = findNearestFreeCell(this, victim, groupBounds, victimId);
294
+ if (!victimTarget) {
295
+ this.restoreTiles(snapshot);
296
+ return false;
297
+ }
298
+ this._setTilePos(victimId, victimTarget);
299
+ }
300
+ // Final validation: ensure no group tile overlaps another non-group tile.
301
+ for (const tile of groupTiles) {
302
+ const pos = targets.get(tile.id);
303
+ const rect = { col: pos.col, row: pos.row, w: tile.w, h: tile.h };
304
+ const hits = this.tilesIn(rect, groupSet);
305
+ if (hits.length > 0) {
306
+ this.restoreTiles(snapshot);
307
+ return false;
308
+ }
309
+ }
310
+ this.changes.emit({ type: 'move', tileIds: this.tiles.map((t) => t.id) });
311
+ if (this.config.gravity && this.config.gravity !== 'none') {
312
+ this.compactAll();
313
+ }
314
+ return true;
315
+ }
316
+ /** Bounding rect of a group of tiles at their target positions. */
317
+ _groupBounds(tiles, targets) {
318
+ let minCol = Infinity, minRow = Infinity, maxCol = -Infinity, maxRow = -Infinity;
319
+ for (const tile of tiles) {
320
+ const pos = targets.get(tile.id) ?? { col: tile.col, row: tile.row };
321
+ minCol = Math.min(minCol, pos.col);
322
+ minRow = Math.min(minRow, pos.row);
323
+ maxCol = Math.max(maxCol, pos.col + tile.w);
324
+ maxRow = Math.max(maxRow, pos.row + tile.h);
325
+ }
326
+ return { col: minCol, row: minRow, w: maxCol - minCol, h: maxRow - minRow };
327
+ }
328
+ /**
329
+ * Update `tile.pinned` for an `absolute` or `fixed` tile. Returns false if
330
+ * the tile is missing or is in flow (use `moveTile` for those). Does NOT
331
+ * trigger the rules engine — pinned coords are independent of grid layout.
332
+ */
333
+ setTilePinned(id, pinned) {
334
+ const tile = this.tilesById.get(id);
335
+ if (!tile)
336
+ return false;
337
+ if (isInFlow(tile))
338
+ return false;
339
+ tile.pinned = { x: pinned.x, y: pinned.y };
340
+ this.changes.emit({ type: 'move', tileIds: [id] });
341
+ return true;
342
+ }
343
+ /**
344
+ * Change a tile's CSS-like positioning mode. When transitioning into
345
+ * `absolute`/`fixed` you can pass a `pinned` start position; switching back
346
+ * to `static`/`relative`/`sticky` keeps the tile's existing col/row so it
347
+ * snaps back into the grid where it logically belongs.
348
+ */
349
+ setTilePosition(id, position, opts = {}) {
350
+ const tile = this.tilesById.get(id);
351
+ if (!tile)
352
+ return false;
353
+ tile.position = position;
354
+ if (opts.pinned !== undefined)
355
+ tile.pinned = { ...opts.pinned };
356
+ if (opts.offset !== undefined)
357
+ tile.offset = { ...opts.offset };
358
+ if (opts.sticky !== undefined)
359
+ tile.sticky = { ...opts.sticky };
360
+ this.changes.emit({ type: 'move', tileIds: [id] });
361
+ return true;
362
+ }
363
+ resizeTile(id, size) {
364
+ const tile = this.tilesById.get(id);
365
+ if (!tile)
366
+ return false;
367
+ const clamped = {
368
+ w: Math.max(tile.minW ?? 1, Math.min(tile.maxW ?? 1000000, Math.floor(size.w))),
369
+ h: Math.max(tile.minH ?? 1, Math.min(tile.maxH ?? 1000000, Math.floor(size.h))),
370
+ };
371
+ if (clamped.w === tile.w && clamped.h === tile.h)
372
+ return true;
373
+ const newRect = {
374
+ col: tile.col,
375
+ row: tile.row,
376
+ w: clamped.w,
377
+ h: clamped.h,
378
+ };
379
+ if (!this.rectInBounds(newRect))
380
+ return false;
381
+ const snapshot = this.snapshotTiles();
382
+ tile.w = clamped.w;
383
+ tile.h = clamped.h;
384
+ const overlapping = this.tilesIn(newRect, new Set([id]));
385
+ for (const victim of overlapping) {
386
+ const priorityDirs = priorityDirections(newRect, tileRect(victim));
387
+ const steps = priorityDirs.map(directionStep);
388
+ let placed = false;
389
+ // Walk each priority direction outward; the first legal placement wins.
390
+ for (const step of steps) {
391
+ if (step.dx === 0 && step.dy === 0)
392
+ continue;
393
+ let cursor = { col: victim.col, row: victim.row };
394
+ for (let k = 0; k < 128 && !placed; k++) {
395
+ cursor = { col: cursor.col + step.dx, row: cursor.row + step.dy };
396
+ const rect = {
397
+ col: cursor.col,
398
+ row: cursor.row,
399
+ w: victim.w,
400
+ h: victim.h,
401
+ };
402
+ if (!this.rectInBounds(rect))
403
+ break;
404
+ const blocked = this.tilesIn(rect, new Set([victim.id, id]));
405
+ if (blocked.length === 0 && !rectsOverlap(rect, newRect)) {
406
+ this._setTilePos(victim.id, cursor);
407
+ placed = true;
408
+ }
409
+ }
410
+ if (placed)
411
+ break;
412
+ }
413
+ if (!placed) {
414
+ const fallback = findNearestFreeCell(this, victim, newRect, id);
415
+ if (fallback) {
416
+ this._setTilePos(victim.id, fallback);
417
+ placed = true;
418
+ }
419
+ }
420
+ if (!placed) {
421
+ this.restoreTiles(snapshot);
422
+ return false;
423
+ }
424
+ }
425
+ this.changes.emit({ type: 'resize', tileIds: [id] });
426
+ if (this.config.gravity && this.config.gravity !== 'none')
427
+ this.compactAll();
428
+ this._structuralRepack();
429
+ return true;
430
+ }
431
+ // ---- snapshots ---------------------------------------------------------
432
+ snapshotTiles() {
433
+ const m = new Map();
434
+ for (const [id, t] of this.tilesById)
435
+ m.set(id, { ...t });
436
+ return m;
437
+ }
438
+ restoreTiles(snap) {
439
+ this.tilesById.clear();
440
+ for (const [id, t] of snap)
441
+ this.tilesById.set(id, { ...t });
442
+ }
443
+ // ---- compaction --------------------------------------------------------
444
+ compactAll() {
445
+ const moved = compactEngine(this);
446
+ if (moved.length > 0) {
447
+ this.changes.emit({ type: 'compact', tileIds: moved });
448
+ }
449
+ }
450
+ /**
451
+ * Repack all in-flow tiles into a dense block (no holes when a hole-free
452
+ * tiling exists — exact bounded search, then a gap-minimizing greedy
453
+ * fallback; see packing.ts). A layout that is already perfectly dense is
454
+ * left exactly as the user arranged it (translated to the origin if
455
+ * offset). Out-of-flow tiles are untouched. Returns true if any tile moved.
456
+ */
457
+ pack() {
458
+ const flow = this.tiles.filter(isInFlow);
459
+ if (flow.length === 0)
460
+ return false;
461
+ const cols = Number.isFinite(this.config.cols) ? this.config.cols : 0;
462
+ if (cols <= 0)
463
+ return false;
464
+ // Already dense: keep the user's arrangement, just anchor it at (0,0).
465
+ let minCol = Infinity, minRow = Infinity, maxCol = -Infinity, maxRow = -Infinity;
466
+ let area = 0;
467
+ for (const t of flow) {
468
+ minCol = Math.min(minCol, t.col);
469
+ minRow = Math.min(minRow, t.row);
470
+ maxCol = Math.max(maxCol, t.col + t.w);
471
+ maxRow = Math.max(maxRow, t.row + t.h);
472
+ area += t.w * t.h;
473
+ }
474
+ if ((maxCol - minCol) * (maxRow - minRow) === area) {
475
+ if (minCol === 0 && minRow === 0)
476
+ return false;
477
+ const moved = [];
478
+ for (const t of flow) {
479
+ this._setTilePos(t.id, { col: t.col - minCol, row: t.row - minRow });
480
+ moved.push(t.id);
481
+ }
482
+ this.changes.emit({ type: 'compact', tileIds: moved });
483
+ return true;
484
+ }
485
+ const result = computePack(flow.map((t) => ({ id: t.id, w: Math.min(t.w, cols), h: t.h })), cols);
486
+ if (!result)
487
+ return false;
488
+ const moved = [];
489
+ for (const t of flow) {
490
+ const p = result.placements.get(t.id);
491
+ if (!p)
492
+ continue;
493
+ const w = Math.min(t.w, cols);
494
+ if (p.col !== t.col || p.row !== t.row || w !== t.w) {
495
+ this._setTileRect(t.id, { col: p.col, row: p.row, w, h: t.h });
496
+ moved.push(t.id);
497
+ }
498
+ }
499
+ if (moved.length > 0) {
500
+ this.changes.emit({ type: 'compact', tileIds: moved });
501
+ }
502
+ return moved.length > 0;
503
+ }
504
+ // ---- serialization ----------------------------------------------------
505
+ toJSON() {
506
+ return {
507
+ version: 1,
508
+ config: { ...this.config },
509
+ tiles: this.tiles.map((t) => ({ ...t })),
510
+ };
511
+ }
512
+ /**
513
+ * Replace this grid's config and tiles with a snapshot, in bulk. Unlike
514
+ * add/remove/resize this has no side effects (no gravity compaction, no
515
+ * structural repack, no pack-on-toggle) — the stored layout is restored
516
+ * verbatim. Emits a single 'load' event.
517
+ */
518
+ loadJSON(snap) {
519
+ if (snap.version !== 1) {
520
+ throw new Error(`Griddle: unsupported snapshot version ${snap.version}`);
521
+ }
522
+ const next = defaultConfig(snap.config);
523
+ assertLoopable(next);
524
+ this.config = next;
525
+ this.tilesById.clear();
526
+ for (const t of snap.tiles)
527
+ this.tilesById.set(t.id, { ...t });
528
+ this.changes.emit({ type: 'load', tileIds: this.tiles.map((t) => t.id) });
529
+ }
530
+ static fromJSON(snap) {
531
+ if (snap.version !== 1) {
532
+ throw new Error(`Griddle: unsupported snapshot version ${snap.version}`);
533
+ }
534
+ const g = new Grid(snap.config, snap.tiles);
535
+ g.changes.emit({ type: 'load', tileIds: g.tiles.map((t) => t.id) });
536
+ return g;
537
+ }
538
+ }
539
+ //# sourceMappingURL=grid.js.map