@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/packing.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// packing.ts — dense repacking for loop mode.
|
|
2
|
+
//
|
|
3
|
+
// Loop mode repeats the content's bounding box, so any hole inside it repeats
|
|
4
|
+
// too. `computePack` arranges tiles into the tightest layout it can find:
|
|
5
|
+
//
|
|
6
|
+
// 1. Exact search: for each candidate width W (widest first) whose W x H
|
|
7
|
+
// rectangle exactly matches the total tile area, run a bounded
|
|
8
|
+
// backtracking tiler (cover the topmost-leftmost empty cell at each step).
|
|
9
|
+
// If it succeeds the layout has ZERO holes.
|
|
10
|
+
// 2. Fallback: a gap-filling greedy — walk cells in reading order and drop
|
|
11
|
+
// the largest remaining tile that fits at each empty cell. Minimizes holes
|
|
12
|
+
// but cannot always reach zero (not every tile mix tiles a rectangle).
|
|
13
|
+
//
|
|
14
|
+
// Rectangle packing is NP-hard in general; the exact search is capped by a
|
|
15
|
+
// node budget so worst-case inputs degrade to the greedy instead of hanging.
|
|
16
|
+
|
|
17
|
+
import type { CellPos } from './types.js';
|
|
18
|
+
|
|
19
|
+
export interface PackTile {
|
|
20
|
+
id: string;
|
|
21
|
+
w: number;
|
|
22
|
+
h: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PackComputation {
|
|
26
|
+
placements: Map<string, CellPos>;
|
|
27
|
+
/** Bounding box of the packed layout, in cells. */
|
|
28
|
+
width: number;
|
|
29
|
+
height: number;
|
|
30
|
+
/** Empty cells left inside the bounding box (0 = perfectly dense). */
|
|
31
|
+
holes: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const EXACT_NODE_BUDGET = 50_000;
|
|
35
|
+
|
|
36
|
+
export function computePack(tiles: PackTile[], maxCols: number): PackComputation | null {
|
|
37
|
+
if (tiles.length === 0 || maxCols <= 0) return null;
|
|
38
|
+
const area = tiles.reduce((s, t) => s + t.w * t.h, 0);
|
|
39
|
+
const maxW = Math.max(...tiles.map((t) => t.w));
|
|
40
|
+
const maxH = Math.max(...tiles.map((t) => t.h));
|
|
41
|
+
|
|
42
|
+
for (let w = Math.min(maxCols, area); w >= maxW; w--) {
|
|
43
|
+
if (area % w !== 0) continue;
|
|
44
|
+
const h = area / w;
|
|
45
|
+
if (h < maxH) continue;
|
|
46
|
+
const exact = exactPack(tiles, w, h);
|
|
47
|
+
if (exact) return { placements: exact, width: w, height: h, holes: 0 };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return greedyPack(tiles, Math.min(maxCols, area));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Bounded backtracking tiler: every step covers the topmost-leftmost empty
|
|
55
|
+
* cell, trying each distinct unused tile size there. Returns null when no
|
|
56
|
+
* complete tiling is found (or the node budget runs out).
|
|
57
|
+
*/
|
|
58
|
+
function exactPack(tiles: PackTile[], w: number, h: number): Map<string, CellPos> | null {
|
|
59
|
+
const order = [...tiles].sort((a, b) => b.w * b.h - a.w * a.h || b.h - a.h);
|
|
60
|
+
const n = order.length;
|
|
61
|
+
const occ = new Uint8Array(w * h);
|
|
62
|
+
const used = new Array<boolean>(n).fill(false);
|
|
63
|
+
const placements = new Map<string, CellPos>();
|
|
64
|
+
let nodes = 0;
|
|
65
|
+
|
|
66
|
+
const fits = (c: number, r: number, tw: number, th: number): boolean => {
|
|
67
|
+
if (c + tw > w || r + th > h) return false;
|
|
68
|
+
for (let y = r; y < r + th; y++) {
|
|
69
|
+
for (let x = c; x < c + tw; x++) {
|
|
70
|
+
if (occ[y * w + x]) return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
};
|
|
75
|
+
const mark = (c: number, r: number, tw: number, th: number, v: number): void => {
|
|
76
|
+
for (let y = r; y < r + th; y++) {
|
|
77
|
+
for (let x = c; x < c + tw; x++) occ[y * w + x] = v;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const rec = (scanFrom: number): boolean => {
|
|
82
|
+
if (++nodes > EXACT_NODE_BUDGET) return false;
|
|
83
|
+
let idx = scanFrom;
|
|
84
|
+
while (idx < w * h && occ[idx]) idx++;
|
|
85
|
+
if (idx >= w * h) return true;
|
|
86
|
+
const c = idx % w;
|
|
87
|
+
const r = (idx - c) / w;
|
|
88
|
+
// Identical-size tiles are interchangeable; trying one per size is enough.
|
|
89
|
+
const triedSizes = new Set<string>();
|
|
90
|
+
for (let i = 0; i < n; i++) {
|
|
91
|
+
if (used[i]) continue;
|
|
92
|
+
const t = order[i]!;
|
|
93
|
+
const sizeKey = `${t.w}x${t.h}`;
|
|
94
|
+
if (triedSizes.has(sizeKey)) continue;
|
|
95
|
+
triedSizes.add(sizeKey);
|
|
96
|
+
if (!fits(c, r, t.w, t.h)) continue;
|
|
97
|
+
used[i] = true;
|
|
98
|
+
mark(c, r, t.w, t.h, 1);
|
|
99
|
+
placements.set(t.id, { col: c, row: r });
|
|
100
|
+
if (rec(idx + 1)) return true;
|
|
101
|
+
used[i] = false;
|
|
102
|
+
mark(c, r, t.w, t.h, 0);
|
|
103
|
+
placements.delete(t.id);
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
return rec(0) ? placements : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Gap-filling greedy: walk cells in reading order; at each empty cell place
|
|
113
|
+
* the largest remaining tile that fits, otherwise leave the cell as a hole
|
|
114
|
+
* and move on. Never widens the layout to skip a fillable gap.
|
|
115
|
+
*/
|
|
116
|
+
function greedyPack(tiles: PackTile[], w: number): PackComputation {
|
|
117
|
+
const remaining = [...tiles].sort((a, b) => b.w * b.h - a.w * a.h || b.h - a.h);
|
|
118
|
+
const placements = new Map<string, CellPos>();
|
|
119
|
+
const occ: boolean[][] = [];
|
|
120
|
+
|
|
121
|
+
const fits = (c: number, r: number, tw: number, th: number): boolean => {
|
|
122
|
+
if (c + tw > w) return false;
|
|
123
|
+
for (let y = r; y < r + th; y++) {
|
|
124
|
+
const row = occ[y];
|
|
125
|
+
if (!row) continue;
|
|
126
|
+
for (let x = c; x < c + tw; x++) {
|
|
127
|
+
if (row[x]) return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return true;
|
|
131
|
+
};
|
|
132
|
+
const mark = (c: number, r: number, tw: number, th: number): void => {
|
|
133
|
+
for (let y = r; y < r + th; y++) {
|
|
134
|
+
let row = occ[y];
|
|
135
|
+
if (!row) {
|
|
136
|
+
row = new Array<boolean>(w).fill(false);
|
|
137
|
+
occ[y] = row;
|
|
138
|
+
}
|
|
139
|
+
for (let x = c; x < c + tw; x++) row[x] = true;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
let idx = 0;
|
|
144
|
+
while (remaining.length > 0) {
|
|
145
|
+
const c = idx % w;
|
|
146
|
+
const r = (idx - c) / w;
|
|
147
|
+
if (!occ[r]?.[c]) {
|
|
148
|
+
const i = remaining.findIndex((t) => fits(c, r, t.w, t.h));
|
|
149
|
+
if (i >= 0) {
|
|
150
|
+
const t = remaining.splice(i, 1)[0]!;
|
|
151
|
+
placements.set(t.id, { col: c, row: r });
|
|
152
|
+
mark(c, r, t.w, t.h);
|
|
153
|
+
}
|
|
154
|
+
// else: unfillable hole — leave it and keep scanning.
|
|
155
|
+
}
|
|
156
|
+
idx++;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let width = 0;
|
|
160
|
+
let height = 0;
|
|
161
|
+
let area = 0;
|
|
162
|
+
for (const t of tiles) {
|
|
163
|
+
const p = placements.get(t.id)!;
|
|
164
|
+
width = Math.max(width, p.col + t.w);
|
|
165
|
+
height = Math.max(height, p.row + t.h);
|
|
166
|
+
area += t.w * t.h;
|
|
167
|
+
}
|
|
168
|
+
return { placements, width, height, holes: width * height - area };
|
|
169
|
+
}
|
package/src/pan.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// PanController — headless camera for loop mode. No DOM, no timers.
|
|
2
|
+
//
|
|
3
|
+
// The controller owns an unbounded 2D camera offset. Three inputs move it:
|
|
4
|
+
// - dragStart/dragMove/dragEnd: pointer-driven panning. While dragging, the
|
|
5
|
+
// target follows the pointer 1:1 (inverted — content follows the finger)
|
|
6
|
+
// and a velocity estimate is kept; on release the target keeps coasting
|
|
7
|
+
// under exponential friction (inertia/fling).
|
|
8
|
+
// - scrollBy: external deltas (wheel/trackpad). Applied directly to both
|
|
9
|
+
// camera and target — the browser already smooths these.
|
|
10
|
+
// - tick(now): advances easing + inertia. Adapters call this from their rAF
|
|
11
|
+
// loop and apply the camera to the plane's CSS transform.
|
|
12
|
+
//
|
|
13
|
+
// All rates are per-second (exponential, frame-rate independent).
|
|
14
|
+
|
|
15
|
+
export interface CameraState {
|
|
16
|
+
/** Unbounded camera offset, px. */
|
|
17
|
+
x: number;
|
|
18
|
+
y: number;
|
|
19
|
+
/** Velocity estimate, px/s. */
|
|
20
|
+
vx: number;
|
|
21
|
+
vy: number;
|
|
22
|
+
/** True while the camera is still easing/coasting toward its target. */
|
|
23
|
+
isMoving: boolean;
|
|
24
|
+
/** True between dragStart and dragEnd. */
|
|
25
|
+
isDragging: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PanPhysicsOptions {
|
|
29
|
+
/** Inertia velocity decay rate after release, 1/s. Default 4. */
|
|
30
|
+
friction?: number;
|
|
31
|
+
/** Camera approach rate toward the target, 1/s. Default 12. */
|
|
32
|
+
ease?: number;
|
|
33
|
+
/** Fling velocity clamp, px/s. Default 6000. */
|
|
34
|
+
maxVelocity?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface Sample {
|
|
38
|
+
t: number;
|
|
39
|
+
x: number;
|
|
40
|
+
y: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const SETTLE_DISTANCE = 0.1; // px
|
|
44
|
+
const SETTLE_VELOCITY = 5; // px/s
|
|
45
|
+
const VELOCITY_WINDOW_MS = 100;
|
|
46
|
+
|
|
47
|
+
export class PanController {
|
|
48
|
+
private friction: number;
|
|
49
|
+
private ease: number;
|
|
50
|
+
private maxVelocity: number;
|
|
51
|
+
|
|
52
|
+
private x = 0;
|
|
53
|
+
private y = 0;
|
|
54
|
+
private targetX = 0;
|
|
55
|
+
private targetY = 0;
|
|
56
|
+
private vx = 0;
|
|
57
|
+
private vy = 0;
|
|
58
|
+
|
|
59
|
+
private dragging = false;
|
|
60
|
+
private lastPointer: { x: number; y: number } | null = null;
|
|
61
|
+
private samples: Sample[] = [];
|
|
62
|
+
private lastTick: number | null = null;
|
|
63
|
+
|
|
64
|
+
constructor(physics: PanPhysicsOptions = {}) {
|
|
65
|
+
this.friction = physics.friction ?? 4;
|
|
66
|
+
this.ease = physics.ease ?? 12;
|
|
67
|
+
this.maxVelocity = physics.maxVelocity ?? 6000;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
setPhysics(physics: PanPhysicsOptions): void {
|
|
71
|
+
if (physics.friction !== undefined) this.friction = physics.friction;
|
|
72
|
+
if (physics.ease !== undefined) this.ease = physics.ease;
|
|
73
|
+
if (physics.maxVelocity !== undefined) this.maxVelocity = physics.maxVelocity;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
state(): CameraState {
|
|
77
|
+
const settled =
|
|
78
|
+
Math.abs(this.targetX - this.x) < SETTLE_DISTANCE &&
|
|
79
|
+
Math.abs(this.targetY - this.y) < SETTLE_DISTANCE &&
|
|
80
|
+
Math.abs(this.vx) < SETTLE_VELOCITY &&
|
|
81
|
+
Math.abs(this.vy) < SETTLE_VELOCITY;
|
|
82
|
+
return {
|
|
83
|
+
x: this.x,
|
|
84
|
+
y: this.y,
|
|
85
|
+
vx: this.vx,
|
|
86
|
+
vy: this.vy,
|
|
87
|
+
isMoving: this.dragging || !settled,
|
|
88
|
+
isDragging: this.dragging,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Begin a pan gesture at pointer position (px, py), in any stable pixel space. */
|
|
93
|
+
dragStart(px: number, py: number, now: number): void {
|
|
94
|
+
this.dragging = true;
|
|
95
|
+
this.lastPointer = { x: px, y: py };
|
|
96
|
+
this.samples = [{ t: now, x: px, y: py }];
|
|
97
|
+
// Grabbing the plane stops any in-flight coast.
|
|
98
|
+
this.vx = 0;
|
|
99
|
+
this.vy = 0;
|
|
100
|
+
this.targetX = this.x;
|
|
101
|
+
this.targetY = this.y;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Pointer moved during a pan gesture. */
|
|
105
|
+
dragMove(px: number, py: number, now: number): void {
|
|
106
|
+
if (!this.dragging || !this.lastPointer) return;
|
|
107
|
+
// Content follows the finger: camera moves opposite the pointer delta.
|
|
108
|
+
this.targetX -= px - this.lastPointer.x;
|
|
109
|
+
this.targetY -= py - this.lastPointer.y;
|
|
110
|
+
this.lastPointer = { x: px, y: py };
|
|
111
|
+
this.samples.push({ t: now, x: px, y: py });
|
|
112
|
+
while (this.samples.length > 2 && now - this.samples[0]!.t > VELOCITY_WINDOW_MS) {
|
|
113
|
+
this.samples.shift();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** End the pan gesture, converting recent pointer motion into a fling. */
|
|
118
|
+
dragEnd(now: number): void {
|
|
119
|
+
if (!this.dragging) return;
|
|
120
|
+
this.dragging = false;
|
|
121
|
+
this.lastPointer = null;
|
|
122
|
+
const first = this.samples[0];
|
|
123
|
+
const last = this.samples[this.samples.length - 1];
|
|
124
|
+
if (first && last && last.t > first.t) {
|
|
125
|
+
const dt = (last.t - first.t) / 1000;
|
|
126
|
+
// Pointer velocity, inverted into camera space.
|
|
127
|
+
let vx = -(last.x - first.x) / dt;
|
|
128
|
+
let vy = -(last.y - first.y) / dt;
|
|
129
|
+
const speed = Math.hypot(vx, vy);
|
|
130
|
+
if (speed > this.maxVelocity) {
|
|
131
|
+
const s = this.maxVelocity / speed;
|
|
132
|
+
vx *= s;
|
|
133
|
+
vy *= s;
|
|
134
|
+
}
|
|
135
|
+
this.vx = vx;
|
|
136
|
+
this.vy = vy;
|
|
137
|
+
}
|
|
138
|
+
this.samples = [];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Abort a drag without inertia. */
|
|
142
|
+
dragCancel(): void {
|
|
143
|
+
this.dragging = false;
|
|
144
|
+
this.lastPointer = null;
|
|
145
|
+
this.samples = [];
|
|
146
|
+
this.vx = 0;
|
|
147
|
+
this.vy = 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** External scroll delta (native scroll / wheel). Applied directly. */
|
|
151
|
+
scrollBy(dx: number, dy: number): void {
|
|
152
|
+
this.x += dx;
|
|
153
|
+
this.y += dy;
|
|
154
|
+
this.targetX += dx;
|
|
155
|
+
this.targetY += dy;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Jump the camera (and target) to an absolute offset. Kills motion. */
|
|
159
|
+
moveTo(x: number, y: number): void {
|
|
160
|
+
this.x = x;
|
|
161
|
+
this.y = y;
|
|
162
|
+
this.targetX = x;
|
|
163
|
+
this.targetY = y;
|
|
164
|
+
this.vx = 0;
|
|
165
|
+
this.vy = 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Stop all motion at the current offset. */
|
|
169
|
+
stop(): void {
|
|
170
|
+
this.targetX = this.x;
|
|
171
|
+
this.targetY = this.y;
|
|
172
|
+
this.vx = 0;
|
|
173
|
+
this.vy = 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Advance the simulation to time `now` (ms). Returns the camera state.
|
|
178
|
+
* Call once per animation frame.
|
|
179
|
+
*/
|
|
180
|
+
tick(now: number): CameraState {
|
|
181
|
+
const last = this.lastTick ?? now;
|
|
182
|
+
this.lastTick = now;
|
|
183
|
+
let dt = (now - last) / 1000;
|
|
184
|
+
if (dt <= 0) return this.state();
|
|
185
|
+
if (dt > 0.1) dt = 0.1; // clamp long gaps (tab switch) to avoid jumps
|
|
186
|
+
|
|
187
|
+
if (!this.dragging) {
|
|
188
|
+
// Inertia: the target coasts and the velocity decays exponentially.
|
|
189
|
+
const decay = Math.exp(-this.friction * dt);
|
|
190
|
+
if (Math.abs(this.vx) > SETTLE_VELOCITY || Math.abs(this.vy) > SETTLE_VELOCITY) {
|
|
191
|
+
// Integrate v over the step: x += v/friction * (1 - decay)
|
|
192
|
+
const integral = this.friction > 0 ? (1 - decay) / this.friction : dt;
|
|
193
|
+
this.targetX += this.vx * integral;
|
|
194
|
+
this.targetY += this.vy * integral;
|
|
195
|
+
this.vx *= decay;
|
|
196
|
+
this.vy *= decay;
|
|
197
|
+
} else {
|
|
198
|
+
this.vx = 0;
|
|
199
|
+
this.vy = 0;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Ease the camera toward the target (frame-rate independent lerp).
|
|
204
|
+
const k = 1 - Math.exp(-this.ease * dt);
|
|
205
|
+
this.x += (this.targetX - this.x) * k;
|
|
206
|
+
this.y += (this.targetY - this.y) * k;
|
|
207
|
+
if (
|
|
208
|
+
Math.abs(this.targetX - this.x) < SETTLE_DISTANCE &&
|
|
209
|
+
Math.abs(this.targetY - this.y) < SETTLE_DISTANCE
|
|
210
|
+
) {
|
|
211
|
+
this.x = this.targetX;
|
|
212
|
+
this.y = this.targetY;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return this.state();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// Positioning helpers — pure functions, no state.
|
|
2
|
+
//
|
|
3
|
+
// Tiles can opt out of grid flow by setting `position: 'absolute'` or `'fixed'`.
|
|
4
|
+
// Out-of-flow tiles keep their `col/row/w/h` fields (so they can fall back to
|
|
5
|
+
// in-flow if the user toggles position back to 'static') but the layout engine
|
|
6
|
+
// ignores them — they don't displace neighbors and they don't get displaced.
|
|
7
|
+
//
|
|
8
|
+
// Coordinate translation between the configured unit space (pixels / subcell /
|
|
9
|
+
// cells) and raw pixels lives here so adapters and engine share one source of
|
|
10
|
+
// truth.
|
|
11
|
+
|
|
12
|
+
import type { GridConfig, Tile } from './types.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Whether a tile participates in grid layout. `static`, `relative`, `sticky`,
|
|
16
|
+
* and tiles with no `position` set are all in flow. `absolute` and `fixed`
|
|
17
|
+
* tiles are not.
|
|
18
|
+
*/
|
|
19
|
+
export function isInFlow(tile: Tile): boolean {
|
|
20
|
+
const p = tile.position;
|
|
21
|
+
return !p || p === 'static' || p === 'relative' || p === 'sticky';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** True for tiles that the engine should ignore for collisions and displacement. */
|
|
25
|
+
export function isOutOfFlow(tile: Tile): boolean {
|
|
26
|
+
return !isInFlow(tile);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Convert `pinned` (in the configured `pinUnits`) to raw pixels. Used by
|
|
31
|
+
* adapters to compute `left/top` for absolute and fixed tiles.
|
|
32
|
+
*/
|
|
33
|
+
export function pinnedToPixels(
|
|
34
|
+
pinned: { x: number; y: number },
|
|
35
|
+
config: GridConfig,
|
|
36
|
+
): { x: number; y: number } {
|
|
37
|
+
const colSize = config.unitWidth + (config.gap ?? 0);
|
|
38
|
+
const rowSize = config.unitHeight + (config.gap ?? 0);
|
|
39
|
+
switch (config.pinUnits ?? 'pixels') {
|
|
40
|
+
case 'pixels':
|
|
41
|
+
return { x: pinned.x, y: pinned.y };
|
|
42
|
+
case 'subcell':
|
|
43
|
+
return { x: pinned.x * colSize, y: pinned.y * rowSize };
|
|
44
|
+
case 'cells':
|
|
45
|
+
return { x: Math.round(pinned.x) * colSize, y: Math.round(pinned.y) * rowSize };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Inverse of `pinnedToPixels`. Convert pixels back to the configured pin-unit space. */
|
|
50
|
+
export function pixelsToPin(
|
|
51
|
+
pixels: { x: number; y: number },
|
|
52
|
+
config: GridConfig,
|
|
53
|
+
): { x: number; y: number } {
|
|
54
|
+
const colSize = config.unitWidth + (config.gap ?? 0);
|
|
55
|
+
const rowSize = config.unitHeight + (config.gap ?? 0);
|
|
56
|
+
switch (config.pinUnits ?? 'pixels') {
|
|
57
|
+
case 'pixels':
|
|
58
|
+
return { x: pixels.x, y: pixels.y };
|
|
59
|
+
case 'subcell':
|
|
60
|
+
return { x: pixels.x / colSize, y: pixels.y / rowSize };
|
|
61
|
+
case 'cells':
|
|
62
|
+
return { x: Math.round(pixels.x / colSize), y: Math.round(pixels.y / rowSize) };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Convert `offset` (in the configured `relativeUnits`) to raw pixels. Used by
|
|
68
|
+
* adapters to compute the visual nudge for `relative` tiles.
|
|
69
|
+
*/
|
|
70
|
+
export function offsetToPixels(
|
|
71
|
+
offset: { x: number; y: number },
|
|
72
|
+
config: GridConfig,
|
|
73
|
+
): { x: number; y: number } {
|
|
74
|
+
const colSize = config.unitWidth + (config.gap ?? 0);
|
|
75
|
+
const rowSize = config.unitHeight + (config.gap ?? 0);
|
|
76
|
+
if ((config.relativeUnits ?? 'pixels') === 'pixels') {
|
|
77
|
+
return { x: offset.x, y: offset.y };
|
|
78
|
+
}
|
|
79
|
+
return { x: offset.x * colSize, y: offset.y * rowSize };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Inverse of `offsetToPixels`. */
|
|
83
|
+
export function pixelsToOffset(
|
|
84
|
+
pixels: { x: number; y: number },
|
|
85
|
+
config: GridConfig,
|
|
86
|
+
): { x: number; y: number } {
|
|
87
|
+
const colSize = config.unitWidth + (config.gap ?? 0);
|
|
88
|
+
const rowSize = config.unitHeight + (config.gap ?? 0);
|
|
89
|
+
if ((config.relativeUnits ?? 'pixels') === 'pixels') {
|
|
90
|
+
return { x: pixels.x, y: pixels.y };
|
|
91
|
+
}
|
|
92
|
+
return { x: pixels.x / colSize, y: pixels.y / rowSize };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Inputs to `computeTileLayout`. The adapter supplies live viewport/scroll
|
|
97
|
+
* values so sticky and fixed tiles can react to scrolling.
|
|
98
|
+
*/
|
|
99
|
+
export interface TileLayoutInput {
|
|
100
|
+
tile: { col: number; row: number; w: number; h: number; position?: import('./types.js').TilePosition; pinned?: { x: number; y: number }; offset?: { x: number; y: number }; sticky?: import('./types.js').StickyConfig };
|
|
101
|
+
config: import('./types.js').GridConfig;
|
|
102
|
+
scrollX: number;
|
|
103
|
+
scrollY: number;
|
|
104
|
+
viewportWidth: number;
|
|
105
|
+
viewportHeight: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface TileLayout {
|
|
109
|
+
/** Left edge in pixels relative to the scroll content origin. */
|
|
110
|
+
left: number;
|
|
111
|
+
/** Top edge in pixels relative to the scroll content origin. */
|
|
112
|
+
top: number;
|
|
113
|
+
/** Rendered width in pixels (derived from w + cellSize + gap). */
|
|
114
|
+
width: number;
|
|
115
|
+
/** Rendered height in pixels. */
|
|
116
|
+
height: number;
|
|
117
|
+
/** Optional CSS transform — currently used for `relative` offsets. */
|
|
118
|
+
transform?: string;
|
|
119
|
+
/** Stacking hint. Static tiles 1, sticky 40, absolute 30, fixed 50. */
|
|
120
|
+
zIndex: number;
|
|
121
|
+
/**
|
|
122
|
+
* The effective position mode that was applied (after honoring
|
|
123
|
+
* `enablePositioning`). Useful for adapters that want to add classes.
|
|
124
|
+
*/
|
|
125
|
+
effective: 'static' | 'relative' | 'absolute' | 'fixed' | 'sticky';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Compute where a tile should render in the scroll content, given current
|
|
130
|
+
* scroll/viewport state. Encapsulates the math for relative offsets, absolute
|
|
131
|
+
* pinned coords (in the configured pinUnits), fixed (anchored to the
|
|
132
|
+
* scrollable viewport via `pinned + scroll` math), and sticky (pins at an
|
|
133
|
+
* edge once scrolled past `threshold`). When `GridConfig.enablePositioning`
|
|
134
|
+
* is false, every tile is treated as `static`.
|
|
135
|
+
*/
|
|
136
|
+
export function computeTileLayout(input: TileLayoutInput): TileLayout {
|
|
137
|
+
const { tile, config, scrollX, scrollY, viewportWidth, viewportHeight } = input;
|
|
138
|
+
const gap = config.gap ?? 0;
|
|
139
|
+
const halfGap = gap / 2;
|
|
140
|
+
const colSize = config.unitWidth + gap;
|
|
141
|
+
const rowSize = config.unitHeight + gap;
|
|
142
|
+
const width = tile.w * config.unitWidth + (tile.w - 1) * gap;
|
|
143
|
+
const height = tile.h * config.unitHeight + (tile.h - 1) * gap;
|
|
144
|
+
const enabled = config.enablePositioning ?? false;
|
|
145
|
+
const pos = enabled ? (tile.position ?? 'static') : 'static';
|
|
146
|
+
|
|
147
|
+
if (pos === 'absolute') {
|
|
148
|
+
const px = pinnedToPixels(tile.pinned ?? { x: 0, y: 0 }, config);
|
|
149
|
+
return { left: px.x, top: px.y, width, height, zIndex: 30, effective: 'absolute' };
|
|
150
|
+
}
|
|
151
|
+
if (pos === 'fixed') {
|
|
152
|
+
const px = pinnedToPixels(tile.pinned ?? { x: 0, y: 0 }, config);
|
|
153
|
+
return {
|
|
154
|
+
left: px.x + scrollX,
|
|
155
|
+
top: px.y + scrollY,
|
|
156
|
+
width,
|
|
157
|
+
height,
|
|
158
|
+
zIndex: 50,
|
|
159
|
+
effective: 'fixed',
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
if (pos === 'sticky') {
|
|
163
|
+
const sticky = tile.sticky ?? { edge: 'top', threshold: 0 };
|
|
164
|
+
const threshold = sticky.threshold ?? 0;
|
|
165
|
+
let left = tile.col * colSize + halfGap;
|
|
166
|
+
let top = tile.row * rowSize + halfGap;
|
|
167
|
+
if (sticky.edge === 'top') {
|
|
168
|
+
const stickyTop = scrollY + threshold;
|
|
169
|
+
if (top < stickyTop) top = stickyTop;
|
|
170
|
+
} else if (sticky.edge === 'bottom') {
|
|
171
|
+
const stickyBottom = scrollY + viewportHeight - height - threshold;
|
|
172
|
+
if (top > stickyBottom) top = stickyBottom;
|
|
173
|
+
} else if (sticky.edge === 'left') {
|
|
174
|
+
const stickyLeft = scrollX + threshold;
|
|
175
|
+
if (left < stickyLeft) left = stickyLeft;
|
|
176
|
+
} else if (sticky.edge === 'right') {
|
|
177
|
+
const stickyRight = scrollX + viewportWidth - width - threshold;
|
|
178
|
+
if (left > stickyRight) left = stickyRight;
|
|
179
|
+
}
|
|
180
|
+
return { left, top, width, height, zIndex: 40, effective: 'sticky' };
|
|
181
|
+
}
|
|
182
|
+
// static / relative — tile is centered in its cell with halfGap inset
|
|
183
|
+
const baseLeft = tile.col * colSize + halfGap;
|
|
184
|
+
const baseTop = tile.row * rowSize + halfGap;
|
|
185
|
+
if (pos === 'relative' && tile.offset) {
|
|
186
|
+
const off = offsetToPixels(tile.offset, config);
|
|
187
|
+
return {
|
|
188
|
+
left: baseLeft,
|
|
189
|
+
top: baseTop,
|
|
190
|
+
width,
|
|
191
|
+
height,
|
|
192
|
+
transform: `translate(${off.x}px, ${off.y}px)`,
|
|
193
|
+
zIndex: 1,
|
|
194
|
+
effective: 'relative',
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
return { left: baseLeft, top: baseTop, width, height, zIndex: 1, effective: 'static' };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Adjust pre-computed layouts so multiple sticky tiles pinned to the same edge
|
|
202
|
+
* stack instead of overlapping — the natural CSS-sticky behavior where, as you
|
|
203
|
+
* scroll, a later sticky element pushes the previous one off-screen by taking
|
|
204
|
+
* its pinned spot.
|
|
205
|
+
*
|
|
206
|
+
* Pass the array of {tile, layout} pairs (in any order); this mutates the
|
|
207
|
+
* `layout.left`/`layout.top` values for sticky tiles in place. Run it AFTER
|
|
208
|
+
* `computeTileLayout` and BEFORE rendering. Non-sticky entries are skipped.
|
|
209
|
+
*/
|
|
210
|
+
export function resolveStickyStacking(
|
|
211
|
+
entries: { tile: TileLayoutInput['tile']; layout: TileLayout }[],
|
|
212
|
+
): void {
|
|
213
|
+
const tops: { tile: TileLayoutInput['tile']; layout: TileLayout }[] = [];
|
|
214
|
+
const bots: { tile: TileLayoutInput['tile']; layout: TileLayout }[] = [];
|
|
215
|
+
const lefts: { tile: TileLayoutInput['tile']; layout: TileLayout }[] = [];
|
|
216
|
+
const rights: { tile: TileLayoutInput['tile']; layout: TileLayout }[] = [];
|
|
217
|
+
for (const e of entries) {
|
|
218
|
+
if (e.layout.effective !== 'sticky') continue;
|
|
219
|
+
const edge = e.tile.sticky?.edge ?? 'top';
|
|
220
|
+
if (edge === 'top') tops.push(e);
|
|
221
|
+
else if (edge === 'bottom') bots.push(e);
|
|
222
|
+
else if (edge === 'left') lefts.push(e);
|
|
223
|
+
else rights.push(e);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Top: sort by natural row ascending. A later (larger-row) sticky whose
|
|
227
|
+
// current rendered top intrudes on an earlier one pushes it up off-screen.
|
|
228
|
+
tops.sort((a, b) => a.tile.row - b.tile.row);
|
|
229
|
+
for (let i = 0; i < tops.length; i++) {
|
|
230
|
+
const a = tops[i];
|
|
231
|
+
if (!a) continue;
|
|
232
|
+
for (let j = i + 1; j < tops.length; j++) {
|
|
233
|
+
const b = tops[j];
|
|
234
|
+
if (!b) continue;
|
|
235
|
+
const aBottom = a.layout.top + a.layout.height;
|
|
236
|
+
if (b.layout.top < aBottom) {
|
|
237
|
+
a.layout.top = b.layout.top - a.layout.height;
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Bottom: mirror of top — sort by natural row descending.
|
|
244
|
+
bots.sort((a, b) => b.tile.row - a.tile.row);
|
|
245
|
+
for (let i = 0; i < bots.length; i++) {
|
|
246
|
+
const a = bots[i];
|
|
247
|
+
if (!a) continue;
|
|
248
|
+
for (let j = i + 1; j < bots.length; j++) {
|
|
249
|
+
const b = bots[j];
|
|
250
|
+
if (!b) continue;
|
|
251
|
+
const aTop = a.layout.top;
|
|
252
|
+
const bBottom = b.layout.top + b.layout.height;
|
|
253
|
+
if (bBottom > aTop) {
|
|
254
|
+
a.layout.top = bBottom;
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Left: mirror of top along the col axis.
|
|
261
|
+
lefts.sort((a, b) => a.tile.col - b.tile.col);
|
|
262
|
+
for (let i = 0; i < lefts.length; i++) {
|
|
263
|
+
const a = lefts[i];
|
|
264
|
+
if (!a) continue;
|
|
265
|
+
for (let j = i + 1; j < lefts.length; j++) {
|
|
266
|
+
const b = lefts[j];
|
|
267
|
+
if (!b) continue;
|
|
268
|
+
const aRight = a.layout.left + a.layout.width;
|
|
269
|
+
if (b.layout.left < aRight) {
|
|
270
|
+
a.layout.left = b.layout.left - a.layout.width;
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Right: mirror of bottom along the col axis.
|
|
277
|
+
rights.sort((a, b) => b.tile.col - a.tile.col);
|
|
278
|
+
for (let i = 0; i < rights.length; i++) {
|
|
279
|
+
const a = rights[i];
|
|
280
|
+
if (!a) continue;
|
|
281
|
+
for (let j = i + 1; j < rights.length; j++) {
|
|
282
|
+
const b = rights[j];
|
|
283
|
+
if (!b) continue;
|
|
284
|
+
const aLeft = a.layout.left;
|
|
285
|
+
const bRight = b.layout.left + b.layout.width;
|
|
286
|
+
if (bRight > aLeft) {
|
|
287
|
+
a.layout.left = bRight;
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|