Adding the lab project
This commit is contained in:
@@ -0,0 +1,538 @@
|
||||
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 }
|
||||
)
|
||||
Reference in New Issue
Block a user