the puzzle grid

This commit is contained in:
2025-12-05 18:32:05 +02:00
parent 52eb024f61
commit 48b2e23f32
+197
View File
@@ -0,0 +1,197 @@
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { motion, AnimatePresence } from 'motion/react'
import { useStore } from '../store'
import type { CellStatus } from '../types'
import InkOverlay from './InkOverlay'
import CellFlip from './CellFlip'
function getCellSize(gridSize: number): number {
if (gridSize <= 5) return 64
if (gridSize <= 7) return 54
if (gridSize <= 9) return 46
if (gridSize <= 13) return 40
return 36
}
export default function Grid() {
const { t } = useTranslation()
const svgRef = useRef<SVGSVGElement>(null)
const puzzle = useStore((s) => s.puzzle)
const entries = useStore((s) => s.entries)
const cellStatus = useStore((s) => s.cellStatus)
const selectedCell = useStore((s) => s.selectedCell)
const direction = useStore((s) => s.direction)
const activeClue = useStore((s) => s.activeClue)
const wordSpans = useStore((s) => s.wordSpans)
const selectCell = useStore((s) => s.selectCell)
const pencilEntries = useStore((s) => s.pencilEntries)
const mpPlayers = useStore((s) => s.mpPlayers)
const inputMode = useStore((s) => s.inputMode)
if (!puzzle) return null
const { width, height } = puzzle
const CELL = getCellSize(Math.max(width, height))
const NUM_SIZE = Math.round(CELL * 0.26)
const LETTER_SIZE = Math.round(CELL * 0.52)
const activeSpan = wordSpans.find(
(s) => s.number === activeClue && s.direction === direction,
)
const activeCells = new Set(
activeSpan?.cells.map(([r, c]) => `${r},${c}`) ?? [],
)
return (
<div
className={`grid-wrapper${inputMode === 'stylus' ? ' stylus-active' : ''}`}
style={{ maxWidth: width * CELL, width: '100%' }}
onContextMenu={inputMode === 'stylus' ? (e => e.preventDefault()) : undefined}
>
<svg
ref={svgRef}
viewBox={`0 0 ${width * CELL} ${height * CELL}`}
style={{ width: '100%', maxWidth: width * CELL, display: 'block' }}
tabIndex={0}
role="grid"
aria-label={t('grid.ariaLabel', { width, height })}
aria-activedescendant={selectedCell ? `cell-${selectedCell.row}-${selectedCell.col}` : undefined}
>
<defs>
<style>{`
.cell-num { font-family: 'DM Sans', system-ui, sans-serif; }
.cell-letter { font-family: 'JetBrains Mono', ui-monospace, monospace; }
`}</style>
</defs>
{Array.from({ length: height }, (_, r) =>
Array.from({ length: width }, (_, c) => {
const isBlack = puzzle.cells[r][c] === 'black'
const isSelected = selectedCell?.row === r && selectedCell?.col === c
const isActive = activeCells.has(`${r},${c}`)
const num = puzzle.cell_numbers[`${r},${c}`]
const letter = entries[r]?.[c] || ''
const pencilLetter = !letter && pencilEntries[r]?.[c] ? pencilEntries[r][c] : ''
const status: CellStatus = cellStatus[r]?.[c] || 'default'
const x = c * CELL
const y = r * CELL
let fill = 'var(--cell-white)'
if (isBlack) fill = 'var(--cell-black)'
else if (isSelected) fill = 'var(--cell-selected)'
else if (isActive) fill = 'var(--cell-active)'
let textColor = 'var(--text)'
if (status === 'correct') textColor = 'var(--correct)'
if (status === 'incorrect') textColor = 'var(--incorrect)'
if (status === 'revealed') textColor = 'var(--revealed)'
const ariaLabel = isBlack ? t('grid.blackCell') :
t('grid.cellLabel', { row: r + 1, col: c + 1 }) + (num ? t('grid.cellNumber', { num }) : '') + (letter ? t('grid.cellLetter', { letter }) : t('grid.cellEmpty'))
return (
<g
key={`${r},${c}`}
id={`cell-${r}-${c}`}
data-cell={`${r},${c}`}
onClick={() => !isBlack && selectCell(r, c)}
style={{ cursor: isBlack ? 'default' : 'pointer' }}
role="gridcell"
aria-label={ariaLabel}
>
<rect
x={x} y={y}
width={CELL} height={CELL}
fill={fill}
stroke="var(--cell-border)"
strokeWidth={0.5}
/>
{!isBlack && num && (
<text
className="cell-num"
x={x + 2.5} y={y + NUM_SIZE}
fontSize={NUM_SIZE}
fill="var(--text-tertiary)"
style={{ userSelect: 'none' }}
>
{num}
</text>
)}
{!isBlack && letter && (
<text
className="cell-letter"
x={x + CELL / 2}
y={y + CELL / 2 + LETTER_SIZE * 0.35}
fontSize={LETTER_SIZE}
textAnchor="middle"
fill={textColor}
fontWeight={500}
style={{ userSelect: 'none' }}
>
{letter}
</text>
)}
{!isBlack && pencilLetter && (
<text
className="cell-letter cell-pencil"
x={x + CELL / 2}
y={y + CELL / 2 + LETTER_SIZE * 0.28}
fontSize={LETTER_SIZE * 0.72}
textAnchor="middle"
fill="var(--text-tertiary)"
fontWeight={400}
fontStyle="italic"
opacity={0.7}
style={{ userSelect: 'none' }}
>
{pencilLetter}
</text>
)}
{status === 'correct' && (
<text x={x + CELL - NUM_SIZE} y={y + CELL - 3} fontSize={NUM_SIZE * 0.9}
fill="var(--correct)" aria-hidden="true" style={{ userSelect: 'none' }}>+</text>
)}
{status === 'incorrect' && (
<text x={x + CELL - NUM_SIZE} y={y + CELL - 3} fontSize={NUM_SIZE * 0.9}
fill="var(--incorrect)" aria-hidden="true" style={{ userSelect: 'none' }}>x</text>
)}
{status === 'revealed' && (
<text x={x + CELL - NUM_SIZE} y={y + CELL - 3} fontSize={NUM_SIZE * 0.9}
fill="var(--revealed)" aria-hidden="true" style={{ userSelect: 'none' }}>*</text>
)}
</g>
)
}),
)}
<AnimatePresence>
{mpPlayers.filter(p => p.cursor).map(p => {
const [r, c] = p.cursor!
return (
<motion.rect key={`cursor-${p.id}`}
x={c * CELL} y={r * CELL}
width={CELL} height={CELL}
fill={p.color}
aria-hidden="true"
style={{ pointerEvents: 'none' }}
initial={{ opacity: 0 }}
animate={{ opacity: 0.2 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
/>
)
})}
</AnimatePresence>
<rect
x={0} y={0}
width={width * CELL} height={height * CELL}
fill="none"
stroke="var(--cell-black)"
strokeWidth={1.5}
/>
</svg>
<CellFlip svgRef={svgRef} />
<InkOverlay svgRef={svgRef} />
</div>
)
}