@omercnet/paseo-queens 0.1.0-next.126.2

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.
@@ -0,0 +1,592 @@
1
+ import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import {
4
+ type GestureResponderEvent,
5
+ type LayoutChangeEvent,
6
+ Pressable,
7
+ StyleSheet,
8
+ Text,
9
+ View,
10
+ } from "react-native";
11
+ import type { CellState, Puzzle } from "./game";
12
+ import { GameMark } from "./game-mark";
13
+
14
+ const CELL_STATE_LABEL: Record<CellState, string> = {
15
+ empty: "empty",
16
+ excluded: "excluded",
17
+ marked: "queen marked",
18
+ };
19
+
20
+ const CELL_ACTION_HINT: Record<CellState, string> = {
21
+ empty: "Tap once to mark this square excluded. Double tap to place a queen.",
22
+ excluded: "Tap once to clear this X. Double tap to place a queen.",
23
+ marked: "Tap once to replace this queen with an X. Double tap to clear the queen.",
24
+ };
25
+
26
+ const DOUBLE_TAP_DELAY_MS = 300;
27
+
28
+ type BoardLayout = {
29
+ readonly width: number;
30
+ readonly height: number;
31
+ };
32
+
33
+ type DragReplacement = "empty" | "excluded";
34
+
35
+ type DragGesture = {
36
+ readonly startIndex: number;
37
+ readonly indexes: Set<number>;
38
+ readonly replacement: DragReplacement;
39
+ moved: boolean;
40
+ cancelled: boolean;
41
+ };
42
+
43
+ type DragPreview = {
44
+ readonly indexes: ReadonlySet<number>;
45
+ readonly replacement: DragReplacement;
46
+ };
47
+
48
+ type PendingTap = {
49
+ readonly releasedAt: number;
50
+ cancel(): void;
51
+ };
52
+
53
+ function cellIndexAtLocation(
54
+ event: GestureResponderEvent,
55
+ size: number,
56
+ layout: BoardLayout,
57
+ ): number | null {
58
+ if (layout.width <= 0 || layout.height <= 0) return null;
59
+
60
+ const { locationX, locationY } = event.nativeEvent;
61
+ if (locationX < 0 || locationY < 0 || locationX >= layout.width || locationY >= layout.height) {
62
+ return null;
63
+ }
64
+
65
+ const column = Math.min(size - 1, Math.floor((locationX / layout.width) * size));
66
+ const row = Math.min(size - 1, Math.floor((locationY / layout.height) * size));
67
+ return row * size + column;
68
+ }
69
+
70
+ type Rgb = readonly [red: number, green: number, blue: number];
71
+
72
+ function parseHexColor(color: string): Rgb | null {
73
+ const value = color.trim();
74
+ const short = /^#([0-9a-f]{3})$/i.exec(value)?.[1];
75
+ if (short) {
76
+ return [
77
+ Number.parseInt(`${short[0]}${short[0]}`, 16),
78
+ Number.parseInt(`${short[1]}${short[1]}`, 16),
79
+ Number.parseInt(`${short[2]}${short[2]}`, 16),
80
+ ];
81
+ }
82
+
83
+ const full = /^#([0-9a-f]{6})(?:[0-9a-f]{2})?$/i.exec(value)?.[1];
84
+ if (!full) return null;
85
+ return [
86
+ Number.parseInt(full.slice(0, 2), 16),
87
+ Number.parseInt(full.slice(2, 4), 16),
88
+ Number.parseInt(full.slice(4, 6), 16),
89
+ ];
90
+ }
91
+
92
+ function mixColor(base: string, tint: string, tintWeight: number): string {
93
+ const baseRgb = parseHexColor(base);
94
+ const tintRgb = parseHexColor(tint);
95
+ if (!baseRgb || !tintRgb) return tint;
96
+
97
+ const baseWeight = 1 - tintWeight;
98
+ const red = Math.round(baseRgb[0] * baseWeight + tintRgb[0] * tintWeight);
99
+ const green = Math.round(baseRgb[1] * baseWeight + tintRgb[1] * tintWeight);
100
+ const blue = Math.round(baseRgb[2] * baseWeight + tintRgb[2] * tintWeight);
101
+ return `rgb(${red}, ${green}, ${blue})`;
102
+ }
103
+
104
+ function createRegionFills(theme: PluginSurfaceProps["theme"], count: number): readonly string[] {
105
+ const { colors } = theme;
106
+ const accent = parseHexColor(colors.accent);
107
+ if (!accent) {
108
+ return [
109
+ colors.accent,
110
+ colors.statusSuccess,
111
+ colors.statusWarning,
112
+ colors.statusDanger,
113
+ colors.foregroundMuted,
114
+ colors.surface2,
115
+ ];
116
+ }
117
+
118
+ const red = accent[0] / 255;
119
+ const green = accent[1] / 255;
120
+ const blue = accent[2] / 255;
121
+ const maximum = Math.max(red, green, blue);
122
+ const minimum = Math.min(red, green, blue);
123
+ const delta = maximum - minimum;
124
+ let accentHue = 0;
125
+ if (delta > 0 && maximum === red) accentHue = 60 * (((green - blue) / delta) % 6);
126
+ if (delta > 0 && maximum === green) accentHue = 60 * ((blue - red) / delta + 2);
127
+ if (delta > 0 && maximum === blue) accentHue = 60 * ((red - green) / delta + 4);
128
+ if (accentHue < 0) accentHue += 360;
129
+
130
+ const regionColors = Array.from({ length: count }, (_, region) => {
131
+ const hue = (accentHue + (region * 360) / count) % 360;
132
+ const chroma = 0.82 * 0.72;
133
+ const secondary = chroma * (1 - Math.abs(((hue / 60) % 2) - 1));
134
+ const offset = 0.82 - chroma;
135
+ let spectrum: Rgb;
136
+ if (hue < 60) spectrum = [chroma, secondary, 0];
137
+ else if (hue < 120) spectrum = [secondary, chroma, 0];
138
+ else if (hue < 180) spectrum = [0, chroma, secondary];
139
+ else if (hue < 240) spectrum = [0, secondary, chroma];
140
+ else if (hue < 300) spectrum = [secondary, 0, chroma];
141
+ else spectrum = [chroma, 0, secondary];
142
+
143
+ const spectrumRed = Math.round((spectrum[0] + offset) * 255)
144
+ .toString(16)
145
+ .padStart(2, "0");
146
+ const spectrumGreen = Math.round((spectrum[1] + offset) * 255)
147
+ .toString(16)
148
+ .padStart(2, "0");
149
+ const spectrumBlue = Math.round((spectrum[2] + offset) * 255)
150
+ .toString(16)
151
+ .padStart(2, "0");
152
+ return `#${spectrumRed}${spectrumGreen}${spectrumBlue}`;
153
+ });
154
+
155
+ return regionColors.map((color) => mixColor(colors.surface1, color, 0.34));
156
+ }
157
+
158
+ export type QueensBoardProps = {
159
+ dragEnabled?: boolean;
160
+ maxSize?: number;
161
+ puzzle: Puzzle;
162
+ cells: readonly CellState[];
163
+ conflicts: ReadonlySet<number>;
164
+ solved: boolean;
165
+ disabled: boolean;
166
+ compact: boolean;
167
+ theme: PluginSurfaceProps["theme"];
168
+ onSetCells(indexes: readonly number[], state: CellState): void;
169
+ onGestureActiveChange(active: boolean): void;
170
+ };
171
+
172
+ export function QueensBoard({
173
+ dragEnabled = true,
174
+ maxSize,
175
+ puzzle,
176
+ cells,
177
+ conflicts,
178
+ solved,
179
+ disabled,
180
+ compact,
181
+ theme,
182
+ onSetCells,
183
+ onGestureActiveChange,
184
+ }: QueensBoardProps) {
185
+ const nominalBoardSize = maxSize ?? (compact ? 360 : 440);
186
+ const cellSize = nominalBoardSize / puzzle.size;
187
+ const markSize = Math.max(9, Math.min(compact ? 26 : 32, Math.floor(cellSize * 0.64)));
188
+ const styles = useMemo(
189
+ () => createStyles(theme, compact, puzzle.size, maxSize),
190
+ [compact, maxSize, puzzle.size, theme],
191
+ );
192
+ const coordinates = useMemo(
193
+ () => Array.from({ length: puzzle.size }, (_, coordinate) => coordinate),
194
+ [puzzle.size],
195
+ );
196
+ const regionFills = useMemo(() => createRegionFills(theme, puzzle.size), [puzzle.size, theme]);
197
+ const layoutRef = useRef<BoardLayout>({ width: 0, height: 0 });
198
+ const gestureRef = useRef<DragGesture | null>(null);
199
+ const pendingTapsRef = useRef(new Map<number, PendingTap>());
200
+ const cellsRef = useRef(cells);
201
+ const onSetCellsRef = useRef(onSetCells);
202
+ const onGestureActiveChangeRef = useRef(onGestureActiveChange);
203
+ const [dragPreview, setDragPreview] = useState<DragPreview | null>(null);
204
+ cellsRef.current = cells;
205
+ onSetCellsRef.current = onSetCells;
206
+ onGestureActiveChangeRef.current = onGestureActiveChange;
207
+
208
+ useEffect(() => {
209
+ const pendingTaps = pendingTapsRef.current;
210
+ return () => {
211
+ for (const pending of pendingTaps.values()) pending.cancel();
212
+ pendingTaps.clear();
213
+ gestureRef.current = null;
214
+ onGestureActiveChangeRef.current(false);
215
+ };
216
+ }, []);
217
+
218
+ const handleBoardLayout = useCallback((event: LayoutChangeEvent) => {
219
+ const { width, height } = event.nativeEvent.layout;
220
+ layoutRef.current = { width, height };
221
+ }, []);
222
+
223
+ const toggleExcluded = useCallback((index: number) => {
224
+ const state = cellsRef.current[index];
225
+ onSetCellsRef.current([index], state === "excluded" ? "empty" : "excluded");
226
+ }, []);
227
+
228
+ const toggleQueen = useCallback((index: number) => {
229
+ const state = cellsRef.current[index];
230
+ onSetCellsRef.current([index], state === "marked" ? "empty" : "marked");
231
+ }, []);
232
+
233
+ const handleTapRelease = useCallback(
234
+ (index: number) => {
235
+ const releasedAt = Date.now();
236
+ const pending = pendingTapsRef.current.get(index);
237
+ if (pending && releasedAt - pending.releasedAt <= DOUBLE_TAP_DELAY_MS) {
238
+ pending.cancel();
239
+ pendingTapsRef.current.delete(index);
240
+ toggleQueen(index);
241
+ return;
242
+ }
243
+
244
+ const timeout = setTimeout(() => {
245
+ pendingTapsRef.current.delete(index);
246
+ toggleExcluded(index);
247
+ }, DOUBLE_TAP_DELAY_MS);
248
+ pendingTapsRef.current.set(index, {
249
+ releasedAt,
250
+ cancel() {
251
+ clearTimeout(timeout);
252
+ },
253
+ });
254
+ },
255
+ [toggleExcluded, toggleQueen],
256
+ );
257
+
258
+ const handleResponderGrant = useCallback(
259
+ (event: GestureResponderEvent) => {
260
+ event.preventDefault();
261
+ event.stopPropagation();
262
+ const index = cellIndexAtLocation(event, puzzle.size, layoutRef.current);
263
+ if (index === null) return;
264
+ gestureRef.current = {
265
+ startIndex: index,
266
+ indexes: new Set([index]),
267
+ replacement: cellsRef.current[index] === "excluded" ? "empty" : "excluded",
268
+ moved: false,
269
+ cancelled: false,
270
+ };
271
+ onGestureActiveChangeRef.current(true);
272
+ },
273
+ [puzzle.size],
274
+ );
275
+
276
+ const handleResponderMove = useCallback(
277
+ (event: GestureResponderEvent) => {
278
+ event.preventDefault();
279
+ event.stopPropagation();
280
+ const gesture = gestureRef.current;
281
+ const index = cellIndexAtLocation(event, puzzle.size, layoutRef.current);
282
+ if (!gesture || index === null || gesture.indexes.has(index)) return;
283
+ if (!dragEnabled) {
284
+ if (index !== gesture.startIndex) gesture.cancelled = true;
285
+ return;
286
+ }
287
+
288
+ gesture.indexes.add(index);
289
+ if (index !== gesture.startIndex) gesture.moved = true;
290
+ setDragPreview({ indexes: new Set(gesture.indexes), replacement: gesture.replacement });
291
+ },
292
+ [dragEnabled, puzzle.size],
293
+ );
294
+ const finishGesture = useCallback(() => {
295
+ const gesture = gestureRef.current;
296
+ gestureRef.current = null;
297
+ setDragPreview(null);
298
+ onGestureActiveChangeRef.current(false);
299
+ if (!gesture) return;
300
+
301
+ if (gesture.cancelled) return;
302
+ if (!gesture.moved) {
303
+ handleTapRelease(gesture.startIndex);
304
+ return;
305
+ }
306
+
307
+ for (const index of gesture.indexes) {
308
+ const pending = pendingTapsRef.current.get(index);
309
+ pending?.cancel();
310
+ pendingTapsRef.current.delete(index);
311
+ }
312
+ const indexes =
313
+ gesture.replacement === "empty"
314
+ ? [...gesture.indexes].filter((index) => cellsRef.current[index] === "excluded")
315
+ : [...gesture.indexes];
316
+ onSetCellsRef.current(indexes, gesture.replacement);
317
+ }, [handleTapRelease]);
318
+
319
+ const cancelGesture = useCallback(() => {
320
+ gestureRef.current = null;
321
+ setDragPreview(null);
322
+ onGestureActiveChangeRef.current(false);
323
+ }, []);
324
+
325
+ return (
326
+ <View style={[styles.boardFrame, maxSize === undefined ? null : { maxWidth: maxSize }]}>
327
+ <View style={styles.boardGrid}>
328
+ {coordinates.map((row) => (
329
+ <View key={`${puzzle.id}:row:${row}`} style={styles.boardRow}>
330
+ {coordinates.map((column) => {
331
+ const index = row * puzzle.size + column;
332
+ const persistedState = cells[index] ?? "empty";
333
+ let state = persistedState;
334
+ if (dragPreview?.indexes.has(index)) {
335
+ if (dragPreview.replacement === "excluded" || persistedState === "excluded") {
336
+ state = dragPreview.replacement;
337
+ }
338
+ }
339
+ const region = puzzle.regions[index] ?? 0;
340
+ const conflicted = conflicts.has(index);
341
+ const markColor = solved
342
+ ? theme.colors.statusSuccess
343
+ : conflicted
344
+ ? theme.colors.statusDanger
345
+ : theme.colors.foreground;
346
+ const accessibilityLabel = [
347
+ `Row ${row + 1}`,
348
+ `column ${column + 1}`,
349
+ `region ${region + 1}`,
350
+ `state ${CELL_STATE_LABEL[state]}`,
351
+ conflicted ? "conflict" : "no conflict",
352
+ solved ? "puzzle solved" : undefined,
353
+ ]
354
+ .filter(Boolean)
355
+ .join(", ");
356
+
357
+ return (
358
+ <Pressable
359
+ key={`${puzzle.id}:cell:${row}:${column}:region:${region}`}
360
+ accessibilityRole="button"
361
+ accessibilityLabel={accessibilityLabel}
362
+ accessibilityHint={disabled ? undefined : CELL_ACTION_HINT[state]}
363
+ accessibilityState={{
364
+ disabled,
365
+ selected: state === "marked",
366
+ }}
367
+ delayLongPress={DOUBLE_TAP_DELAY_MS}
368
+ disabled={disabled}
369
+ onLongPress={() => toggleQueen(index)}
370
+ onPress={() => toggleExcluded(index)}
371
+ style={({ pressed }) => [
372
+ styles.cell,
373
+ {
374
+ backgroundColor:
375
+ regionFills[Math.abs(region) % regionFills.length] ?? theme.colors.surface1,
376
+ },
377
+ disabled && !solved && styles.disabledCell,
378
+ pressed && !disabled && styles.pressedCell,
379
+ ]}
380
+ >
381
+ {state === "marked" ? (
382
+ <GameMark size={markSize} color={markColor} conflicted={conflicted} />
383
+ ) : state === "excluded" ? (
384
+ <Text
385
+ accessible={false}
386
+ importantForAccessibility="no"
387
+ style={styles.excludedMark}
388
+ >
389
+ ×
390
+ </Text>
391
+ ) : null}
392
+ {conflicted ? (
393
+ <Text
394
+ accessible={false}
395
+ importantForAccessibility="no"
396
+ style={styles.conflictIndicator}
397
+ >
398
+ !
399
+ </Text>
400
+ ) : null}
401
+ {conflicted ? (
402
+ <>
403
+ <View
404
+ accessible={false}
405
+ importantForAccessibility="no"
406
+ pointerEvents="none"
407
+ style={styles.conflictTint}
408
+ />
409
+ <View
410
+ accessible={false}
411
+ importantForAccessibility="no"
412
+ pointerEvents="none"
413
+ style={styles.conflictBorder}
414
+ />
415
+ </>
416
+ ) : null}
417
+ </Pressable>
418
+ );
419
+ })}
420
+ </View>
421
+ ))}
422
+
423
+ {/* Region boundaries are drawn once, centered on the grid lines, above every cell so
424
+ adjacent segments overlap at joins instead of leaving one-sided notches. */}
425
+ <View
426
+ accessible={false}
427
+ importantForAccessibility="no-hide-descendants"
428
+ pointerEvents="none"
429
+ style={styles.boundaryLayer}
430
+ >
431
+ {coordinates.map((row) => (
432
+ <View key={`${puzzle.id}:boundary-row:${row}`} style={styles.boardRow}>
433
+ {coordinates.map((column) => {
434
+ const index = row * puzzle.size + column;
435
+ const region = puzzle.regions[index] ?? 0;
436
+ const aboveRegion = row > 0 ? puzzle.regions[index - puzzle.size] : region;
437
+ const leftRegion = column > 0 ? puzzle.regions[index - 1] : region;
438
+
439
+ return (
440
+ <View key={`${puzzle.id}:boundary:${row}:${column}`} style={styles.boundaryCell}>
441
+ {aboveRegion !== region ? <View style={styles.boundaryTop} /> : null}
442
+ {leftRegion !== region ? <View style={styles.boundaryLeft} /> : null}
443
+ </View>
444
+ );
445
+ })}
446
+ </View>
447
+ ))}
448
+ </View>
449
+ <View
450
+ accessible={false}
451
+ importantForAccessibility="no-hide-descendants"
452
+ onLayout={handleBoardLayout}
453
+ onMoveShouldSetResponder={() => !disabled}
454
+ onMoveShouldSetResponderCapture={() => !disabled}
455
+ onResponderGrant={handleResponderGrant}
456
+ onResponderMove={handleResponderMove}
457
+ onResponderRelease={(event) => {
458
+ event.preventDefault();
459
+ event.stopPropagation();
460
+ finishGesture();
461
+ }}
462
+ onResponderTerminate={cancelGesture}
463
+ onResponderTerminationRequest={() => !dragEnabled}
464
+ onStartShouldSetResponder={() => !disabled}
465
+ onStartShouldSetResponderCapture={() => !disabled}
466
+ pointerEvents={disabled ? "none" : "auto"}
467
+ style={styles.gestureLayer}
468
+ />
469
+ </View>
470
+ </View>
471
+ );
472
+ }
473
+
474
+ const BOUNDARY_WIDTH = 2;
475
+ const FRAME_RADIUS = 10;
476
+
477
+ function createStyles(
478
+ theme: PluginSurfaceProps["theme"],
479
+ compact: boolean,
480
+ size: number,
481
+ maxSize?: number,
482
+ ) {
483
+ const nominalBoardSize = maxSize ?? (compact ? 360 : 440);
484
+ const cellSize = nominalBoardSize / size;
485
+ const excludedSize = Math.max(10, Math.min(compact ? 27 : 32, Math.floor(cellSize * 0.72)));
486
+ const conflictSize = Math.max(7, Math.min(compact ? 13 : 15, Math.floor(cellSize * 0.36)));
487
+ const overhang = (BOUNDARY_WIDTH + StyleSheet.hairlineWidth) / 2;
488
+ return StyleSheet.create({
489
+ boardFrame: {
490
+ width: "100%",
491
+ maxWidth: compact ? 360 : 440,
492
+ aspectRatio: 1,
493
+ alignSelf: "center",
494
+ borderWidth: BOUNDARY_WIDTH,
495
+ borderColor: theme.colors.foreground,
496
+ borderRadius: FRAME_RADIUS,
497
+ backgroundColor: theme.colors.foreground,
498
+ },
499
+ boardGrid: {
500
+ flex: 1,
501
+ gap: StyleSheet.hairlineWidth,
502
+ overflow: "hidden",
503
+ borderRadius: FRAME_RADIUS - BOUNDARY_WIDTH,
504
+ backgroundColor: theme.colors.border,
505
+ },
506
+ boardRow: {
507
+ flex: 1,
508
+ flexDirection: "row",
509
+ gap: StyleSheet.hairlineWidth,
510
+ },
511
+ cell: {
512
+ flex: 1,
513
+ alignItems: "center",
514
+ justifyContent: "center",
515
+ },
516
+ boundaryLayer: {
517
+ position: "absolute",
518
+ top: 0,
519
+ right: 0,
520
+ bottom: 0,
521
+ left: 0,
522
+ gap: StyleSheet.hairlineWidth,
523
+ },
524
+ boundaryCell: {
525
+ flex: 1,
526
+ },
527
+ boundaryTop: {
528
+ position: "absolute",
529
+ top: -overhang,
530
+ left: -overhang,
531
+ right: -overhang,
532
+ height: BOUNDARY_WIDTH,
533
+ backgroundColor: theme.colors.foreground,
534
+ },
535
+ gestureLayer: {
536
+ position: "absolute",
537
+ top: 0,
538
+ right: 0,
539
+ bottom: 0,
540
+ left: 0,
541
+ },
542
+ boundaryLeft: {
543
+ position: "absolute",
544
+ top: -overhang,
545
+ bottom: -overhang,
546
+ left: -overhang,
547
+ width: BOUNDARY_WIDTH,
548
+ backgroundColor: theme.colors.foreground,
549
+ },
550
+ conflictTint: {
551
+ position: "absolute",
552
+ top: 3,
553
+ right: 3,
554
+ bottom: 3,
555
+ left: 3,
556
+ backgroundColor: theme.colors.statusDanger,
557
+ opacity: 0.12,
558
+ },
559
+ conflictBorder: {
560
+ position: "absolute",
561
+ top: 3,
562
+ right: 3,
563
+ bottom: 3,
564
+ left: 3,
565
+ borderWidth: 2,
566
+ borderColor: theme.colors.statusDanger,
567
+ borderRadius: 3,
568
+ },
569
+ disabledCell: {
570
+ opacity: 0.54,
571
+ },
572
+ pressedCell: {
573
+ opacity: 0.66,
574
+ transform: [{ scale: 0.97 }],
575
+ },
576
+ conflictIndicator: {
577
+ position: "absolute",
578
+ top: 4,
579
+ right: 7,
580
+ color: theme.colors.statusDanger,
581
+ fontSize: conflictSize,
582
+ lineHeight: conflictSize + 2,
583
+ fontWeight: "800",
584
+ },
585
+ excludedMark: {
586
+ color: theme.colors.foregroundMuted,
587
+ fontSize: excludedSize,
588
+ lineHeight: excludedSize + 4,
589
+ fontWeight: "300",
590
+ },
591
+ });
592
+ }