@astronox/core 0.5.4 → 0.5.6
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/README.md +13 -0
- package/package.json +1 -1
- package/src/components/content/AnnotatedText/AnnotatedText.astro +38 -0
- package/src/components/content/AnnotatedText/AnnotatedText.css +19 -0
- package/src/components/content/AnnotatedText/MarkedText.astro +11 -0
- package/src/components/content/InlineDefinition/InlineDefinition.astro +11 -2
- package/src/components/data-display/TextPair/TextPair.astro +13 -2
- package/src/components/feedback/Tooltip/Tooltip.astro +18 -3
- package/src/components/feedback/Tooltip/Tooltip.css +9 -0
- package/src/components/index.ts +1 -0
- package/src/display/annotatedText.ts +14 -0
- package/src/display/contracts.ts +4 -3
- package/src/display/index.ts +1 -0
- package/src/patterns/MagneticFieldPuzzle/MagneticFieldPuzzle.css +0 -79
- package/src/patterns/MagneticFieldPuzzle/magneticFieldPuzzleController.ts +14 -148
- package/src/patterns/PuzzleGrid/movement.css +80 -0
- package/src/patterns/PuzzleGrid/movement.ts +149 -0
- package/src/patterns/PuzzleGrid/selectionDismissal.ts +4 -2
- package/src/patterns/SlidingPuzzle/SlidingPuzzle.astro +181 -0
- package/src/patterns/SlidingPuzzle/SlidingPuzzle.css +177 -0
- package/src/patterns/SlidingPuzzle/contracts.ts +57 -0
- package/src/patterns/SlidingPuzzle/simulation.ts +106 -0
- package/src/patterns/SlidingPuzzle/slidingPuzzleController.ts +703 -0
- package/src/patterns/Table/TableBody.astro +7 -1
- package/src/patterns/Table/TableCell.astro +3 -1
- package/src/patterns/Table/TableCellValue.astro +10 -1
- package/src/patterns/index.ts +4 -0
- package/src/primitives/text/TextBlock/TextBlock.astro +54 -4
- package/src/styles/style.css +4 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
export type PuzzleDrag = {
|
|
2
|
+
pointerId: number;
|
|
3
|
+
pieceId: string;
|
|
4
|
+
startX: number;
|
|
5
|
+
startY: number;
|
|
6
|
+
currentX: number;
|
|
7
|
+
currentY: number;
|
|
8
|
+
frameId: number | null;
|
|
9
|
+
element: HTMLButtonElement;
|
|
10
|
+
originCellBounds: DOMRect;
|
|
11
|
+
originCenterX: number;
|
|
12
|
+
originCenterY: number;
|
|
13
|
+
destinations: readonly HTMLButtonElement[] | null;
|
|
14
|
+
moved: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type Direction = 'north' | 'east' | 'south' | 'west';
|
|
18
|
+
|
|
19
|
+
const pathChevronsPerCell = 3;
|
|
20
|
+
const pathDurationPerCellMs = 2100;
|
|
21
|
+
|
|
22
|
+
export function animatePieceMovement(
|
|
23
|
+
element: HTMLButtonElement,
|
|
24
|
+
initialBounds: DOMRect,
|
|
25
|
+
): void {
|
|
26
|
+
const finalBounds = element.getBoundingClientRect();
|
|
27
|
+
element.style.setProperty(
|
|
28
|
+
'--piece-move-inline-distance',
|
|
29
|
+
`${initialBounds.left - finalBounds.left}px`,
|
|
30
|
+
);
|
|
31
|
+
element.style.setProperty(
|
|
32
|
+
'--piece-move-block-distance',
|
|
33
|
+
`${initialBounds.top - finalBounds.top}px`,
|
|
34
|
+
);
|
|
35
|
+
element.setAttribute('data-piece-moved', '');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function hasLeftOriginCell(
|
|
39
|
+
drag: PuzzleDrag,
|
|
40
|
+
deltaX: number,
|
|
41
|
+
deltaY: number,
|
|
42
|
+
): boolean {
|
|
43
|
+
const centerX = drag.originCenterX + deltaX;
|
|
44
|
+
const centerY = drag.originCenterY + deltaY;
|
|
45
|
+
const { bottom, left, right, top } = drag.originCellBounds;
|
|
46
|
+
|
|
47
|
+
return centerX < left || centerX > right || centerY < top || centerY > bottom;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function scheduleDragFrame(
|
|
51
|
+
drag: PuzzleDrag,
|
|
52
|
+
isCurrent: () => boolean,
|
|
53
|
+
): void {
|
|
54
|
+
if (drag.frameId !== null) return;
|
|
55
|
+
drag.frameId = window.requestAnimationFrame(() => {
|
|
56
|
+
drag.frameId = null;
|
|
57
|
+
if (!isCurrent()) return;
|
|
58
|
+
|
|
59
|
+
drag.element.style.setProperty(
|
|
60
|
+
'--piece-drag-x',
|
|
61
|
+
`${drag.currentX - drag.startX}px`,
|
|
62
|
+
);
|
|
63
|
+
drag.element.style.setProperty(
|
|
64
|
+
'--piece-drag-y',
|
|
65
|
+
`${drag.currentY - drag.startY}px`,
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function cancelDragFrame(drag: PuzzleDrag): void {
|
|
71
|
+
if (drag.frameId === null) return;
|
|
72
|
+
window.cancelAnimationFrame(drag.frameId);
|
|
73
|
+
drag.frameId = null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function findDirectionalDropDestination(
|
|
77
|
+
drag: PuzzleDrag,
|
|
78
|
+
deltaX: number,
|
|
79
|
+
deltaY: number,
|
|
80
|
+
): HTMLButtonElement | null {
|
|
81
|
+
let direction: Direction;
|
|
82
|
+
if (Math.abs(deltaX) >= Math.abs(deltaY)) {
|
|
83
|
+
direction = deltaX >= 0 ? 'east' : 'west';
|
|
84
|
+
} else {
|
|
85
|
+
direction = deltaY >= 0 ? 'south' : 'north';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
(drag.destinations ?? []).find(
|
|
90
|
+
(destination) =>
|
|
91
|
+
destination.dataset.destinationDirection === direction &&
|
|
92
|
+
destination.dataset.destinationPiece === drag.pieceId,
|
|
93
|
+
) ?? null
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function renderPathStream(
|
|
98
|
+
board: HTMLElement,
|
|
99
|
+
movement: {
|
|
100
|
+
from: { row: number; column: number };
|
|
101
|
+
to: { row: number; column: number };
|
|
102
|
+
direction: Direction;
|
|
103
|
+
},
|
|
104
|
+
): void {
|
|
105
|
+
const rowDistance = movement.to.row - movement.from.row;
|
|
106
|
+
const columnDistance = movement.to.column - movement.from.column;
|
|
107
|
+
const cellDistance = Math.abs(rowDistance) + Math.abs(columnDistance);
|
|
108
|
+
const chevronCount = pathChevronsPerCell * cellDistance;
|
|
109
|
+
const stream = document.createElement('span');
|
|
110
|
+
stream.className = 'puzzle-grid__path-stream';
|
|
111
|
+
stream.dataset.puzzlePathStream = '';
|
|
112
|
+
stream.dataset.puzzlePathDirection = movement.direction;
|
|
113
|
+
stream.setAttribute('aria-hidden', 'true');
|
|
114
|
+
stream.style.setProperty('--puzzle-path-row', String(movement.from.row + 1));
|
|
115
|
+
stream.style.setProperty(
|
|
116
|
+
'--puzzle-path-column',
|
|
117
|
+
String(movement.from.column + 1),
|
|
118
|
+
);
|
|
119
|
+
stream.style.setProperty(
|
|
120
|
+
'--puzzle-path-target-inline',
|
|
121
|
+
`${50 + columnDistance * 100}%`,
|
|
122
|
+
);
|
|
123
|
+
stream.style.setProperty(
|
|
124
|
+
'--puzzle-path-target-block',
|
|
125
|
+
`${50 + rowDistance * 100}%`,
|
|
126
|
+
);
|
|
127
|
+
stream.style.setProperty(
|
|
128
|
+
'--puzzle-path-duration',
|
|
129
|
+
`${pathDurationPerCellMs * cellDistance}ms`,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
for (let index = 0; index < chevronCount; index += 1) {
|
|
133
|
+
const progress = (index + 0.5) / chevronCount;
|
|
134
|
+
const chevron = document.createElement('span');
|
|
135
|
+
chevron.className = 'puzzle-grid__path-chevron';
|
|
136
|
+
chevron.style.setProperty('--puzzle-path-chevron-index', String(index));
|
|
137
|
+
chevron.style.setProperty(
|
|
138
|
+
'--puzzle-path-static-inline',
|
|
139
|
+
`${50 + columnDistance * progress * 100}%`,
|
|
140
|
+
);
|
|
141
|
+
chevron.style.setProperty(
|
|
142
|
+
'--puzzle-path-static-block',
|
|
143
|
+
`${50 + rowDistance * progress * 100}%`,
|
|
144
|
+
);
|
|
145
|
+
stream.append(chevron);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
board.append(stream);
|
|
149
|
+
}
|
|
@@ -6,7 +6,8 @@ type PuzzleSelectionDismissalOptions = {
|
|
|
6
6
|
export function initPuzzleSelectionDismissal(
|
|
7
7
|
root: HTMLElement,
|
|
8
8
|
{ dismiss, isSelectionTarget }: PuzzleSelectionDismissalOptions,
|
|
9
|
-
): void {
|
|
9
|
+
): () => void {
|
|
10
|
+
const controller = new AbortController();
|
|
10
11
|
document.addEventListener(
|
|
11
12
|
'pointerdown',
|
|
12
13
|
(event) => {
|
|
@@ -22,6 +23,7 @@ export function initPuzzleSelectionDismissal(
|
|
|
22
23
|
focused.blur();
|
|
23
24
|
}
|
|
24
25
|
},
|
|
25
|
-
true,
|
|
26
|
+
{ capture: true, signal: controller.signal },
|
|
26
27
|
);
|
|
28
|
+
return () => controller.abort();
|
|
27
29
|
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
3
|
+
import Media from '../../primitives/media/Media/Media.astro';
|
|
4
|
+
import PuzzleGrid from '../PuzzleGrid/PuzzleGrid.astro';
|
|
5
|
+
import type {
|
|
6
|
+
SlidingPuzzleLabels,
|
|
7
|
+
SlidingPuzzleMedia,
|
|
8
|
+
SlidingPuzzleModel,
|
|
9
|
+
} from './contracts';
|
|
10
|
+
|
|
11
|
+
type Props = HTMLAttributes<'section'> & {
|
|
12
|
+
label: string;
|
|
13
|
+
labels: SlidingPuzzleLabels;
|
|
14
|
+
media: SlidingPuzzleMedia;
|
|
15
|
+
puzzles: readonly SlidingPuzzleModel[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const {
|
|
19
|
+
class: className,
|
|
20
|
+
label,
|
|
21
|
+
labels,
|
|
22
|
+
media,
|
|
23
|
+
puzzles,
|
|
24
|
+
...attrs
|
|
25
|
+
}: Props = Astro.props;
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
<section
|
|
29
|
+
{...attrs}
|
|
30
|
+
class:list={['sliding-puzzle', className]}
|
|
31
|
+
aria-label={label}
|
|
32
|
+
data-sliding-puzzle
|
|
33
|
+
>
|
|
34
|
+
{
|
|
35
|
+
puzzles.map((puzzle, index) => (
|
|
36
|
+
<div
|
|
37
|
+
class="sliding-puzzle__instance"
|
|
38
|
+
data-puzzle-id={puzzle.id}
|
|
39
|
+
data-puzzle-status="incomplete"
|
|
40
|
+
data-sliding-model={JSON.stringify(puzzle)}
|
|
41
|
+
data-sliding-labels={JSON.stringify(labels)}
|
|
42
|
+
hidden={index !== 0}
|
|
43
|
+
style={`--piece-selection-filter: url(#sliding-outline-${encodeURIComponent(puzzle.id)}); --piece-hover-filter: url(#sliding-hover-${encodeURIComponent(puzzle.id)});`}
|
|
44
|
+
>
|
|
45
|
+
<svg
|
|
46
|
+
class="sliding-puzzle__filters"
|
|
47
|
+
aria-hidden="true"
|
|
48
|
+
focusable="false"
|
|
49
|
+
>
|
|
50
|
+
<defs>
|
|
51
|
+
<filter
|
|
52
|
+
id={`sliding-hover-${encodeURIComponent(puzzle.id)}`}
|
|
53
|
+
color-interpolation-filters="sRGB"
|
|
54
|
+
>
|
|
55
|
+
<feFlood
|
|
56
|
+
class="sliding-puzzle__hover-color"
|
|
57
|
+
flood-opacity="0.1"
|
|
58
|
+
/>
|
|
59
|
+
<feComposite operator="in" in2="SourceAlpha" />
|
|
60
|
+
<feMerge>
|
|
61
|
+
<feMergeNode in="SourceGraphic" />
|
|
62
|
+
<feMergeNode />
|
|
63
|
+
</feMerge>
|
|
64
|
+
</filter>
|
|
65
|
+
<filter
|
|
66
|
+
id={`sliding-outline-${encodeURIComponent(puzzle.id)}`}
|
|
67
|
+
x="-25%"
|
|
68
|
+
y="-25%"
|
|
69
|
+
width="150%"
|
|
70
|
+
height="150%"
|
|
71
|
+
color-interpolation-filters="sRGB"
|
|
72
|
+
>
|
|
73
|
+
{/* Soften the square dilation at silhouette corners. */}
|
|
74
|
+
<feMorphology in="SourceAlpha" operator="dilate" radius="2" />
|
|
75
|
+
<feGaussianBlur stdDeviation="0.5" result="outline" />
|
|
76
|
+
<feFlood class="sliding-puzzle__ring-color" />
|
|
77
|
+
<feComposite operator="in" in2="outline" />
|
|
78
|
+
<feMerge>
|
|
79
|
+
<feMergeNode />
|
|
80
|
+
<feMergeNode in="SourceGraphic" />
|
|
81
|
+
</feMerge>
|
|
82
|
+
</filter>
|
|
83
|
+
</defs>
|
|
84
|
+
</svg>
|
|
85
|
+
<PuzzleGrid
|
|
86
|
+
class="sliding-puzzle__board"
|
|
87
|
+
label={puzzle.label}
|
|
88
|
+
rows={puzzle.rows}
|
|
89
|
+
columns={puzzle.columns}
|
|
90
|
+
>
|
|
91
|
+
{Array.from(
|
|
92
|
+
{ length: puzzle.rows * puzzle.columns },
|
|
93
|
+
(_, cellIndex) => {
|
|
94
|
+
const row = Math.floor(cellIndex / puzzle.columns);
|
|
95
|
+
const column = cellIndex % puzzle.columns;
|
|
96
|
+
const blocked = puzzle.blockedCells.some(
|
|
97
|
+
(cell) => cell.row === row && cell.column === column,
|
|
98
|
+
);
|
|
99
|
+
const target =
|
|
100
|
+
puzzle.target.row === row && puzzle.target.column === column;
|
|
101
|
+
const hole = puzzle.holes.some(
|
|
102
|
+
({ position }) =>
|
|
103
|
+
position.row === row && position.column === column,
|
|
104
|
+
);
|
|
105
|
+
const fragile =
|
|
106
|
+
hole ||
|
|
107
|
+
puzzle.fragileCells.some(
|
|
108
|
+
(cell) => cell.row === row && cell.column === column,
|
|
109
|
+
);
|
|
110
|
+
const cellLabel = blocked
|
|
111
|
+
? labels.blockedCell
|
|
112
|
+
: target
|
|
113
|
+
? labels.target
|
|
114
|
+
: fragile
|
|
115
|
+
? labels.fragileCell
|
|
116
|
+
: '';
|
|
117
|
+
return (
|
|
118
|
+
<button
|
|
119
|
+
class="puzzle-grid__cell sliding-puzzle__cell"
|
|
120
|
+
type="button"
|
|
121
|
+
disabled={blocked}
|
|
122
|
+
tabindex={-1}
|
|
123
|
+
aria-label={cellLabel || undefined}
|
|
124
|
+
data-puzzle-cell
|
|
125
|
+
data-puzzle-cell-disabled={blocked ? '' : undefined}
|
|
126
|
+
data-sliding-cell
|
|
127
|
+
data-sliding-target={target ? '' : undefined}
|
|
128
|
+
data-sliding-fragile={fragile ? '' : undefined}
|
|
129
|
+
data-sliding-hole={hole ? '' : undefined}
|
|
130
|
+
data-cell-row={row}
|
|
131
|
+
data-cell-column={column}
|
|
132
|
+
data-cell-label={cellLabel}
|
|
133
|
+
style={`--cell-row: ${row + 1}; --cell-column: ${column + 1};`}
|
|
134
|
+
>
|
|
135
|
+
{fragile && (
|
|
136
|
+
<svg
|
|
137
|
+
class="sliding-puzzle__crack"
|
|
138
|
+
viewBox="0 0 100 100"
|
|
139
|
+
aria-hidden="true"
|
|
140
|
+
focusable="false"
|
|
141
|
+
>
|
|
142
|
+
<path d="M44 0 54 25 40 46 60 65 48 100M40 46 18 36 0 44M60 65 80 49 100 55M54 25 75 14" />
|
|
143
|
+
</svg>
|
|
144
|
+
)}
|
|
145
|
+
{hole && (
|
|
146
|
+
<span class="sliding-puzzle__hole" aria-hidden="true" />
|
|
147
|
+
)}
|
|
148
|
+
{target && (
|
|
149
|
+
<span class="sliding-puzzle__target" aria-hidden="true">
|
|
150
|
+
<Media media={media.target} size="fill" />
|
|
151
|
+
</span>
|
|
152
|
+
)}
|
|
153
|
+
</button>
|
|
154
|
+
);
|
|
155
|
+
},
|
|
156
|
+
)}
|
|
157
|
+
<button
|
|
158
|
+
class="sliding-puzzle__piece"
|
|
159
|
+
type="button"
|
|
160
|
+
aria-label={labels.piece}
|
|
161
|
+
aria-pressed="false"
|
|
162
|
+
data-sliding-piece
|
|
163
|
+
style={`--piece-row: ${puzzle.start.row + 1}; --piece-column: ${puzzle.start.column + 1};`}
|
|
164
|
+
>
|
|
165
|
+
<span class="sliding-puzzle__piece-media" aria-hidden="true">
|
|
166
|
+
<Media media={media.piece} size="fill" />
|
|
167
|
+
</span>
|
|
168
|
+
</button>
|
|
169
|
+
</PuzzleGrid>
|
|
170
|
+
</div>
|
|
171
|
+
))
|
|
172
|
+
}
|
|
173
|
+
</section>
|
|
174
|
+
|
|
175
|
+
<script>
|
|
176
|
+
import { initSlidingPuzzle } from './slidingPuzzleController';
|
|
177
|
+
|
|
178
|
+
for (const root of document.querySelectorAll('[data-sliding-puzzle]')) {
|
|
179
|
+
if (root instanceof HTMLElement) initSlidingPuzzle(root);
|
|
180
|
+
}
|
|
181
|
+
</script>
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
.sliding-puzzle,
|
|
2
|
+
.sliding-puzzle__instance {
|
|
3
|
+
display: grid;
|
|
4
|
+
min-inline-size: 0;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.sliding-puzzle__instance[hidden] {
|
|
8
|
+
display: none;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.sliding-puzzle__filters {
|
|
12
|
+
position: absolute;
|
|
13
|
+
inline-size: 0;
|
|
14
|
+
block-size: 0;
|
|
15
|
+
pointer-events: none;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.sliding-puzzle__ring-color {
|
|
19
|
+
flood-color: var(--nox-source-tone-info);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.sliding-puzzle__hover-color {
|
|
23
|
+
flood-color: var(--nox-tone-color);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.sliding-puzzle__board {
|
|
27
|
+
isolation: isolate;
|
|
28
|
+
touch-action: manipulation;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.sliding-puzzle__cell {
|
|
32
|
+
appearance: none;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.sliding-puzzle__cell[data-sliding-target] {
|
|
36
|
+
--puzzle-cell-base-border: var(--nox-source-tone-success);
|
|
37
|
+
|
|
38
|
+
border-style: dashed;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.sliding-puzzle__cell[data-sliding-fragile]:not([data-sliding-collapsed]):not(
|
|
42
|
+
[data-sliding-hole-open]
|
|
43
|
+
) {
|
|
44
|
+
--puzzle-cell-base-border: var(--nox-source-tone-info);
|
|
45
|
+
|
|
46
|
+
border-style: dashed;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.sliding-puzzle__crack {
|
|
50
|
+
position: absolute;
|
|
51
|
+
inset: 0;
|
|
52
|
+
inline-size: 100%;
|
|
53
|
+
block-size: 100%;
|
|
54
|
+
fill: none;
|
|
55
|
+
stroke: var(--nox-source-tone-info);
|
|
56
|
+
stroke-width: 3;
|
|
57
|
+
pointer-events: none;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.sliding-puzzle__cell[data-sliding-collapsed] .sliding-puzzle__crack {
|
|
61
|
+
stroke: var(--nox-text-disabled);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.sliding-puzzle__hole {
|
|
65
|
+
display: none;
|
|
66
|
+
inline-size: 72%;
|
|
67
|
+
aspect-ratio: 1;
|
|
68
|
+
border: calc(var(--nox-border-width) * 2) solid var(--nox-source-tone-info);
|
|
69
|
+
border-radius: var(--nox-radius-pill);
|
|
70
|
+
background: color-mix(
|
|
71
|
+
in srgb,
|
|
72
|
+
var(--nox-source-tone-neutral) 20%,
|
|
73
|
+
var(--nox-mix-dark)
|
|
74
|
+
);
|
|
75
|
+
pointer-events: none;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.sliding-puzzle__cell[data-sliding-hole-open] .sliding-puzzle__hole {
|
|
79
|
+
display: block;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.sliding-puzzle__cell[data-sliding-hole-open] .sliding-puzzle__crack {
|
|
83
|
+
display: none;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.sliding-puzzle__cell[data-sliding-destination] {
|
|
87
|
+
--puzzle-cell-ring-width: var(--nox-focus-ring-width);
|
|
88
|
+
--puzzle-cell-ring-color: var(--nox-source-tone-info);
|
|
89
|
+
|
|
90
|
+
z-index: 3;
|
|
91
|
+
overflow: visible;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.sliding-puzzle__target {
|
|
95
|
+
display: grid;
|
|
96
|
+
inline-size: 90%;
|
|
97
|
+
aspect-ratio: 1;
|
|
98
|
+
pointer-events: none;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.sliding-puzzle__piece {
|
|
102
|
+
position: relative;
|
|
103
|
+
z-index: 4;
|
|
104
|
+
display: grid;
|
|
105
|
+
grid-row: var(--piece-row);
|
|
106
|
+
grid-column: var(--piece-column);
|
|
107
|
+
min-inline-size: 0;
|
|
108
|
+
min-block-size: 0;
|
|
109
|
+
margin: var(--nox-space-1);
|
|
110
|
+
padding: 0;
|
|
111
|
+
place-items: center;
|
|
112
|
+
border: 0;
|
|
113
|
+
border-radius: var(--nox-radius-sm);
|
|
114
|
+
background: transparent;
|
|
115
|
+
cursor: grab;
|
|
116
|
+
touch-action: none;
|
|
117
|
+
user-select: none;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.sliding-puzzle__piece-media {
|
|
121
|
+
display: grid;
|
|
122
|
+
inline-size: 80%;
|
|
123
|
+
aspect-ratio: 1;
|
|
124
|
+
pointer-events: none;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.sliding-puzzle__piece:focus-visible {
|
|
128
|
+
outline: none;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.sliding-puzzle__piece[data-piece-selected] .sliding-puzzle__piece-media,
|
|
132
|
+
.sliding-puzzle__piece:focus-visible .sliding-puzzle__piece-media {
|
|
133
|
+
filter: var(--piece-selection-filter);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
@media (hover: hover) {
|
|
137
|
+
.sliding-puzzle__piece:hover .sliding-puzzle__piece-media {
|
|
138
|
+
filter: var(--piece-hover-filter);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.sliding-puzzle__piece[data-piece-selected]:hover
|
|
142
|
+
.sliding-puzzle__piece-media,
|
|
143
|
+
.sliding-puzzle__piece:focus-visible:hover .sliding-puzzle__piece-media {
|
|
144
|
+
filter: var(--piece-hover-filter) var(--piece-selection-filter);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.sliding-puzzle__piece[data-piece-dragging] {
|
|
149
|
+
z-index: 6;
|
|
150
|
+
cursor: grabbing;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.sliding-puzzle__piece[data-piece-dragging] .sliding-puzzle__piece-media {
|
|
154
|
+
transform: translate3d(var(--piece-drag-x, 0px), var(--piece-drag-y, 0px), 0)
|
|
155
|
+
scale(1.04);
|
|
156
|
+
will-change: transform;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.sliding-puzzle__instance[data-puzzle-status='valid'] .sliding-puzzle__piece {
|
|
160
|
+
cursor: default;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
164
|
+
.sliding-puzzle__piece[data-piece-moved] {
|
|
165
|
+
animation: sliding-puzzle-move 260ms var(--nox-motion-easing-standard);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
@keyframes sliding-puzzle-move {
|
|
170
|
+
from {
|
|
171
|
+
transform: translate3d(
|
|
172
|
+
var(--piece-move-inline-distance),
|
|
173
|
+
var(--piece-move-block-distance),
|
|
174
|
+
0
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { DisplayMedia } from '../../display/contracts';
|
|
2
|
+
|
|
3
|
+
export type SlidingDirection = 'north' | 'east' | 'south' | 'west';
|
|
4
|
+
export type SlidingPosition = { row: number; column: number };
|
|
5
|
+
export type SlidingPuzzleStatus = 'incomplete' | 'valid';
|
|
6
|
+
|
|
7
|
+
export type SlidingPuzzleModel = {
|
|
8
|
+
id: string;
|
|
9
|
+
label: string;
|
|
10
|
+
rows: number;
|
|
11
|
+
columns: number;
|
|
12
|
+
start: SlidingPosition;
|
|
13
|
+
target: SlidingPosition;
|
|
14
|
+
blockedCells: readonly SlidingPosition[];
|
|
15
|
+
fragileCells: readonly SlidingPosition[];
|
|
16
|
+
// Entry direction selects the adjacent landing cell around each exit.
|
|
17
|
+
// A transfer ends the move; its landing never triggers another tile.
|
|
18
|
+
holes: readonly { position: SlidingPosition; exit: SlidingPosition }[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type SlidingPuzzleState = {
|
|
22
|
+
position: SlidingPosition;
|
|
23
|
+
collapsedCells: readonly SlidingPosition[];
|
|
24
|
+
// Source tile positions, tracked independently and cleared on reset.
|
|
25
|
+
openHoles: readonly SlidingPosition[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type SlidingPuzzleLabels = {
|
|
29
|
+
piece: string;
|
|
30
|
+
target: string;
|
|
31
|
+
blockedCell: string;
|
|
32
|
+
fragileCell: string;
|
|
33
|
+
collapsedCell: string;
|
|
34
|
+
hole: string;
|
|
35
|
+
directions: Readonly<Record<SlidingDirection, string>>;
|
|
36
|
+
moveTemplate: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type SlidingPuzzleMedia = {
|
|
40
|
+
piece: DisplayMedia;
|
|
41
|
+
target: DisplayMedia;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type SlidingMovement = {
|
|
45
|
+
direction: SlidingDirection;
|
|
46
|
+
from: SlidingPosition;
|
|
47
|
+
slideTo: SlidingPosition;
|
|
48
|
+
to: SlidingPosition;
|
|
49
|
+
teleported: boolean;
|
|
50
|
+
state: SlidingPuzzleState;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type SlidingPuzzleChangeDetail = {
|
|
54
|
+
kind: 'sliding';
|
|
55
|
+
puzzleId: string;
|
|
56
|
+
state: SlidingPuzzleState;
|
|
57
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SlidingDirection,
|
|
3
|
+
SlidingMovement,
|
|
4
|
+
SlidingPuzzleModel,
|
|
5
|
+
SlidingPuzzleState,
|
|
6
|
+
} from './contracts';
|
|
7
|
+
|
|
8
|
+
const deltas = {
|
|
9
|
+
north: [-1, 0],
|
|
10
|
+
east: [0, 1],
|
|
11
|
+
south: [1, 0],
|
|
12
|
+
west: [0, -1],
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
export function createSlidingPuzzleState(
|
|
16
|
+
puzzle: SlidingPuzzleModel,
|
|
17
|
+
): SlidingPuzzleState {
|
|
18
|
+
return { position: { ...puzzle.start }, collapsedCells: [], openHoles: [] };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isSlidingPuzzleComplete(
|
|
22
|
+
puzzle: SlidingPuzzleModel,
|
|
23
|
+
state: SlidingPuzzleState,
|
|
24
|
+
): boolean {
|
|
25
|
+
return (
|
|
26
|
+
state.position.row === puzzle.target.row &&
|
|
27
|
+
state.position.column === puzzle.target.column
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function simulateSlidingMove(
|
|
32
|
+
puzzle: SlidingPuzzleModel,
|
|
33
|
+
state: SlidingPuzzleState,
|
|
34
|
+
direction: SlidingDirection,
|
|
35
|
+
): SlidingMovement | null {
|
|
36
|
+
if (isSlidingPuzzleComplete(puzzle, state)) return null;
|
|
37
|
+
const [dr, dc] = deltas[direction];
|
|
38
|
+
let to = { ...state.position };
|
|
39
|
+
const collapsedCells = [...state.collapsedCells];
|
|
40
|
+
while (true) {
|
|
41
|
+
const next = { row: to.row + dr, column: to.column + dc };
|
|
42
|
+
if (
|
|
43
|
+
next.row < 0 ||
|
|
44
|
+
next.row >= puzzle.rows ||
|
|
45
|
+
next.column < 0 ||
|
|
46
|
+
next.column >= puzzle.columns ||
|
|
47
|
+
[...puzzle.blockedCells, ...collapsedCells].some(
|
|
48
|
+
(cell) => cell.row === next.row && cell.column === next.column,
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
break;
|
|
52
|
+
// Entering a fragile tile collapses it, including the stopping tile.
|
|
53
|
+
// A piece resting on a collapsed tile may leave it, but cannot return.
|
|
54
|
+
to = next;
|
|
55
|
+
const hole = puzzle.holes.find(
|
|
56
|
+
({ position }) =>
|
|
57
|
+
position.row === to.row && position.column === to.column,
|
|
58
|
+
);
|
|
59
|
+
if (hole) {
|
|
60
|
+
const arrival = {
|
|
61
|
+
row: hole.exit.row + dr,
|
|
62
|
+
column: hole.exit.column + dc,
|
|
63
|
+
};
|
|
64
|
+
const openHoles = state.openHoles.some(
|
|
65
|
+
(position) => position.row === to.row && position.column === to.column,
|
|
66
|
+
)
|
|
67
|
+
? [...state.openHoles]
|
|
68
|
+
: [...state.openHoles, { ...to }];
|
|
69
|
+
return {
|
|
70
|
+
direction,
|
|
71
|
+
from: { ...state.position },
|
|
72
|
+
slideTo: to,
|
|
73
|
+
to: arrival,
|
|
74
|
+
teleported: true,
|
|
75
|
+
state: { position: arrival, collapsedCells, openHoles },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
if (
|
|
79
|
+
puzzle.fragileCells.some(
|
|
80
|
+
(cell) => cell.row === to.row && cell.column === to.column,
|
|
81
|
+
)
|
|
82
|
+
) {
|
|
83
|
+
collapsedCells.push({ ...to });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (to.row === state.position.row && to.column === state.position.column)
|
|
87
|
+
return null;
|
|
88
|
+
return {
|
|
89
|
+
direction,
|
|
90
|
+
from: { ...state.position },
|
|
91
|
+
slideTo: to,
|
|
92
|
+
to,
|
|
93
|
+
teleported: false,
|
|
94
|
+
state: { position: to, collapsedCells, openHoles: [...state.openHoles] },
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function getSlidingMoves(
|
|
99
|
+
puzzle: SlidingPuzzleModel,
|
|
100
|
+
state: SlidingPuzzleState,
|
|
101
|
+
): SlidingMovement[] {
|
|
102
|
+
return (Object.keys(deltas) as SlidingDirection[]).flatMap((direction) => {
|
|
103
|
+
const movement = simulateSlidingMove(puzzle, state, direction);
|
|
104
|
+
return movement ? [movement] : [];
|
|
105
|
+
});
|
|
106
|
+
}
|