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 } const rendererRuntimes = new Map() 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 } return (
{caption.trim() ?
{caption}
: null}
{runtime ? `Renderer ${rendererId} does not support this CA space` : `Renderer unavailable: ${rendererId}`}
) } function Voxel3DRendererAdapter({ caption, settings }: CaRendererProps) { return } 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(null) const cellsRef = React.useRef(cells) const paintValueRef = React.useRef(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) { 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) { 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) { if (paintValueRef.current === null) return const cell = cellFromPointer(event) if (!cell) return paintCell(cell.x, cell.y, paintValueRef.current) } function stopPainting(event: React.PointerEvent) { paintValueRef.current = null if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId) } } return (
{caption.trim() ?
{caption}
: null}
) } function Elementary1DRenderer({ caption, cells, settings, onCellsChange }: CaRendererProps) { const canvasRef = React.useRef(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) { 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 (
{caption.trim() ?
{caption}
: null}
) } function Wildfire2DRenderer({ caption, cells, onCellsChange }: CaRendererProps) { const canvasRef = React.useRef(null) const cellsRef = React.useRef(cells) const paintValueRef = React.useRef(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) { 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) { 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) { if (paintValueRef.current === null) return const cell = cellFromPointer(event) if (!cell) return paintCell(cell.x, cell.y, paintValueRef.current) } function stopPainting(event: React.PointerEvent) { paintValueRef.current = null if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId) } } return (
{caption.trim() ?
{caption}
: null}
) } 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 } )