Files
glitch_automata_lab/backend/admin/src/CaRenderer.tsx
T
2026-07-01 12:10:12 +02:00

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 }
)