@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.
- package/LICENSE +21 -0
- package/README.md +54 -0
- package/dist/compaction.d.ts +3 -0
- package/dist/compaction.d.ts.map +1 -0
- package/dist/compaction.js +85 -0
- package/dist/compaction.js.map +1 -0
- package/dist/drag.d.ts +52 -0
- package/dist/drag.d.ts.map +1 -0
- package/dist/drag.js +120 -0
- package/dist/drag.js.map +1 -0
- package/dist/events.d.ts +9 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +22 -0
- package/dist/events.js.map +1 -0
- package/dist/geometry.d.ts +33 -0
- package/dist/geometry.d.ts.map +1 -0
- package/dist/geometry.js +164 -0
- package/dist/geometry.js.map +1 -0
- package/dist/grid.d.ts +100 -0
- package/dist/grid.d.ts.map +1 -0
- package/dist/grid.js +539 -0
- package/dist/grid.js.map +1 -0
- package/dist/group-drag.d.ts +41 -0
- package/dist/group-drag.d.ts.map +1 -0
- package/dist/group-drag.js +97 -0
- package/dist/group-drag.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/loop.d.ts +99 -0
- package/dist/loop.d.ts.map +1 -0
- package/dist/loop.js +188 -0
- package/dist/loop.js.map +1 -0
- package/dist/movement.d.ts +17 -0
- package/dist/movement.d.ts.map +1 -0
- package/dist/movement.js +333 -0
- package/dist/movement.js.map +1 -0
- package/dist/packing.d.ts +16 -0
- package/dist/packing.d.ts.map +1 -0
- package/dist/packing.js +159 -0
- package/dist/packing.js.map +1 -0
- package/dist/pan.d.ts +58 -0
- package/dist/pan.d.ts.map +1 -0
- package/dist/pan.js +174 -0
- package/dist/pan.js.map +1 -0
- package/dist/positioning.d.ts +117 -0
- package/dist/positioning.d.ts.map +1 -0
- package/dist/positioning.js +251 -0
- package/dist/positioning.js.map +1 -0
- package/dist/repack.d.ts +4 -0
- package/dist/repack.d.ts.map +1 -0
- package/dist/repack.js +179 -0
- package/dist/repack.js.map +1 -0
- package/dist/types.d.ts +236 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -0
- package/dist/virtualize.d.ts +27 -0
- package/dist/virtualize.d.ts.map +1 -0
- package/dist/virtualize.js +53 -0
- package/dist/virtualize.js.map +1 -0
- package/package.json +55 -0
- package/src/compaction.ts +80 -0
- package/src/drag.ts +146 -0
- package/src/events.ts +25 -0
- package/src/geometry.ts +199 -0
- package/src/grid.ts +578 -0
- package/src/group-drag.ts +120 -0
- package/src/index.ts +78 -0
- package/src/loop.ts +246 -0
- package/src/movement.ts +363 -0
- package/src/packing.ts +169 -0
- package/src/pan.ts +217 -0
- package/src/positioning.ts +292 -0
- package/src/repack.ts +212 -0
- package/src/types.ts +262 -0
- package/src/virtualize.ts +77 -0
package/src/repack.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// 0-1 BFS repack solver.
|
|
2
|
+
//
|
|
3
|
+
// Called when Rules 1-5 fail and the grid has no infinite axis available for
|
|
4
|
+
// the Rule-6 push direction. We search for a sequence of single-cell tile moves
|
|
5
|
+
// that frees `target` for the dragged tile, preferring sequences with the fewest
|
|
6
|
+
// total displacements (0-1 BFS: an edge that moves a tile into a previously-empty
|
|
7
|
+
// cell costs 0, an edge that displaces another tile costs 1).
|
|
8
|
+
//
|
|
9
|
+
// This is bounded by `config.maxRepackHops` — if we can't solve within budget,
|
|
10
|
+
// the move is rejected.
|
|
11
|
+
//
|
|
12
|
+
// Search state is the full positions-map of all tiles. To keep it manageable we:
|
|
13
|
+
// - only move tiles that are in the "affected set" (tiles we've had to touch)
|
|
14
|
+
// - limit move-candidate directions per tile to the 8 priority directions
|
|
15
|
+
// - use a stringified key of the positions of the affected set for visited checks.
|
|
16
|
+
|
|
17
|
+
import type { CellPos, CellRect, Direction8, Tile } from './types.js';
|
|
18
|
+
import type { Grid } from './grid.js';
|
|
19
|
+
import {
|
|
20
|
+
directionStep,
|
|
21
|
+
priorityDirections,
|
|
22
|
+
rectsOverlap,
|
|
23
|
+
tileRect,
|
|
24
|
+
} from './geometry.js';
|
|
25
|
+
|
|
26
|
+
interface SearchState {
|
|
27
|
+
/** Positions of every tile, keyed by id. */
|
|
28
|
+
positions: Map<string, CellPos>;
|
|
29
|
+
/** Tiles we've displaced in the search so far. */
|
|
30
|
+
touched: Set<string>;
|
|
31
|
+
/** Accumulated cost (number of unique tiles moved). */
|
|
32
|
+
cost: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function solvePushBFS(
|
|
36
|
+
grid: Grid,
|
|
37
|
+
tileId: string,
|
|
38
|
+
target: CellPos,
|
|
39
|
+
originRect: CellRect,
|
|
40
|
+
): boolean {
|
|
41
|
+
const drag = grid.getTile(tileId);
|
|
42
|
+
if (!drag) return false;
|
|
43
|
+
|
|
44
|
+
const tilesArr = grid.tiles;
|
|
45
|
+
const byId = new Map<string, Tile>(tilesArr.map((t) => [t.id, t]));
|
|
46
|
+
const targetRect: CellRect = {
|
|
47
|
+
col: target.col,
|
|
48
|
+
row: target.row,
|
|
49
|
+
w: drag.w,
|
|
50
|
+
h: drag.h,
|
|
51
|
+
};
|
|
52
|
+
const priorityDirs = priorityDirections(originRect, targetRect);
|
|
53
|
+
const maxHops = grid.config.maxRepackHops ?? 64;
|
|
54
|
+
|
|
55
|
+
// Initial state: real grid positions.
|
|
56
|
+
const initial: SearchState = {
|
|
57
|
+
positions: new Map(
|
|
58
|
+
tilesArr.map((t) => [t.id, { col: t.col, row: t.row } as CellPos]),
|
|
59
|
+
),
|
|
60
|
+
touched: new Set(),
|
|
61
|
+
cost: 0,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const visited = new Map<string, number>(); // key -> best cost
|
|
65
|
+
|
|
66
|
+
// 0-1 BFS uses a deque: 0-cost edges push_front, 1-cost edges push_back.
|
|
67
|
+
const deque: SearchState[] = [initial];
|
|
68
|
+
|
|
69
|
+
const goalSatisfied = (s: SearchState): boolean => {
|
|
70
|
+
for (const [id, pos] of s.positions) {
|
|
71
|
+
if (id === tileId) continue;
|
|
72
|
+
const t = byId.get(id)!;
|
|
73
|
+
const rect: CellRect = { col: pos.col, row: pos.row, w: t.w, h: t.h };
|
|
74
|
+
if (rectsOverlap(rect, targetRect)) return false;
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
while (deque.length > 0) {
|
|
80
|
+
const s = deque.shift()!;
|
|
81
|
+
const key = stateKey(s.positions, s.touched);
|
|
82
|
+
const prev = visited.get(key);
|
|
83
|
+
if (prev !== undefined && prev <= s.cost) continue;
|
|
84
|
+
visited.set(key, s.cost);
|
|
85
|
+
|
|
86
|
+
if (goalSatisfied(s)) {
|
|
87
|
+
// Commit. Apply all positions back to the grid, then move the dragger.
|
|
88
|
+
for (const [id, pos] of s.positions) {
|
|
89
|
+
if (id === tileId) continue;
|
|
90
|
+
grid._setTilePos(id, pos);
|
|
91
|
+
}
|
|
92
|
+
grid._setTilePos(tileId, target);
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (s.cost >= maxHops) continue;
|
|
97
|
+
|
|
98
|
+
// Find an overlapper on targetRect — that's what we must dislodge this step.
|
|
99
|
+
let occupier: Tile | null = null;
|
|
100
|
+
for (const [id, pos] of s.positions) {
|
|
101
|
+
if (id === tileId) continue;
|
|
102
|
+
const t = byId.get(id)!;
|
|
103
|
+
const rect: CellRect = { col: pos.col, row: pos.row, w: t.w, h: t.h };
|
|
104
|
+
if (rectsOverlap(rect, targetRect)) {
|
|
105
|
+
occupier = t;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (!occupier) continue;
|
|
110
|
+
|
|
111
|
+
// Try priority directions on the occupier.
|
|
112
|
+
for (const dir of priorityDirs) {
|
|
113
|
+
const child = tryMoveInState(
|
|
114
|
+
s,
|
|
115
|
+
occupier.id,
|
|
116
|
+
dir,
|
|
117
|
+
byId,
|
|
118
|
+
grid,
|
|
119
|
+
tileId,
|
|
120
|
+
);
|
|
121
|
+
if (!child) continue;
|
|
122
|
+
const wasTouched = s.touched.has(occupier.id);
|
|
123
|
+
const edgeCost = wasTouched ? 0 : 1;
|
|
124
|
+
const newCost = s.cost + edgeCost;
|
|
125
|
+
const nextKey = stateKey(child.positions, child.touched);
|
|
126
|
+
const prevBest = visited.get(nextKey);
|
|
127
|
+
if (prevBest !== undefined && prevBest <= newCost) continue;
|
|
128
|
+
child.cost = newCost;
|
|
129
|
+
if (edgeCost === 0) deque.unshift(child);
|
|
130
|
+
else deque.push(child);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Also allow moving a *chain* overlapper — if the occupier can't be moved directly,
|
|
134
|
+
// try pushing a neighbor tile to free up its destination. Iterate all touched or
|
|
135
|
+
// occupier-adjacent tiles and try each in a priority direction.
|
|
136
|
+
const candidates = new Set<string>([occupier.id, ...s.touched]);
|
|
137
|
+
for (const candId of candidates) {
|
|
138
|
+
if (candId === tileId) continue;
|
|
139
|
+
for (const dir of priorityDirs) {
|
|
140
|
+
const child = tryMoveInState(s, candId, dir, byId, grid, tileId);
|
|
141
|
+
if (!child) continue;
|
|
142
|
+
const wasTouched = s.touched.has(candId);
|
|
143
|
+
const edgeCost = wasTouched ? 0 : 1;
|
|
144
|
+
const newCost = s.cost + edgeCost;
|
|
145
|
+
const nextKey = stateKey(child.positions, child.touched);
|
|
146
|
+
const prevBest = visited.get(nextKey);
|
|
147
|
+
if (prevBest !== undefined && prevBest <= newCost) continue;
|
|
148
|
+
child.cost = newCost;
|
|
149
|
+
if (edgeCost === 0) deque.unshift(child);
|
|
150
|
+
else deque.push(child);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Attempt to move `moveId` one cell in `dir` in the given state. Returns a new
|
|
160
|
+
* state on success or null if the move is blocked (out of bounds or would overlap
|
|
161
|
+
* a non-moveable tile). For simplicity we only allow a single tile move per step;
|
|
162
|
+
* if another tile is in the way, that becomes the next BFS node to address rather
|
|
163
|
+
* than being recursively pushed here.
|
|
164
|
+
*/
|
|
165
|
+
function tryMoveInState(
|
|
166
|
+
s: SearchState,
|
|
167
|
+
moveId: string,
|
|
168
|
+
dir: Direction8,
|
|
169
|
+
byId: Map<string, Tile>,
|
|
170
|
+
grid: Grid,
|
|
171
|
+
draggerId: string,
|
|
172
|
+
): SearchState | null {
|
|
173
|
+
const pos = s.positions.get(moveId);
|
|
174
|
+
if (!pos) return null;
|
|
175
|
+
const t = byId.get(moveId)!;
|
|
176
|
+
const { dx, dy } = directionStep(dir);
|
|
177
|
+
const newRect: CellRect = {
|
|
178
|
+
col: pos.col + dx,
|
|
179
|
+
row: pos.row + dy,
|
|
180
|
+
w: t.w,
|
|
181
|
+
h: t.h,
|
|
182
|
+
};
|
|
183
|
+
if (!grid.rectInBounds(newRect)) return null;
|
|
184
|
+
|
|
185
|
+
// Must not overlap any other tile (except self and the dragger's original spot — dragger is "floating")
|
|
186
|
+
for (const [id, p] of s.positions) {
|
|
187
|
+
if (id === moveId || id === draggerId) continue;
|
|
188
|
+
const ot = byId.get(id)!;
|
|
189
|
+
const oRect: CellRect = { col: p.col, row: p.row, w: ot.w, h: ot.h };
|
|
190
|
+
if (rectsOverlap(oRect, newRect)) return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const nextPositions = new Map(s.positions);
|
|
194
|
+
nextPositions.set(moveId, { col: newRect.col, row: newRect.row });
|
|
195
|
+
const nextTouched = new Set(s.touched);
|
|
196
|
+
nextTouched.add(moveId);
|
|
197
|
+
return {
|
|
198
|
+
positions: nextPositions,
|
|
199
|
+
touched: nextTouched,
|
|
200
|
+
cost: s.cost, // caller sets
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function stateKey(positions: Map<string, CellPos>, touched: Set<string>): string {
|
|
205
|
+
const parts: string[] = [];
|
|
206
|
+
const ids = Array.from(positions.keys()).sort();
|
|
207
|
+
for (const id of ids) {
|
|
208
|
+
const p = positions.get(id)!;
|
|
209
|
+
parts.push(`${id}:${p.col},${p.row}`);
|
|
210
|
+
}
|
|
211
|
+
return parts.join('|') + '#' + Array.from(touched).sort().join(',');
|
|
212
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// Griddle core types
|
|
2
|
+
// Zero runtime dependencies. Everything consumers touch is plain JSON-serializable.
|
|
3
|
+
|
|
4
|
+
/** A corner identifier, used for resize handles. */
|
|
5
|
+
export type Corner = 'nw' | 'ne' | 'sw' | 'se';
|
|
6
|
+
|
|
7
|
+
/** Face direction. */
|
|
8
|
+
export type Face = 'n' | 's' | 'e' | 'w';
|
|
9
|
+
|
|
10
|
+
/** Priority slot direction returned by movement priority calc. */
|
|
11
|
+
export type Direction8 = Face | Corner;
|
|
12
|
+
|
|
13
|
+
/** Compaction / gravity target. */
|
|
14
|
+
export type Gravity =
|
|
15
|
+
| 'none'
|
|
16
|
+
| 'top'
|
|
17
|
+
| 'bottom'
|
|
18
|
+
| 'left'
|
|
19
|
+
| 'right'
|
|
20
|
+
| { col: number; row: number };
|
|
21
|
+
|
|
22
|
+
/** A grid position in cells. */
|
|
23
|
+
export interface CellPos {
|
|
24
|
+
col: number;
|
|
25
|
+
row: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Footprint of a tile in cells. */
|
|
29
|
+
export interface Footprint {
|
|
30
|
+
w: number;
|
|
31
|
+
h: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* CSS-like positioning mode for a tile. Default 'static'.
|
|
36
|
+
*
|
|
37
|
+
* - static - normal grid tile; participates in displacement and compaction.
|
|
38
|
+
* - relative - keeps its grid slot; renders with a visual offset that doesn't
|
|
39
|
+
* affect layout. Other tiles still treat its grid cell as taken.
|
|
40
|
+
* - absolute - out of grid flow. Pinned at coordinates given by 'pinned'.
|
|
41
|
+
* Other tiles can occupy its col/row. Engine ignores it.
|
|
42
|
+
* - fixed - like absolute but anchored to the scrollable container's
|
|
43
|
+
* viewport, so it stays put when the grid scrolls.
|
|
44
|
+
* - sticky - in flow normally, but pins to a viewport edge once scrolled
|
|
45
|
+
* past the configured threshold.
|
|
46
|
+
*
|
|
47
|
+
* Requires GridConfig.enablePositioning = true to take effect; otherwise
|
|
48
|
+
* adapters render every tile as static.
|
|
49
|
+
*/
|
|
50
|
+
export type TilePosition =
|
|
51
|
+
| 'static'
|
|
52
|
+
| 'relative'
|
|
53
|
+
| 'absolute'
|
|
54
|
+
| 'fixed'
|
|
55
|
+
| 'sticky';
|
|
56
|
+
|
|
57
|
+
/** Edge a sticky tile pins to. */
|
|
58
|
+
export type StickyEdge = 'top' | 'bottom' | 'left' | 'right';
|
|
59
|
+
|
|
60
|
+
export interface StickyConfig {
|
|
61
|
+
/** Edge of the scroll viewport to stick to. */
|
|
62
|
+
edge: StickyEdge;
|
|
63
|
+
/** Distance from the edge in CSS pixels. Default 0. */
|
|
64
|
+
threshold?: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Tile state stored in the grid. */
|
|
68
|
+
export interface Tile extends CellPos, Footprint {
|
|
69
|
+
/** Stable, unique string id. */
|
|
70
|
+
id: string;
|
|
71
|
+
/** Arbitrary consumer payload - not interpreted by core. */
|
|
72
|
+
data?: unknown;
|
|
73
|
+
/** If set, overrides the grid-level resize handles for this tile. */
|
|
74
|
+
resizeHandles?: Corner[];
|
|
75
|
+
/** If false, the tile cannot be dragged. Defaults to true. */
|
|
76
|
+
draggable?: boolean;
|
|
77
|
+
/** If false, the tile cannot be resized. Defaults to true. */
|
|
78
|
+
resizable?: boolean;
|
|
79
|
+
/** Min/max footprint clamps for resize. */
|
|
80
|
+
minW?: number;
|
|
81
|
+
minH?: number;
|
|
82
|
+
maxW?: number;
|
|
83
|
+
maxH?: number;
|
|
84
|
+
/** CSS-like positioning mode. Defaults to 'static'. */
|
|
85
|
+
position?: TilePosition;
|
|
86
|
+
/** For absolute/fixed: pinned coordinates (units per GridConfig.pinUnits). */
|
|
87
|
+
pinned?: { x: number; y: number };
|
|
88
|
+
/** For relative: visual offset that does NOT affect layout (units per GridConfig.relativeUnits). */
|
|
89
|
+
offset?: { x: number; y: number };
|
|
90
|
+
/** For sticky: edge + pixel threshold from that edge. */
|
|
91
|
+
sticky?: StickyConfig;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Physics knobs for drag-to-pan in loop mode. All rates are per-second so
|
|
96
|
+
* behavior is frame-rate independent.
|
|
97
|
+
*/
|
|
98
|
+
export interface LoopPhysicsConfig {
|
|
99
|
+
/**
|
|
100
|
+
* Whether drag-to-pan is active. Default true. In 'edit' mode tiles capture
|
|
101
|
+
* their own pointer drags, so drag-to-pan only engages from the background.
|
|
102
|
+
*/
|
|
103
|
+
dragPan?: boolean;
|
|
104
|
+
/**
|
|
105
|
+
* Exponential decay rate of the inertia velocity after release (1/s).
|
|
106
|
+
* Higher stops sooner. Default 4.
|
|
107
|
+
*/
|
|
108
|
+
friction?: number;
|
|
109
|
+
/**
|
|
110
|
+
* Rate at which the camera eases toward its target (1/s). Higher feels
|
|
111
|
+
* stiffer / more direct. Default 12.
|
|
112
|
+
*/
|
|
113
|
+
ease?: number;
|
|
114
|
+
/** Clamp on fling velocity, in px/s. Default 6000. */
|
|
115
|
+
maxVelocity?: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Loop mode: the packed content (bounding box of the in-flow tiles) repeats
|
|
120
|
+
* infinitely in both axes ("object looping" — tiles wrap around the camera so
|
|
121
|
+
* the plane has no edges). There is no native scrolling: the viewport is
|
|
122
|
+
* overflow-hidden and wheel/drag input drives a transform camera.
|
|
123
|
+
*
|
|
124
|
+
* The loop off->on toggle (and only that toggle) packs tiles into a dense
|
|
125
|
+
* block so repeats are seamless; already-dense layouts are kept as arranged.
|
|
126
|
+
* Packing is independent of all other layout strategies — it never runs on
|
|
127
|
+
* tile moves, drags, creates, resizes, snapshot loads, or toggling loop off
|
|
128
|
+
* (the packed layout simply persists).
|
|
129
|
+
* Requires finite cols/rows; incompatible with infiniteX/infiniteY.
|
|
130
|
+
*/
|
|
131
|
+
export interface LoopConfig {
|
|
132
|
+
/** Master switch, like gravity. Default false. */
|
|
133
|
+
enabled: boolean;
|
|
134
|
+
/**
|
|
135
|
+
* - 'pan' — view-only gallery: dragging anywhere pans the camera (with the
|
|
136
|
+
* physics below); tile drag/resize/draw-create are disabled.
|
|
137
|
+
* - 'edit' — "ghost edit": the base copy of the content is an ordinary,
|
|
138
|
+
* non-wrapped grid surface with normal drag/resize semantics;
|
|
139
|
+
* the surrounding repeats render live but are non-interactive
|
|
140
|
+
* (pointer-transparent ghosts). The camera moves via wheel or
|
|
141
|
+
* by dragging anywhere outside the base copy.
|
|
142
|
+
* Default 'pan'.
|
|
143
|
+
*/
|
|
144
|
+
interaction?: 'pan' | 'edit';
|
|
145
|
+
/**
|
|
146
|
+
* How repeats are laid out relative to each other:
|
|
147
|
+
* - 'grid' — repeats aligned in both axes (default).
|
|
148
|
+
* - 'brick' — each successive repeat row shifts horizontally by
|
|
149
|
+
* `offset` x period width (running-bond brickwork).
|
|
150
|
+
* - 'drop' — each successive repeat column shifts vertically by
|
|
151
|
+
* `offset` x period height (half-drop wallpaper).
|
|
152
|
+
*/
|
|
153
|
+
pattern?: 'grid' | 'brick' | 'drop';
|
|
154
|
+
/**
|
|
155
|
+
* Shift fraction for 'brick'/'drop' patterns, 0..1 of the period (rounded
|
|
156
|
+
* to whole cells). 0.5 = classic half-brick / half-drop; other fractions
|
|
157
|
+
* produce staircase / diagonal tessellations. Default 0.5.
|
|
158
|
+
*/
|
|
159
|
+
offset?: number;
|
|
160
|
+
/**
|
|
161
|
+
* When packing runs (see Grid.pack):
|
|
162
|
+
* - 'toggle' — only on the loop off->on transition (default).
|
|
163
|
+
* - 'structural' — additionally after resize / add / remove while looping
|
|
164
|
+
* (never after plain moves, so arrangements made by
|
|
165
|
+
* dragging are not shuffled).
|
|
166
|
+
*/
|
|
167
|
+
repack?: 'toggle' | 'structural';
|
|
168
|
+
/** Pan physics tuning. Only meaningful for 'pan' interaction. */
|
|
169
|
+
physics?: LoopPhysicsConfig;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Grid configuration. */
|
|
173
|
+
export interface GridConfig {
|
|
174
|
+
/** Columns. Use Infinity for horizontally infinite canvas. */
|
|
175
|
+
cols: number;
|
|
176
|
+
/** Rows. Use Infinity for vertically infinite canvas. */
|
|
177
|
+
rows: number;
|
|
178
|
+
/** Width of one cell, in CSS pixels. */
|
|
179
|
+
unitWidth: number;
|
|
180
|
+
/** Height of one cell, in CSS pixels. */
|
|
181
|
+
unitHeight: number;
|
|
182
|
+
/** Explicit infinite flags (auto-inferred from cols/rows === Infinity otherwise). */
|
|
183
|
+
infiniteX?: boolean;
|
|
184
|
+
infiniteY?: boolean;
|
|
185
|
+
/** Optional pixel gap between cells (purely visual). */
|
|
186
|
+
gap?: number;
|
|
187
|
+
/** Compaction / gravity target. Default 'none'. */
|
|
188
|
+
gravity?: Gravity;
|
|
189
|
+
/** Default corner handles shown on tiles. Default ['se']. */
|
|
190
|
+
resizeHandles?: Corner[];
|
|
191
|
+
/** Whether the tile snaps to grid cells during drag. Default true. */
|
|
192
|
+
snapDuringDrag?: boolean;
|
|
193
|
+
/** Max hops for the 0-1 BFS repack fallback on fixed grids. Default 64. */
|
|
194
|
+
maxRepackHops?: number;
|
|
195
|
+
/**
|
|
196
|
+
* Border radius applied to each tile (and the drop indicator), in CSS
|
|
197
|
+
* pixels. Default 4. The adapter exposes this on the content container as
|
|
198
|
+
* the CSS custom property `--griddle-tile-radius`, so tile content
|
|
199
|
+
* components can read it with `var(--griddle-tile-radius)` to stay in sync.
|
|
200
|
+
*/
|
|
201
|
+
tileRadius?: number;
|
|
202
|
+
/**
|
|
203
|
+
* Master switch for CSS-like tile positioning. When true, tiles can use
|
|
204
|
+
* the 'position' field (and 'pinned' / 'offset' / 'sticky'). When false
|
|
205
|
+
* (the default), adapters render every tile in static grid flow regardless
|
|
206
|
+
* of any 'position' value set on the tile data.
|
|
207
|
+
*/
|
|
208
|
+
enablePositioning?: boolean;
|
|
209
|
+
/**
|
|
210
|
+
* Coordinate units used by 'pinned' on absolute / fixed tiles. Default
|
|
211
|
+
* 'pixels'. 'subcell' measures in cells with float precision (e.g.
|
|
212
|
+
* { x: 3.5, y: 2.25 }); 'cells' measures in whole-cell coordinates.
|
|
213
|
+
*/
|
|
214
|
+
pinUnits?: 'pixels' | 'subcell' | 'cells';
|
|
215
|
+
/**
|
|
216
|
+
* Units used by 'offset' on relative tiles. Default 'pixels'. 'subcell'
|
|
217
|
+
* measures in cells with float precision so the offset scales with cell size.
|
|
218
|
+
*/
|
|
219
|
+
relativeUnits?: 'pixels' | 'subcell';
|
|
220
|
+
/**
|
|
221
|
+
* CSS selector string passed to `Element.closest()` on the pointer-down
|
|
222
|
+
* target. If the target matches, the drag is suppressed so the element's
|
|
223
|
+
* native interaction (click, focus, text selection, etc.) works normally.
|
|
224
|
+
*
|
|
225
|
+
* Default: `'a, button, input, textarea, select, [contenteditable]'`
|
|
226
|
+
*
|
|
227
|
+
* Set to `''` to disable (entire tile surface starts a drag). Consumers
|
|
228
|
+
* can extend the default with app-specific selectors, e.g.
|
|
229
|
+
* `'a, button, input, textarea, select, [contenteditable], .my-caption'`.
|
|
230
|
+
*/
|
|
231
|
+
dragIgnoreFrom?: string;
|
|
232
|
+
/**
|
|
233
|
+
* Loop mode — the finite grid tiles repeat infinitely in both axes.
|
|
234
|
+
* Off by default. See LoopConfig.
|
|
235
|
+
*/
|
|
236
|
+
loop?: LoopConfig;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** A rectangle in cells (inclusive col,row; exclusive col+w,row+h). */
|
|
240
|
+
export interface CellRect extends CellPos, Footprint {}
|
|
241
|
+
|
|
242
|
+
/** Event payload for change notifications. */
|
|
243
|
+
export interface GridChangeEvent {
|
|
244
|
+
type:
|
|
245
|
+
| 'config'
|
|
246
|
+
| 'add'
|
|
247
|
+
| 'remove'
|
|
248
|
+
| 'move'
|
|
249
|
+
| 'resize'
|
|
250
|
+
| 'repack'
|
|
251
|
+
| 'compact'
|
|
252
|
+
| 'load';
|
|
253
|
+
/** Tile ids affected by this event (for animation diffing). */
|
|
254
|
+
tileIds: string[];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Serialized grid snapshot. */
|
|
258
|
+
export interface GridSnapshot {
|
|
259
|
+
version: 1;
|
|
260
|
+
config: GridConfig;
|
|
261
|
+
tiles: Tile[];
|
|
262
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Virtualization helpers. Given a viewport (px) and scroll offset (px), compute
|
|
2
|
+
// the range of cells that are visible plus a buffer. The adapters use this to
|
|
3
|
+
// decide which tiles to actually render.
|
|
4
|
+
|
|
5
|
+
import type { CellRect, GridConfig, Tile } from './types.js';
|
|
6
|
+
import { rectsOverlap } from './geometry.js';
|
|
7
|
+
|
|
8
|
+
export interface Viewport {
|
|
9
|
+
/** Pixel offset of the viewport relative to the grid origin (0,0 cell). */
|
|
10
|
+
scrollX: number;
|
|
11
|
+
scrollY: number;
|
|
12
|
+
/** Pixel size of the viewport. */
|
|
13
|
+
width: number;
|
|
14
|
+
height: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface VisibleRange extends CellRect {}
|
|
18
|
+
|
|
19
|
+
/** Compute the visible cell rect for a given viewport, padded by `buffer` cells. */
|
|
20
|
+
export function visibleRange(
|
|
21
|
+
config: GridConfig,
|
|
22
|
+
vp: Viewport,
|
|
23
|
+
buffer = 2,
|
|
24
|
+
): VisibleRange {
|
|
25
|
+
const colSize = config.unitWidth + (config.gap ?? 0);
|
|
26
|
+
const rowSize = config.unitHeight + (config.gap ?? 0);
|
|
27
|
+
const col0 = Math.max(0, Math.floor(vp.scrollX / colSize) - buffer);
|
|
28
|
+
const row0 = Math.max(0, Math.floor(vp.scrollY / rowSize) - buffer);
|
|
29
|
+
const col1 = Math.ceil((vp.scrollX + vp.width) / colSize) + buffer;
|
|
30
|
+
const row1 = Math.ceil((vp.scrollY + vp.height) / rowSize) + buffer;
|
|
31
|
+
|
|
32
|
+
const maxCol = config.infiniteX || config.cols === Infinity ? col1 : Math.min(col1, config.cols);
|
|
33
|
+
const maxRow = config.infiniteY || config.rows === Infinity ? row1 : Math.min(row1, config.rows);
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
col: col0,
|
|
37
|
+
row: row0,
|
|
38
|
+
w: Math.max(0, maxCol - col0),
|
|
39
|
+
h: Math.max(0, maxRow - row0),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Filter tiles to only those overlapping the visible range. Tiles that are NOT
|
|
45
|
+
* laid out by their grid cell (`absolute`, `fixed`, `sticky`) are always
|
|
46
|
+
* included — their on-screen position is independent of their `col/row`, so
|
|
47
|
+
* filtering by cell-overlap would incorrectly cull them when the user scrolls
|
|
48
|
+
* past their natural cell.
|
|
49
|
+
*/
|
|
50
|
+
export function visibleTiles(tiles: Tile[], range: VisibleRange): Tile[] {
|
|
51
|
+
return tiles.filter((t) => {
|
|
52
|
+
if (t.position && t.position !== 'static' && t.position !== 'relative') {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
return rectsOverlap({ col: t.col, row: t.row, w: t.w, h: t.h }, range);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Compute the total pixel size of the grid content (for scroll container sizing). */
|
|
60
|
+
export function gridContentSize(
|
|
61
|
+
config: GridConfig,
|
|
62
|
+
tiles: Tile[],
|
|
63
|
+
): { width: number; height: number } {
|
|
64
|
+
const gap = config.gap ?? 0;
|
|
65
|
+
const colSize = config.unitWidth + gap;
|
|
66
|
+
const rowSize = config.unitHeight + gap;
|
|
67
|
+
let maxCol = config.infiniteX || config.cols === Infinity ? 0 : config.cols;
|
|
68
|
+
let maxRow = config.infiniteY || config.rows === Infinity ? 0 : config.rows;
|
|
69
|
+
for (const t of tiles) {
|
|
70
|
+
maxCol = Math.max(maxCol, t.col + t.w + 2);
|
|
71
|
+
maxRow = Math.max(maxRow, t.row + t.h + 2);
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
width: maxCol * colSize,
|
|
75
|
+
height: maxRow * rowSize,
|
|
76
|
+
};
|
|
77
|
+
}
|