@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
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Compaction (gravity) — pull tiles toward a configured edge or anchor cell.
|
|
2
|
+
//
|
|
3
|
+
// Runs after moves/adds/removes when gravity is not 'none'. Iteratively moves
|
|
4
|
+
// each tile one step toward the gravity anchor until no tile can move any closer.
|
|
5
|
+
|
|
6
|
+
import type { CellPos, CellRect, Gravity, Tile } from './types.js';
|
|
7
|
+
import type { Grid } from './grid.js';
|
|
8
|
+
import { rectsOverlap, tileRect } from './geometry.js';
|
|
9
|
+
import { isInFlow } from './positioning.js';
|
|
10
|
+
|
|
11
|
+
export function compact(grid: Grid): string[] {
|
|
12
|
+
const gravity = grid.config.gravity ?? 'none';
|
|
13
|
+
if (gravity === 'none') return [];
|
|
14
|
+
|
|
15
|
+
const moved = new Set<string>();
|
|
16
|
+
// Iteratively move tiles one cell toward anchor; stop when no tile moves.
|
|
17
|
+
for (let iter = 0; iter < 10_000; iter++) {
|
|
18
|
+
let anyMoved = false;
|
|
19
|
+
|
|
20
|
+
// Order tiles so that those closest to the anchor move first — otherwise
|
|
21
|
+
// a further tile may try to move into a cell not yet vacated by the closer
|
|
22
|
+
// one. Out-of-flow tiles (absolute/fixed) are skipped entirely by gravity.
|
|
23
|
+
const ordered = [...grid.tiles]
|
|
24
|
+
.filter((t) => isInFlow(t))
|
|
25
|
+
.sort((a, b) =>
|
|
26
|
+
distanceToAnchor(a, gravity) - distanceToAnchor(b, gravity),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
for (const t of ordered) {
|
|
30
|
+
const step = stepToward(t, gravity);
|
|
31
|
+
if (!step) continue;
|
|
32
|
+
const newRect: CellRect = {
|
|
33
|
+
col: t.col + step.dx,
|
|
34
|
+
row: t.row + step.dy,
|
|
35
|
+
w: t.w,
|
|
36
|
+
h: t.h,
|
|
37
|
+
};
|
|
38
|
+
if (!grid.rectInBounds(newRect)) continue;
|
|
39
|
+
const overlap = grid.tiles.filter(
|
|
40
|
+
(o) => o.id !== t.id && isInFlow(o) && rectsOverlap(tileRect(o), newRect),
|
|
41
|
+
);
|
|
42
|
+
if (overlap.length > 0) continue;
|
|
43
|
+
grid._setTilePos(t.id, { col: newRect.col, row: newRect.row });
|
|
44
|
+
moved.add(t.id);
|
|
45
|
+
anyMoved = true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!anyMoved) break;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return Array.from(moved);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function stepToward(t: Tile, gravity: Gravity): { dx: number; dy: number } | null {
|
|
55
|
+
if (gravity === 'none') return null;
|
|
56
|
+
if (gravity === 'top') return t.row > 0 ? { dx: 0, dy: -1 } : null;
|
|
57
|
+
if (gravity === 'left') return t.col > 0 ? { dx: -1, dy: 0 } : null;
|
|
58
|
+
if (gravity === 'bottom') return { dx: 0, dy: 1 };
|
|
59
|
+
if (gravity === 'right') return { dx: 1, dy: 0 };
|
|
60
|
+
// Anchor cell
|
|
61
|
+
const dx = Math.sign(gravity.col - t.col);
|
|
62
|
+
const dy = Math.sign(gravity.row - t.row);
|
|
63
|
+
// Move along the axis with the greater distance first so we take the shortest path.
|
|
64
|
+
const adx = Math.abs(gravity.col - t.col);
|
|
65
|
+
const ady = Math.abs(gravity.row - t.row);
|
|
66
|
+
if (adx === 0 && dy === 0) return null;
|
|
67
|
+
if (adx >= ady && dx !== 0) return { dx, dy: 0 };
|
|
68
|
+
if (dy !== 0) return { dx: 0, dy };
|
|
69
|
+
if (dx !== 0) return { dx, dy: 0 };
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function distanceToAnchor(t: Tile, gravity: Gravity): number {
|
|
74
|
+
if (gravity === 'none') return 0;
|
|
75
|
+
if (gravity === 'top') return t.row;
|
|
76
|
+
if (gravity === 'bottom') return -t.row;
|
|
77
|
+
if (gravity === 'left') return t.col;
|
|
78
|
+
if (gravity === 'right') return -t.col;
|
|
79
|
+
return Math.abs(gravity.col - t.col) + Math.abs(gravity.row - t.row);
|
|
80
|
+
}
|
package/src/drag.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// DragController — live-preview state machine for drag-to-rearrange.
|
|
2
|
+
//
|
|
3
|
+
// Models the storyboard semantics: as the user drags a tile, the engine
|
|
4
|
+
// continuously runs the candidate move against a snapshot of the grid taken
|
|
5
|
+
// at pickup. Whenever the cursor crosses a cell boundary, any prior preview
|
|
6
|
+
// is rewound and the new candidate is attempted. The dragged tile and any
|
|
7
|
+
// displaced victims animate into their new positions via the adapter's FLIP
|
|
8
|
+
// loop while the drag is still in progress; on release the latest preview
|
|
9
|
+
// is committed. If the user releases over an invalid cell (out of bounds or
|
|
10
|
+
// rejected by the move engine), the snapshot is restored.
|
|
11
|
+
//
|
|
12
|
+
// This module is pure cell-space — pixel-to-cell conversion is the adapter's
|
|
13
|
+
// responsibility. That keeps the core engine free of any DOM concepts.
|
|
14
|
+
|
|
15
|
+
import type { CellPos, Tile } from './types.js';
|
|
16
|
+
import type { Grid } from './grid.js';
|
|
17
|
+
|
|
18
|
+
type Snapshot = Map<string, Tile>;
|
|
19
|
+
|
|
20
|
+
export interface DragUpdateResult {
|
|
21
|
+
/** Whether the most recent candidate cell was successfully committed. */
|
|
22
|
+
committed: boolean;
|
|
23
|
+
/** Cell where the drop indicator should render (null if no valid target). */
|
|
24
|
+
indicatorCell: CellPos | null;
|
|
25
|
+
/** Did the candidate cell change since the previous update? */
|
|
26
|
+
changed: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Holds the snapshot/preview state for a single drag gesture. One controller
|
|
31
|
+
* instance can be reused across drags — call `start()` to begin a new gesture
|
|
32
|
+
* and `end()` or `cancel()` to finish.
|
|
33
|
+
*/
|
|
34
|
+
export class DragController {
|
|
35
|
+
private grid: Grid;
|
|
36
|
+
private tileId: string | null = null;
|
|
37
|
+
private snapshot: Snapshot | null = null;
|
|
38
|
+
private pickup: CellPos = { col: 0, row: 0 };
|
|
39
|
+
private lastCandidate: CellPos | null = null;
|
|
40
|
+
private lastCommitted: CellPos | null = null;
|
|
41
|
+
|
|
42
|
+
constructor(grid: Grid) {
|
|
43
|
+
this.grid = grid;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** True if a drag gesture is currently in progress. */
|
|
47
|
+
isActive(): boolean {
|
|
48
|
+
return this.tileId !== null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** ID of the tile currently being dragged, or null. */
|
|
52
|
+
draggerId(): string | null {
|
|
53
|
+
return this.tileId;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Cell where the drag was picked up (the dragger's pre-drag position). */
|
|
57
|
+
pickupCell(): CellPos {
|
|
58
|
+
return { col: this.pickup.col, row: this.pickup.row };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Begin a drag gesture for `tileId`. Snapshots the grid so we can rewind on
|
|
63
|
+
* each candidate change. Returns false if the tile doesn't exist.
|
|
64
|
+
*/
|
|
65
|
+
start(tileId: string): boolean {
|
|
66
|
+
const tile = this.grid.getTile(tileId);
|
|
67
|
+
if (!tile) return false;
|
|
68
|
+
this.tileId = tileId;
|
|
69
|
+
this.snapshot = this.grid.snapshotTiles();
|
|
70
|
+
this.pickup = { col: tile.col, row: tile.row };
|
|
71
|
+
this.lastCandidate = { col: tile.col, row: tile.row };
|
|
72
|
+
this.lastCommitted = { col: tile.col, row: tile.row };
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The cursor is now over `candidate`. If it's the same cell as last update,
|
|
78
|
+
* this is a no-op. If it changed, rewind any prior preview and attempt the
|
|
79
|
+
* new candidate. Returns the resulting render hints.
|
|
80
|
+
*/
|
|
81
|
+
update(candidate: CellPos): DragUpdateResult {
|
|
82
|
+
if (!this.tileId || !this.snapshot) {
|
|
83
|
+
return { committed: false, indicatorCell: null, changed: false };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const same =
|
|
87
|
+
this.lastCandidate !== null &&
|
|
88
|
+
this.lastCandidate.col === candidate.col &&
|
|
89
|
+
this.lastCandidate.row === candidate.row;
|
|
90
|
+
if (same) {
|
|
91
|
+
return {
|
|
92
|
+
committed: this.lastCommitted !== null,
|
|
93
|
+
indicatorCell: this.lastCommitted,
|
|
94
|
+
changed: false,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
this.lastCandidate = { col: candidate.col, row: candidate.row };
|
|
98
|
+
|
|
99
|
+
// Always rewind to pickup state before attempting the new candidate, so
|
|
100
|
+
// each preview is independent and we can't compound errors.
|
|
101
|
+
this.grid.restoreTiles(this.snapshot);
|
|
102
|
+
|
|
103
|
+
if (candidate.col === this.pickup.col && candidate.row === this.pickup.row) {
|
|
104
|
+
// Back at pickup — nothing to commit, indicator at pickup.
|
|
105
|
+
this.lastCommitted = { col: this.pickup.col, row: this.pickup.row };
|
|
106
|
+
return { committed: true, indicatorCell: this.lastCommitted, changed: true };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const ok = this.grid.moveTile(this.tileId, candidate);
|
|
110
|
+
this.lastCommitted = ok ? { col: candidate.col, row: candidate.row } : null;
|
|
111
|
+
return {
|
|
112
|
+
committed: ok,
|
|
113
|
+
indicatorCell: this.lastCommitted,
|
|
114
|
+
changed: true,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* End the drag. The current preview state stays committed if it was a valid
|
|
120
|
+
* move; otherwise the snapshot is restored. Returns the final state.
|
|
121
|
+
*/
|
|
122
|
+
end(): { committed: boolean; finalCell: CellPos | null } {
|
|
123
|
+
const result = {
|
|
124
|
+
committed: this.lastCommitted !== null,
|
|
125
|
+
finalCell: this.lastCommitted,
|
|
126
|
+
};
|
|
127
|
+
// If the last attempt was rejected, ensure we're back at the original state.
|
|
128
|
+
if (!this.lastCommitted && this.snapshot) {
|
|
129
|
+
this.grid.restoreTiles(this.snapshot);
|
|
130
|
+
}
|
|
131
|
+
this.tileId = null;
|
|
132
|
+
this.snapshot = null;
|
|
133
|
+
this.lastCandidate = null;
|
|
134
|
+
this.lastCommitted = null;
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Abort the drag and restore the pre-drag grid state. */
|
|
139
|
+
cancel(): void {
|
|
140
|
+
if (this.snapshot) this.grid.restoreTiles(this.snapshot);
|
|
141
|
+
this.tileId = null;
|
|
142
|
+
this.snapshot = null;
|
|
143
|
+
this.lastCandidate = null;
|
|
144
|
+
this.lastCommitted = null;
|
|
145
|
+
}
|
|
146
|
+
}
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Minimal event emitter — no dependencies.
|
|
2
|
+
|
|
3
|
+
export type Listener<T> = (payload: T) => void;
|
|
4
|
+
|
|
5
|
+
export class Emitter<T> {
|
|
6
|
+
private listeners = new Set<Listener<T>>();
|
|
7
|
+
|
|
8
|
+
on(fn: Listener<T>): () => void {
|
|
9
|
+
this.listeners.add(fn);
|
|
10
|
+
return () => this.listeners.delete(fn);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
off(fn: Listener<T>): void {
|
|
14
|
+
this.listeners.delete(fn);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
emit(payload: T): void {
|
|
18
|
+
// Copy to array so listeners can safely unsubscribe during emit.
|
|
19
|
+
for (const l of Array.from(this.listeners)) l(payload);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
clear(): void {
|
|
23
|
+
this.listeners.clear();
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/geometry.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Rect / footprint / priority math. Pure functions only — no state.
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
CellPos,
|
|
5
|
+
CellRect,
|
|
6
|
+
Direction8,
|
|
7
|
+
Face,
|
|
8
|
+
Corner,
|
|
9
|
+
Footprint,
|
|
10
|
+
Tile,
|
|
11
|
+
} from './types.js';
|
|
12
|
+
|
|
13
|
+
/** Does rect `a` overlap rect `b`? */
|
|
14
|
+
export function rectsOverlap(a: CellRect, b: CellRect): boolean {
|
|
15
|
+
return (
|
|
16
|
+
a.col < b.col + b.w &&
|
|
17
|
+
a.col + a.w > b.col &&
|
|
18
|
+
a.row < b.row + b.h &&
|
|
19
|
+
a.row + a.h > b.row
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Does rect `outer` fully contain rect `inner`? */
|
|
24
|
+
export function rectContains(outer: CellRect, inner: CellRect): boolean {
|
|
25
|
+
return (
|
|
26
|
+
inner.col >= outer.col &&
|
|
27
|
+
inner.row >= outer.row &&
|
|
28
|
+
inner.col + inner.w <= outer.col + outer.w &&
|
|
29
|
+
inner.row + inner.h <= outer.row + outer.h
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function rectEquals(a: CellRect, b: CellRect): boolean {
|
|
34
|
+
return a.col === b.col && a.row === b.row && a.w === b.w && a.h === b.h;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Two tiles are "adjacent" if their rects share at least a corner touch (8-neighbor). */
|
|
38
|
+
export function rectsAdjacent(a: CellRect, b: CellRect): boolean {
|
|
39
|
+
if (rectsOverlap(a, b)) return false;
|
|
40
|
+
const horizTouch =
|
|
41
|
+
(a.col + a.w === b.col || b.col + b.w === a.col) &&
|
|
42
|
+
a.row < b.row + b.h &&
|
|
43
|
+
a.row + a.h > b.row;
|
|
44
|
+
const vertTouch =
|
|
45
|
+
(a.row + a.h === b.row || b.row + b.h === a.row) &&
|
|
46
|
+
a.col < b.col + b.w &&
|
|
47
|
+
a.col + a.w > b.col;
|
|
48
|
+
const cornerTouch =
|
|
49
|
+
(a.col + a.w === b.col || b.col + b.w === a.col) &&
|
|
50
|
+
(a.row + a.h === b.row || b.row + b.h === a.row);
|
|
51
|
+
return horizTouch || vertTouch || cornerTouch;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function tileRect(t: Tile): CellRect {
|
|
55
|
+
return { col: t.col, row: t.row, w: t.w, h: t.h };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Delta from origin's center to target's center, both as cell centers. */
|
|
59
|
+
function centerDelta(origin: CellRect, target: CellRect): { dx: number; dy: number } {
|
|
60
|
+
const ox = origin.col + origin.w / 2;
|
|
61
|
+
const oy = origin.row + origin.h / 2;
|
|
62
|
+
const tx = target.col + target.w / 2;
|
|
63
|
+
const ty = target.row + target.h / 2;
|
|
64
|
+
return { dx: tx - ox, dy: ty - oy };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Face closest to origin from target's perspective. */
|
|
68
|
+
export function faceClosestToOrigin(origin: CellRect, target: CellRect): Face {
|
|
69
|
+
const { dx, dy } = centerDelta(origin, target);
|
|
70
|
+
// target->origin: negate
|
|
71
|
+
const ox = -dx;
|
|
72
|
+
const oy = -dy;
|
|
73
|
+
if (Math.abs(ox) >= Math.abs(oy)) {
|
|
74
|
+
return ox >= 0 ? 'e' : 'w';
|
|
75
|
+
}
|
|
76
|
+
return oy >= 0 ? 's' : 'n';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Opposite face. */
|
|
80
|
+
export function oppositeFace(f: Face): Face {
|
|
81
|
+
if (f === 'n') return 's';
|
|
82
|
+
if (f === 's') return 'n';
|
|
83
|
+
if (f === 'e') return 'w';
|
|
84
|
+
return 'e';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Classify origin -> target geometry. */
|
|
88
|
+
export type OriginKind = 'horizontal' | 'vertical' | 'corner';
|
|
89
|
+
export function classifyOrigin(origin: CellRect, target: CellRect): OriginKind {
|
|
90
|
+
const { dx, dy } = centerDelta(origin, target);
|
|
91
|
+
const horiz = Math.abs(dx) > 1e-9;
|
|
92
|
+
const vert = Math.abs(dy) > 1e-9;
|
|
93
|
+
if (horiz && vert) return 'corner';
|
|
94
|
+
if (horiz) return 'horizontal';
|
|
95
|
+
return 'vertical';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Is corner `c` on the side of origin? */
|
|
99
|
+
function cornerDistToOrigin(
|
|
100
|
+
origin: CellRect,
|
|
101
|
+
target: CellRect,
|
|
102
|
+
c: Corner,
|
|
103
|
+
): number {
|
|
104
|
+
// Corner position as the center of the 1x1 slot diagonally adjacent.
|
|
105
|
+
const cx =
|
|
106
|
+
c === 'nw' || c === 'sw'
|
|
107
|
+
? target.col - 0.5
|
|
108
|
+
: target.col + target.w + 0.5;
|
|
109
|
+
const cy =
|
|
110
|
+
c === 'nw' || c === 'ne'
|
|
111
|
+
? target.row - 0.5
|
|
112
|
+
: target.row + target.h + 0.5;
|
|
113
|
+
const ox = origin.col + origin.w / 2;
|
|
114
|
+
const oy = origin.row + origin.h / 2;
|
|
115
|
+
const dx = cx - ox;
|
|
116
|
+
const dy = cy - oy;
|
|
117
|
+
return dx * dx + dy * dy;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Return the 8 displacement directions for a tile at `target` being pushed
|
|
122
|
+
* by a drag originating at `origin`, ordered by the priority spec.
|
|
123
|
+
*/
|
|
124
|
+
export function priorityDirections(origin: CellRect, target: CellRect): Direction8[] {
|
|
125
|
+
const faceClosest = faceClosestToOrigin(origin, target);
|
|
126
|
+
const faceOpposite = oppositeFace(faceClosest);
|
|
127
|
+
const kind = classifyOrigin(origin, target);
|
|
128
|
+
|
|
129
|
+
// 3rd priority: upwards face if origin horizontal, right face otherwise
|
|
130
|
+
// but excluding already-chosen faces
|
|
131
|
+
const allFaces: Face[] = ['n', 's', 'e', 'w'];
|
|
132
|
+
const used = new Set<Face>([faceClosest, faceOpposite]);
|
|
133
|
+
let third: Face;
|
|
134
|
+
if (kind === 'horizontal') {
|
|
135
|
+
third = used.has('n') ? pickRemaining(allFaces, used) : 'n';
|
|
136
|
+
} else {
|
|
137
|
+
third = used.has('e') ? pickRemaining(allFaces, used) : 'e';
|
|
138
|
+
}
|
|
139
|
+
used.add(third);
|
|
140
|
+
const fourth = pickRemaining(allFaces, used);
|
|
141
|
+
|
|
142
|
+
// Corners: rank closest-2 then furthest-2
|
|
143
|
+
const corners: Corner[] = ['nw', 'ne', 'sw', 'se'];
|
|
144
|
+
const cornerRanked = corners
|
|
145
|
+
.map((c) => ({ c, d: cornerDistToOrigin(origin, target, c) }))
|
|
146
|
+
.sort((a, b) => a.d - b.d);
|
|
147
|
+
const closeCorners = cornerRanked.slice(0, 2).map((x) => x.c);
|
|
148
|
+
const farCorners = cornerRanked.slice(2).map((x) => x.c);
|
|
149
|
+
|
|
150
|
+
return [
|
|
151
|
+
faceClosest,
|
|
152
|
+
faceOpposite,
|
|
153
|
+
third,
|
|
154
|
+
fourth,
|
|
155
|
+
closeCorners[0]!,
|
|
156
|
+
closeCorners[1]!,
|
|
157
|
+
farCorners[0]!,
|
|
158
|
+
farCorners[1]!,
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function pickRemaining(all: Face[], used: Set<Face>): Face {
|
|
163
|
+
for (const f of all) if (!used.has(f)) return f;
|
|
164
|
+
return all[0]!;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Translate a rect by a direction (one unit). */
|
|
168
|
+
export function translateRect(r: CellRect, dir: Direction8): CellRect {
|
|
169
|
+
let dcol = 0;
|
|
170
|
+
let drow = 0;
|
|
171
|
+
if (dir === 'n' || dir === 'nw' || dir === 'ne') drow = -1;
|
|
172
|
+
if (dir === 's' || dir === 'sw' || dir === 'se') drow = 1;
|
|
173
|
+
if (dir === 'w' || dir === 'nw' || dir === 'sw') dcol = -1;
|
|
174
|
+
if (dir === 'e' || dir === 'ne' || dir === 'se') dcol = 1;
|
|
175
|
+
return { col: r.col + dcol, row: r.row + drow, w: r.w, h: r.h };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Translate by arbitrary cell offset. */
|
|
179
|
+
export function offsetRect(
|
|
180
|
+
r: CellRect,
|
|
181
|
+
dcol: number,
|
|
182
|
+
drow: number,
|
|
183
|
+
): CellRect {
|
|
184
|
+
return { col: r.col + dcol, row: r.row + drow, w: r.w, h: r.h };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Stepwise offset for a direction (dx, dy in cells, -1..1). */
|
|
188
|
+
export function directionStep(dir: Direction8): { dx: number; dy: number } {
|
|
189
|
+
const r = translateRect({ col: 0, row: 0, w: 0, h: 0 }, dir);
|
|
190
|
+
return { dx: r.col, dy: r.row };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function footprintEquals(a: Footprint, b: Footprint): boolean {
|
|
194
|
+
return a.w === b.w && a.h === b.h;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function clonePos(p: CellPos): CellPos {
|
|
198
|
+
return { col: p.col, row: p.row };
|
|
199
|
+
}
|