539 lines
18 KiB
TypeScript
539 lines
18 KiB
TypeScript
import React from 'react'
|
|
import { getCaClassFromParams, supportsCaClass } from './caRuntime.js'
|
|
import type { CaClass, Cells, SceneParams } from './types.js'
|
|
import { Voxel3DRenderer } from './voxel3d/Voxel3DRenderer.js'
|
|
|
|
interface CaRendererProps {
|
|
caption: string
|
|
cells: Cells
|
|
settings: SceneParams
|
|
onCellsChange: (cells: Cells) => void
|
|
}
|
|
|
|
export interface CaRendererRuntime {
|
|
id: string
|
|
label: string
|
|
aliases?: string[]
|
|
supportedClasses: CaClass[]
|
|
Component: React.ComponentType<CaRendererProps>
|
|
}
|
|
|
|
const rendererRuntimes = new Map<string, CaRendererRuntime>()
|
|
|
|
export function registerCaRendererRuntime(runtime: CaRendererRuntime, options: { replace?: boolean } = {}) {
|
|
if (!runtime.id.trim()) throw new Error('CA renderer runtime id is required')
|
|
if (rendererRuntimes.has(runtime.id) && !options.replace) {
|
|
throw new Error(`CA renderer runtime already registered: ${runtime.id}`)
|
|
}
|
|
rendererRuntimes.set(runtime.id, runtime)
|
|
}
|
|
|
|
export function listCaRendererRuntimes() {
|
|
return [...rendererRuntimes.values()]
|
|
}
|
|
|
|
export function listCompatibleCaRendererRuntimes(caClass: CaClass | undefined) {
|
|
return listCaRendererRuntimes().filter((runtime) => supportsCaClass(runtime, caClass))
|
|
}
|
|
|
|
export function getCaRendererRuntime(rendererId: string) {
|
|
return (
|
|
rendererRuntimes.get(rendererId) ??
|
|
[...rendererRuntimes.values()].find((runtime) => runtime.aliases?.includes(rendererId)) ??
|
|
null
|
|
)
|
|
}
|
|
|
|
export function CaRenderer({ caption, cells, settings, onCellsChange }: CaRendererProps) {
|
|
const rendererId = settings.renderer?.id ?? '2d-canvas'
|
|
const runtime = getCaRendererRuntime(rendererId)
|
|
const caClass = getCaClassFromParams(settings)
|
|
|
|
if (runtime && (!caClass || supportsCaClass(runtime, caClass))) {
|
|
const RendererComponent = runtime.Component
|
|
return <RendererComponent caption={caption} cells={cells} settings={settings} onCellsChange={onCellsChange} />
|
|
}
|
|
|
|
return (
|
|
<div className="board-wrap">
|
|
{caption.trim() ? <div className="renderer-caption">{caption}</div> : null}
|
|
<div className="renderer-empty">
|
|
{runtime ? `Renderer ${rendererId} does not support this CA space` : `Renderer unavailable: ${rendererId}`}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Voxel3DRendererAdapter({ caption, settings }: CaRendererProps) {
|
|
return <Voxel3DRenderer caption={caption} settings={settings} />
|
|
}
|
|
|
|
function rendererNumber(value: unknown, fallback: number, min: number, max: number) {
|
|
return typeof value === 'number' && Number.isFinite(value) ? Math.max(min, Math.min(max, value)) : fallback
|
|
}
|
|
|
|
function parseElementaryRule(settings: SceneParams) {
|
|
const rendererRule = settings.renderer?.elementaryRule
|
|
if (typeof rendererRule === 'number') return Math.max(0, Math.min(255, Math.floor(rendererRule)))
|
|
|
|
const ruleId = settings.simulation?.ruleId ?? ''
|
|
const match = /(?:rule[-_\s]*)?(\d{1,3})/i.exec(ruleId)
|
|
if (!match) return 110
|
|
return Math.max(0, Math.min(255, Number(match[1])))
|
|
}
|
|
|
|
function nextElementaryRow(row: boolean[], rule: number) {
|
|
return row.map((_, index) => {
|
|
const left = row[(index - 1 + row.length) % row.length] ? 1 : 0
|
|
const center = row[index] ? 1 : 0
|
|
const right = row[(index + 1) % row.length] ? 1 : 0
|
|
const pattern = (left << 2) | (center << 1) | right
|
|
return ((rule >> pattern) & 1) === 1
|
|
})
|
|
}
|
|
|
|
function firstLiveRowIndex(cells: Cells) {
|
|
const index = cells.findIndex((row) => row.some(Boolean))
|
|
return index === -1 ? 0 : index
|
|
}
|
|
|
|
function Canvas2DRenderer({
|
|
caption,
|
|
cells,
|
|
onCellsChange
|
|
}: {
|
|
caption: string
|
|
cells: Cells
|
|
onCellsChange: (cells: Cells) => void
|
|
}) {
|
|
const canvasRef = React.useRef<HTMLCanvasElement | null>(null)
|
|
const cellsRef = React.useRef(cells)
|
|
const paintValueRef = React.useRef<boolean | null>(null)
|
|
const [canvasRevision, setCanvasRevision] = React.useState(0)
|
|
const width = cells[0]?.length ?? 0
|
|
const height = cells.length
|
|
|
|
React.useEffect(() => {
|
|
cellsRef.current = cells
|
|
}, [cells])
|
|
|
|
React.useEffect(() => {
|
|
const canvas = canvasRef.current
|
|
if (!canvas) return
|
|
|
|
const observer = new ResizeObserver(() => setCanvasRevision((revision) => revision + 1))
|
|
observer.observe(canvas)
|
|
return () => observer.disconnect()
|
|
}, [])
|
|
|
|
React.useEffect(() => {
|
|
const canvas = canvasRef.current
|
|
if (!canvas || width === 0 || height === 0) return
|
|
|
|
const context = canvas.getContext('2d')
|
|
if (!context) return
|
|
|
|
const rect = canvas.getBoundingClientRect()
|
|
const dpr = window.devicePixelRatio || 1
|
|
const pixelWidth = Math.max(1, Math.floor(rect.width * dpr))
|
|
const pixelHeight = Math.max(1, Math.floor(rect.height * dpr))
|
|
|
|
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
|
|
canvas.width = pixelWidth
|
|
canvas.height = pixelHeight
|
|
}
|
|
|
|
context.setTransform(dpr, 0, 0, dpr, 0, 0)
|
|
context.clearRect(0, 0, rect.width, rect.height)
|
|
context.fillStyle = '#071206'
|
|
context.fillRect(0, 0, rect.width, rect.height)
|
|
|
|
const cellSize = Math.min(rect.width / width, rect.height / height)
|
|
const boardWidth = cellSize * width
|
|
const boardHeight = cellSize * height
|
|
const offsetX = (rect.width - boardWidth) / 2
|
|
const offsetY = (rect.height - boardHeight) / 2
|
|
const gap = Math.max(1, Math.min(2, cellSize * 0.08))
|
|
|
|
context.strokeStyle = '#1e3519'
|
|
context.lineWidth = 1
|
|
|
|
for (let y = 0; y < height; y += 1) {
|
|
for (let x = 0; x < width; x += 1) {
|
|
const px = offsetX + x * cellSize + gap / 2
|
|
const py = offsetY + y * cellSize + gap / 2
|
|
const size = Math.max(1, cellSize - gap)
|
|
|
|
if (cells[y][x]) {
|
|
context.fillStyle = '#70f45f'
|
|
context.shadowColor = 'rgba(112, 244, 95, 0.42)'
|
|
context.shadowBlur = Math.max(0, cellSize * 0.38)
|
|
context.fillRect(px, py, size, size)
|
|
context.shadowBlur = 0
|
|
context.strokeStyle = '#b7ffab'
|
|
} else {
|
|
context.fillStyle = '#071206'
|
|
context.fillRect(px, py, size, size)
|
|
context.strokeStyle = '#1e3519'
|
|
}
|
|
context.strokeRect(px + 0.5, py + 0.5, Math.max(0, size - 1), Math.max(0, size - 1))
|
|
}
|
|
}
|
|
}, [canvasRevision, cells, height, width])
|
|
|
|
function cellFromPointer(event: React.PointerEvent<HTMLCanvasElement>) {
|
|
const canvas = canvasRef.current
|
|
if (!canvas || width === 0 || height === 0) return null
|
|
|
|
const rect = canvas.getBoundingClientRect()
|
|
const cellSize = Math.min(rect.width / width, rect.height / height)
|
|
const boardWidth = cellSize * width
|
|
const boardHeight = cellSize * height
|
|
const offsetX = (rect.width - boardWidth) / 2
|
|
const offsetY = (rect.height - boardHeight) / 2
|
|
const x = Math.floor((event.clientX - rect.left - offsetX) / cellSize)
|
|
const y = Math.floor((event.clientY - rect.top - offsetY) / cellSize)
|
|
|
|
if (x < 0 || y < 0 || x >= width || y >= height) return null
|
|
return { x, y }
|
|
}
|
|
|
|
function paintCell(x: number, y: number, value: boolean) {
|
|
const current = cellsRef.current
|
|
if (current[y]?.[x] === value) return
|
|
|
|
const next = current.map((row, rowIndex) =>
|
|
rowIndex === y ? row.map((alive, columnIndex) => (columnIndex === x ? value : alive)) : row
|
|
)
|
|
cellsRef.current = next
|
|
onCellsChange(next)
|
|
}
|
|
|
|
function handlePointerDown(event: React.PointerEvent<HTMLCanvasElement>) {
|
|
const cell = cellFromPointer(event)
|
|
if (!cell) return
|
|
|
|
event.currentTarget.setPointerCapture(event.pointerId)
|
|
const nextValue = !cellsRef.current[cell.y][cell.x]
|
|
paintValueRef.current = nextValue
|
|
paintCell(cell.x, cell.y, nextValue)
|
|
}
|
|
|
|
function handlePointerMove(event: React.PointerEvent<HTMLCanvasElement>) {
|
|
if (paintValueRef.current === null) return
|
|
const cell = cellFromPointer(event)
|
|
if (!cell) return
|
|
paintCell(cell.x, cell.y, paintValueRef.current)
|
|
}
|
|
|
|
function stopPainting(event: React.PointerEvent<HTMLCanvasElement>) {
|
|
paintValueRef.current = null
|
|
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
|
event.currentTarget.releasePointerCapture(event.pointerId)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="board-wrap">
|
|
{caption.trim() ? <div className="renderer-caption">{caption}</div> : null}
|
|
<canvas
|
|
ref={canvasRef}
|
|
aria-label="Game of Life cell editor"
|
|
className="board-canvas"
|
|
role="img"
|
|
onPointerCancel={stopPainting}
|
|
onPointerDown={handlePointerDown}
|
|
onPointerLeave={stopPainting}
|
|
onPointerMove={handlePointerMove}
|
|
onPointerUp={stopPainting}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Elementary1DRenderer({ caption, cells, settings, onCellsChange }: CaRendererProps) {
|
|
const canvasRef = React.useRef<HTMLCanvasElement | null>(null)
|
|
const cellsRef = React.useRef(cells)
|
|
const [canvasRevision, setCanvasRevision] = React.useState(0)
|
|
const width = cells[0]?.length ?? 0
|
|
const sourceRowIndex = Math.min(cells.length - 1, Math.max(0, firstLiveRowIndex(cells)))
|
|
const sourceRow = cells[sourceRowIndex] ?? []
|
|
const rule = parseElementaryRule(settings)
|
|
const historyRows = rendererNumber(settings.renderer?.historyRows, 96, 8, 512)
|
|
|
|
React.useEffect(() => {
|
|
cellsRef.current = cells
|
|
}, [cells])
|
|
|
|
React.useEffect(() => {
|
|
const canvas = canvasRef.current
|
|
if (!canvas) return
|
|
|
|
const observer = new ResizeObserver(() => setCanvasRevision((revision) => revision + 1))
|
|
observer.observe(canvas)
|
|
return () => observer.disconnect()
|
|
}, [])
|
|
|
|
React.useEffect(() => {
|
|
const canvas = canvasRef.current
|
|
if (!canvas || width === 0 || sourceRow.length === 0) return
|
|
|
|
const context = canvas.getContext('2d')
|
|
if (!context) return
|
|
|
|
const rect = canvas.getBoundingClientRect()
|
|
const dpr = window.devicePixelRatio || 1
|
|
const pixelWidth = Math.max(1, Math.floor(rect.width * dpr))
|
|
const pixelHeight = Math.max(1, Math.floor(rect.height * dpr))
|
|
|
|
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
|
|
canvas.width = pixelWidth
|
|
canvas.height = pixelHeight
|
|
}
|
|
|
|
context.setTransform(dpr, 0, 0, dpr, 0, 0)
|
|
context.clearRect(0, 0, rect.width, rect.height)
|
|
context.fillStyle = '#061006'
|
|
context.fillRect(0, 0, rect.width, rect.height)
|
|
|
|
const cellWidth = rect.width / width
|
|
const cellHeight = rect.height / historyRows
|
|
let row = [...sourceRow]
|
|
|
|
for (let y = 0; y < historyRows; y += 1) {
|
|
const age = y / Math.max(1, historyRows - 1)
|
|
for (let x = 0; x < width; x += 1) {
|
|
if (row[x]) {
|
|
const green = Math.round(190 + age * 55)
|
|
const red = Math.round(112 + age * 80)
|
|
context.fillStyle = `rgb(${red} ${green} 92)`
|
|
context.fillRect(x * cellWidth, y * cellHeight, Math.ceil(cellWidth), Math.ceil(cellHeight))
|
|
}
|
|
}
|
|
row = nextElementaryRow(row, rule)
|
|
}
|
|
|
|
context.strokeStyle = 'rgba(240, 201, 74, 0.32)'
|
|
context.lineWidth = 1
|
|
context.strokeRect(0.5, 0.5, Math.max(0, rect.width - 1), Math.max(0, rect.height - 1))
|
|
}, [canvasRevision, historyRows, rule, sourceRow, width])
|
|
|
|
function toggleColumn(event: React.PointerEvent<HTMLCanvasElement>) {
|
|
const canvas = canvasRef.current
|
|
if (!canvas || width === 0) return
|
|
|
|
const rect = canvas.getBoundingClientRect()
|
|
const x = Math.floor(((event.clientX - rect.left) / rect.width) * width)
|
|
if (x < 0 || x >= width) return
|
|
|
|
const current = cellsRef.current
|
|
const next = current.map((row, rowIndex) =>
|
|
rowIndex === sourceRowIndex ? row.map((alive, columnIndex) => (columnIndex === x ? !alive : alive)) : row
|
|
)
|
|
cellsRef.current = next
|
|
onCellsChange(next)
|
|
}
|
|
|
|
return (
|
|
<div className="board-wrap elementary-renderer-wrap">
|
|
{caption.trim() ? <div className="renderer-caption">{caption}</div> : null}
|
|
<canvas
|
|
ref={canvasRef}
|
|
aria-label={`Elementary cellular automaton rule ${rule}`}
|
|
className="elementary-canvas"
|
|
role="img"
|
|
onPointerDown={toggleColumn}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Wildfire2DRenderer({ caption, cells, onCellsChange }: CaRendererProps) {
|
|
const canvasRef = React.useRef<HTMLCanvasElement | null>(null)
|
|
const cellsRef = React.useRef(cells)
|
|
const paintValueRef = React.useRef<boolean | null>(null)
|
|
const [canvasRevision, setCanvasRevision] = React.useState(0)
|
|
const width = cells[0]?.length ?? 0
|
|
const height = cells.length
|
|
|
|
React.useEffect(() => {
|
|
cellsRef.current = cells
|
|
}, [cells])
|
|
|
|
React.useEffect(() => {
|
|
const canvas = canvasRef.current
|
|
if (!canvas) return
|
|
|
|
const observer = new ResizeObserver(() => setCanvasRevision((revision) => revision + 1))
|
|
observer.observe(canvas)
|
|
return () => observer.disconnect()
|
|
}, [])
|
|
|
|
React.useEffect(() => {
|
|
const canvas = canvasRef.current
|
|
if (!canvas || width === 0 || height === 0) return
|
|
|
|
const context = canvas.getContext('2d')
|
|
if (!context) return
|
|
|
|
const rect = canvas.getBoundingClientRect()
|
|
const dpr = window.devicePixelRatio || 1
|
|
const pixelWidth = Math.max(1, Math.floor(rect.width * dpr))
|
|
const pixelHeight = Math.max(1, Math.floor(rect.height * dpr))
|
|
|
|
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
|
|
canvas.width = pixelWidth
|
|
canvas.height = pixelHeight
|
|
}
|
|
|
|
context.setTransform(dpr, 0, 0, dpr, 0, 0)
|
|
context.clearRect(0, 0, rect.width, rect.height)
|
|
|
|
const cellSize = Math.min(rect.width / width, rect.height / height)
|
|
const boardWidth = cellSize * width
|
|
const boardHeight = cellSize * height
|
|
const offsetX = (rect.width - boardWidth) / 2
|
|
const offsetY = (rect.height - boardHeight) / 2
|
|
|
|
context.fillStyle = '#061006'
|
|
context.fillRect(0, 0, rect.width, rect.height)
|
|
|
|
for (let y = 0; y < height; y += 1) {
|
|
for (let x = 0; x < width; x += 1) {
|
|
const px = offsetX + x * cellSize
|
|
const py = offsetY + y * cellSize
|
|
|
|
if (cells[y][x]) {
|
|
const gradient = context.createRadialGradient(
|
|
px + cellSize * 0.5,
|
|
py + cellSize * 0.52,
|
|
cellSize * 0.08,
|
|
px + cellSize * 0.5,
|
|
py + cellSize * 0.52,
|
|
cellSize * 0.62
|
|
)
|
|
gradient.addColorStop(0, '#fff5b9')
|
|
gradient.addColorStop(0.45, '#f0c94a')
|
|
gradient.addColorStop(1, '#d9481e')
|
|
context.fillStyle = gradient
|
|
} else {
|
|
context.fillStyle = (x + y) % 2 === 0 ? '#143f1b' : '#0d2b14'
|
|
}
|
|
context.fillRect(px, py, Math.ceil(cellSize), Math.ceil(cellSize))
|
|
}
|
|
}
|
|
}, [canvasRevision, cells, height, width])
|
|
|
|
function cellFromPointer(event: React.PointerEvent<HTMLCanvasElement>) {
|
|
const canvas = canvasRef.current
|
|
if (!canvas || width === 0 || height === 0) return null
|
|
|
|
const rect = canvas.getBoundingClientRect()
|
|
const cellSize = Math.min(rect.width / width, rect.height / height)
|
|
const boardWidth = cellSize * width
|
|
const boardHeight = cellSize * height
|
|
const offsetX = (rect.width - boardWidth) / 2
|
|
const offsetY = (rect.height - boardHeight) / 2
|
|
const x = Math.floor((event.clientX - rect.left - offsetX) / cellSize)
|
|
const y = Math.floor((event.clientY - rect.top - offsetY) / cellSize)
|
|
|
|
if (x < 0 || y < 0 || x >= width || y >= height) return null
|
|
return { x, y }
|
|
}
|
|
|
|
function paintCell(x: number, y: number, value: boolean) {
|
|
const current = cellsRef.current
|
|
if (current[y]?.[x] === value) return
|
|
|
|
const next = current.map((row, rowIndex) =>
|
|
rowIndex === y ? row.map((alive, columnIndex) => (columnIndex === x ? value : alive)) : row
|
|
)
|
|
cellsRef.current = next
|
|
onCellsChange(next)
|
|
}
|
|
|
|
function handlePointerDown(event: React.PointerEvent<HTMLCanvasElement>) {
|
|
const cell = cellFromPointer(event)
|
|
if (!cell) return
|
|
|
|
event.currentTarget.setPointerCapture(event.pointerId)
|
|
const nextValue = !cellsRef.current[cell.y][cell.x]
|
|
paintValueRef.current = nextValue
|
|
paintCell(cell.x, cell.y, nextValue)
|
|
}
|
|
|
|
function handlePointerMove(event: React.PointerEvent<HTMLCanvasElement>) {
|
|
if (paintValueRef.current === null) return
|
|
const cell = cellFromPointer(event)
|
|
if (!cell) return
|
|
paintCell(cell.x, cell.y, paintValueRef.current)
|
|
}
|
|
|
|
function stopPainting(event: React.PointerEvent<HTMLCanvasElement>) {
|
|
paintValueRef.current = null
|
|
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
|
event.currentTarget.releasePointerCapture(event.pointerId)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="board-wrap wildfire-renderer-wrap">
|
|
{caption.trim() ? <div className="renderer-caption">{caption}</div> : null}
|
|
<canvas
|
|
ref={canvasRef}
|
|
aria-label="Wildfire cellular automaton editor"
|
|
className="wildfire-canvas"
|
|
role="img"
|
|
onPointerCancel={stopPainting}
|
|
onPointerDown={handlePointerDown}
|
|
onPointerLeave={stopPainting}
|
|
onPointerMove={handlePointerMove}
|
|
onPointerUp={stopPainting}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
registerCaRendererRuntime(
|
|
{
|
|
id: '2d-canvas',
|
|
label: '2D Canvas',
|
|
supportedClasses: [{ dimensions: 2, states: 2 }],
|
|
Component: Canvas2DRenderer
|
|
},
|
|
{ replace: true }
|
|
)
|
|
|
|
registerCaRendererRuntime(
|
|
{
|
|
id: 'elementary-1d',
|
|
label: 'Elementary 1D',
|
|
aliases: ['elementary-ca', '1d-canvas'],
|
|
supportedClasses: [{ dimensions: 1, states: 2 }],
|
|
Component: Elementary1DRenderer
|
|
},
|
|
{ replace: true }
|
|
)
|
|
|
|
registerCaRendererRuntime(
|
|
{
|
|
id: 'wildfire-2d',
|
|
label: 'Wildfire 2D',
|
|
aliases: ['forest-fire-2d'],
|
|
supportedClasses: [{ dimensions: 2, states: 2 }],
|
|
Component: Wildfire2DRenderer
|
|
},
|
|
{ replace: true }
|
|
)
|
|
|
|
registerCaRendererRuntime(
|
|
{
|
|
id: 'voxel-3d',
|
|
label: 'Voxel 3D',
|
|
aliases: ['three-voxel'],
|
|
supportedClasses: [{ dimensions: 3, states: 2 }],
|
|
Component: Voxel3DRendererAdapter
|
|
},
|
|
{ replace: true }
|
|
)
|