commit 48ce52b52cfc06c29b794359eaa192817fa27296 Author: jenstandstad Date: Wed Jul 1 12:10:12 2026 +0200 Adding the lab project diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..9a29a57 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,54 @@ +# Deploy CA Lab Studio + +Deploy `glitch_voxel_automata_lab`, not `glitch_voxel_automata`. + +This app is the CA Lab Studio service: + +- Express API at `/api/ca/*` +- React admin app at `/admin` +- React viewer/recorder routes at `/view/*` +- Postgres database via `DATABASE_URL` + +## Build + +```bash +nvm use 20 +npm --prefix backend ci +npm run build +``` + +The build creates: + +```txt +backend/dist/server.js +backend/public/admin/index.html +backend/public/admin/assets/* +``` + +## Start + +```bash +DATABASE_URL=postgres://... PORT=3100 npm start +``` + +Run migrations before starting or during release: + +```bash +DATABASE_URL=postgres://... npm run migrate +``` + +## Docker + +```bash +docker build -t ca-lab-studio . +docker run --rm -p 3100:3100 \ + -e DATABASE_URL=postgres://... \ + ca-lab-studio +``` + +The container serves the app at: + +```txt +http://localhost:3100/admin +http://localhost:3100/view/decks/:deckId +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0d324a3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM node:20-bookworm-slim AS build + +WORKDIR /app/backend + +COPY backend/package*.json ./ +RUN npm ci + +COPY backend ./ +RUN npm run build + +FROM node:20-bookworm-slim AS runtime + +ENV NODE_ENV=production +WORKDIR /app/backend + +COPY backend/package*.json ./ +RUN npm ci --omit=dev + +COPY --from=build /app/backend/dist ./dist +COPY --from=build /app/backend/public ./public +COPY backend/scripts ./scripts +COPY backend/src/migrations ./src/migrations + +EXPOSE 3100 + +CMD ["npm", "start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..0b10ff0 --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# CA Lab Studio + +This folder contains the CA Lab Studio application: an Express API, Postgres-backed preset/deck data model, React admin interface, and viewer/recorder routes. + +The original embeddable GlitchComponent has been moved to the sibling folder: + +```txt +/Users/jenstandstad/Projects/glitch-components/glitch_voxel_automata +``` + +## Run Locally + +Start the database: + +```bash +cd backend +docker compose up -d +``` + +Run migrations: + +```bash +DATABASE_URL=postgres://ca_studio:ca_studio@localhost:54329/ca_studio_test npm run migrate +``` + +Start the API: + +```bash +DATABASE_URL=postgres://ca_studio:ca_studio@localhost:54329/ca_studio_test npm run dev +``` + +In another terminal, start the admin UI: + +```bash +npm run admin:dev +``` + +Open: + +```txt +http://localhost:5174/admin/ +``` + +## Deployment Note + +Deploy this app, not the sibling GlitchComponent package. See [DEPLOY.md](/Users/jenstandstad/Projects/glitch-components/glitch_voxel_automata_lab/DEPLOY.md). + +The production build compiles the Express API to `backend/dist` and emits the admin/viewer app to `backend/public/admin`. diff --git a/backend/admin/index.html b/backend/admin/index.html new file mode 100644 index 0000000..1c8ee12 --- /dev/null +++ b/backend/admin/index.html @@ -0,0 +1,12 @@ + + + + + + CA Studio Admin + + +
+ + + diff --git a/backend/admin/src/CaRenderer.tsx b/backend/admin/src/CaRenderer.tsx new file mode 100644 index 0000000..cca7a37 --- /dev/null +++ b/backend/admin/src/CaRenderer.tsx @@ -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 +} + +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 } +) diff --git a/backend/admin/src/PresetAssetTree.tsx b/backend/admin/src/PresetAssetTree.tsx new file mode 100644 index 0000000..6d3726a --- /dev/null +++ b/backend/admin/src/PresetAssetTree.tsx @@ -0,0 +1,113 @@ +import React from 'react' +import type { PresetNode } from './types.js' + +interface PresetAssetTreeProps { + assignedNodeId?: string + nodes: PresetNode[] + selectedNodeId?: string + onSelect: (node: PresetNode) => void +} + +interface TreeItem { + node: PresetNode + children: TreeItem[] +} + +function compareNodes(left: PresetNode, right: PresetNode) { + return left.sort_order - right.sort_order || left.name.localeCompare(right.name) +} + +function buildTree(nodes: PresetNode[]) { + const byParent = new Map() + for (const node of nodes) { + const siblings = byParent.get(node.parent_id) ?? [] + siblings.push(node) + byParent.set(node.parent_id, siblings) + } + + function build(parentId: string | null): TreeItem[] { + return (byParent.get(parentId) ?? []) + .sort(compareNodes) + .map((node) => ({ node, children: build(node.id) })) + } + + return build(null) +} + +export function PresetAssetTree({ + assignedNodeId, + nodes, + selectedNodeId, + onSelect +}: PresetAssetTreeProps) { + const tree = React.useMemo(() => buildTree(nodes), [nodes]) + + if (tree.length === 0) return

This CA library has no presets.

+ + return ( +
+ {tree.map((item) => ( + + ))} +
+ ) +} + +function AssetTreeItem({ + assignedNodeId, + item, + selectedNodeId, + onSelect +}: { + assignedNodeId?: string + item: TreeItem + selectedNodeId?: string + onSelect: (node: PresetNode) => void +}) { + const [open, setOpen] = React.useState(true) + const selected = item.node.id === selectedNodeId + const assigned = item.node.id === assignedNodeId + + return ( +
+
+ + +
+ {open && item.children.length ? ( +
+ {item.children.map((child) => ( + + ))} +
+ ) : null} +
+ ) +} diff --git a/backend/admin/src/PresetNodeTree.tsx b/backend/admin/src/PresetNodeTree.tsx new file mode 100644 index 0000000..147f337 --- /dev/null +++ b/backend/admin/src/PresetNodeTree.tsx @@ -0,0 +1,679 @@ +import React from 'react' +import { getCaClassFromParams, supportsCaClass } from './caRuntime.js' +import type { CaEngine, JsonObject, PresetNode } from './types.js' + +interface PresetNodeTreeProps { + assignedNodeId?: string + engines?: CaEngine[] + nodes: PresetNode[] + selectedNodeId?: string + usedNodeIds: Set + onCreateChild: (node: PresetNode) => void + onCreateGroup: (node: PresetNode) => void + onDelete: (node: PresetNode) => boolean | void | Promise + onMove: (node: PresetNode, direction: 1 | -1) => void + onRename: (node: PresetNode, name: string) => void | Promise + onSelect: (node: PresetNode) => void + onSet: (node: PresetNode, path: string[], value: unknown) => void + onUnset: (node: PresetNode, path: string[]) => void +} + +interface TreeItem { + node: PresetNode + children: TreeItem[] + siblingIndex: number + siblingCount: number +} + +interface VisibleTreeItem { + item: TreeItem + inheritedParams: JsonObject + parentId: string | null +} + +function isPlainObject(value: unknown): value is JsonObject { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +function valuesEqual(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right) +} + +function getParamPath(value: JsonObject, path: string[]) { + let current: unknown = value + for (const segment of path) { + if (!isPlainObject(current) || !(segment in current)) return undefined + current = current[segment] + } + return current +} + +function formatParamValue(path: string[], value: unknown) { + if (path.join('.') === 'simulation.initialCondition' && isPlainObject(value)) { + const cells = Array.isArray(value.cells) ? value.cells.length : 0 + return `${cells} live cells` + } + + if (Array.isArray(value)) return `${value.length} items` + if (isPlainObject(value)) return 'object' + return String(value) +} + +function mergeParamObjects(base: JsonObject, patch: JsonObject): JsonObject { + const merged: JsonObject = { ...base } + + for (const [key, value] of Object.entries(patch)) { + const current = merged[key] + merged[key] = isPlainObject(current) && isPlainObject(value) ? mergeParamObjects(current, value) : value + } + + return merged +} + +function compareNodesByOrder(left: PresetNode, right: PresetNode) { + return left.sort_order - right.sort_order || left.name.localeCompare(right.name) || left.slug.localeCompare(right.slug) +} + +function buildTree(nodes: PresetNode[]): TreeItem[] { + const byParent = new Map() + for (const node of nodes) { + const siblings = byParent.get(node.parent_id) ?? [] + siblings.push(node) + byParent.set(node.parent_id, siblings) + } + + function build(parentId: string | null): TreeItem[] { + const siblings = (byParent.get(parentId) ?? []).sort(compareNodesByOrder) + return siblings.map((node, index) => ({ + node, + children: build(node.id), + siblingIndex: index, + siblingCount: siblings.length + })) + } + + return build(null) +} + +function flattenVisibleTree( + items: TreeItem[], + expandedNodeIds: Set, + inheritedParams: JsonObject = {}, + parentId: string | null = null +): VisibleTreeItem[] { + const visible: VisibleTreeItem[] = [] + for (const item of items) { + visible.push({ item, inheritedParams, parentId }) + if (expandedNodeIds.has(item.node.id)) { + const resolvedParams = mergeParamObjects(inheritedParams, item.node.params) + visible.push(...flattenVisibleTree(item.children, expandedNodeIds, resolvedParams, item.node.id)) + } + } + return visible +} + +export function PresetNodeTree({ + assignedNodeId, + engines = [], + nodes, + selectedNodeId, + usedNodeIds, + onCreateChild, + onCreateGroup, + onDelete, + onMove, + onRename, + onSelect, + onSet, + onUnset +}: PresetNodeTreeProps) { + const tree = React.useMemo(() => buildTree(nodes), [nodes]) + const [expandedNodeIds, setExpandedNodeIds] = React.useState>( + () => new Set(nodes.map((node) => node.id)) + ) + const [editingNodeId, setEditingNodeId] = React.useState('') + const [openMenuNodeId, setOpenMenuNodeId] = React.useState('') + const visibleItems = React.useMemo( + () => flattenVisibleTree(tree, expandedNodeIds), + [expandedNodeIds, tree] + ) + + React.useEffect(() => { + setExpandedNodeIds((current) => { + const next = new Set([...current].filter((id) => nodes.some((node) => node.id === id))) + for (const node of nodes) { + if (!current.has(node.id)) next.add(node.id) + } + return next + }) + }, [nodes]) + + React.useEffect(() => { + if (!openMenuNodeId) return + + function closeMenu(event: PointerEvent) { + const target = event.target + if (!(target instanceof Element)) return + if (target.closest('.tree-node-action-menu') || target.closest('.node-action-trigger')) return + setOpenMenuNodeId('') + } + + function closeOnEscape(event: KeyboardEvent) { + if (event.key === 'Escape') setOpenMenuNodeId('') + } + + document.addEventListener('pointerdown', closeMenu) + document.addEventListener('keydown', closeOnEscape) + return () => { + document.removeEventListener('pointerdown', closeMenu) + document.removeEventListener('keydown', closeOnEscape) + } + }, [openMenuNodeId]) + + function toggleNode(nodeId: string, open?: boolean) { + setExpandedNodeIds((current) => { + const next = new Set(current) + const shouldOpen = open ?? !next.has(nodeId) + if (shouldOpen) next.add(nodeId) + else next.delete(nodeId) + return next + }) + } + + function handleTreeKeyDown(event: React.KeyboardEvent) { + if (!['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight', 'Enter'].includes(event.key)) return + const selectedIndex = visibleItems.findIndex(({ item }) => item.node.id === selectedNodeId) + const currentIndex = selectedIndex === -1 ? 0 : selectedIndex + const current = visibleItems[currentIndex] + if (!current) return + + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + const offset = event.key === 'ArrowDown' ? 1 : -1 + const next = visibleItems[Math.min(Math.max(currentIndex + offset, 0), visibleItems.length - 1)] + if (next) onSelect(next.item.node) + return + } + + if (event.key === 'ArrowRight') { + event.preventDefault() + if (current.item.children.length && !expandedNodeIds.has(current.item.node.id)) { + toggleNode(current.item.node.id, true) + } else if (current.item.children[0]) { + onSelect(current.item.children[0].node) + } + return + } + + if (event.key === 'ArrowLeft') { + event.preventDefault() + if (expandedNodeIds.has(current.item.node.id) && current.item.children.length) { + toggleNode(current.item.node.id, false) + } else if (current.parentId) { + const parent = nodes.find((node) => node.id === current.parentId) + if (parent) onSelect(parent) + } + return + } + + event.preventDefault() + setEditingNodeId(current.item.node.id) + } + + if (tree.length === 0) { + return

No nodes loaded.

+ } + + return ( +
+ {tree.map((node) => ( + setOpenMenuNodeId((current) => (current === nodeId ? '' : nodeId))} + onMove={onMove} + onRename={onRename} + onSelect={onSelect} + onSet={onSet} + onToggle={toggleNode} + onUnset={onUnset} + /> + ))} +
+ ) +} + +function TreeNodeView({ + item, + assignedNodeId, + engines, + inheritedParams, + selectedNodeId, + usedNodeIds, + expandedNodeIds, + editingNodeId, + openMenuNodeId, + onCreateChild, + onCreateGroup, + onDelete, + onEdit, + onMenuToggle, + onMove, + onRename, + onSelect, + onSet, + onToggle, + onUnset +}: { + item: TreeItem + assignedNodeId?: string + engines: CaEngine[] + inheritedParams: JsonObject + selectedNodeId?: string + usedNodeIds: Set + expandedNodeIds: Set + editingNodeId: string + openMenuNodeId: string + onCreateChild: (node: PresetNode) => void + onCreateGroup: (node: PresetNode) => void + onDelete: (node: PresetNode) => boolean | void | Promise + onEdit: (nodeId: string) => void + onMenuToggle: (nodeId: string) => void + onMove: (node: PresetNode, direction: 1 | -1) => void + onRename: (node: PresetNode, name: string) => void | Promise + onSelect: (node: PresetNode) => void + onSet: (node: PresetNode, path: string[], value: unknown) => void + onToggle: (nodeId: string, open?: boolean) => void + onUnset: (node: PresetNode, path: string[]) => void +}) { + const open = expandedNodeIds.has(item.node.id) + const menuOpen = openMenuNodeId === item.node.id + const selected = item.node.id === selectedNodeId + const assigned = item.node.id === assignedNodeId + const used = usedNodeIds.has(item.node.id) + const usedDescendantCount = React.useMemo(() => { + function countUsedDescendants(children: TreeItem[]): number { + return children.reduce( + (total, child) => + total + (usedNodeIds.has(child.node.id) ? 1 : 0) + countUsedDescendants(child.children), + 0 + ) + } + return countUsedDescendants(item.children) + }, [item.children, usedNodeIds]) + const resolvedParams = mergeParamObjects(inheritedParams, item.node.params) + const kindLabel = item.node.kind === 'preset_root' ? 'root' : item.node.kind === 'chapter' ? 'group' : item.node.kind + const usageLabel = assigned + ? `${kindLabel} · assigned` + : used + ? `${kindLabel} · used by slides` + : usedDescendantCount > 0 + ? `${kindLabel} · contains ${usedDescendantCount} used preset${usedDescendantCount === 1 ? '' : 's'}` + : `${kindLabel} · unused` + + async function deleteFromMenu() { + onMenuToggle(item.node.id) + if (!window.confirm(`Delete preset "${item.node.name}"? This cannot be undone.`)) return + await onDelete(item.node) + } + + return ( +
+
0 ? ' used' : ' unused'}`}> + + +
+ + {menuOpen ? ( +
+ + + + + + +
+ ) : null} +
+
+ {editingNodeId === item.node.id ? ( + onEdit('')} + onDelete={onDelete} + onRename={onRename} + onSet={onSet} + onUnset={onUnset} + /> + ) : null} + {open && item.children.length ? ( +
+ {item.children.map((child) => ( + + ))} +
+ ) : null} +
+ ) +} + +function collectResolvedParams(value: unknown, prefix: string[] = []): Array<{ path: string[]; value: unknown }> { + if (!isPlainObject(value)) return [] + + const entries: Array<{ path: string[]; value: unknown }> = [] + for (const [key, child] of Object.entries(value)) { + const path = [...prefix, key] + if (path.join('.') === 'simulation.initialCondition' || !isPlainObject(child)) { + entries.push({ path, value: child }) + } else { + entries.push(...collectResolvedParams(child, path)) + } + } + return entries +} + +function isGenericEditableProperty(path: string[]) { + const joinedPath = path.join('.') + return ( + joinedPath !== 'simulation.initialCondition' && + joinedPath !== 'simulation.engineId' && + joinedPath !== 'simulation.neighborhoodId' && + !joinedPath.startsWith('caClass.') + ) +} + +function engineParamPath(key: string) { + if (key === 'rendererId') return ['renderer', 'id'] + if (key === 'ruleId') return ['simulation', 'ruleId'] + if (key.startsWith('grid.')) return ['simulation', ...key.split('.')] + if (key.includes('.')) return key.split('.') + return ['simulation', key] +} + +function engineSettingKeys(engine: CaEngine | undefined) { + return engine ? Object.keys(engine.params_schema) : [] +} + +function isEngineSettingPath(path: string[], engine: CaEngine | undefined) { + const joinedPath = path.join('.') + return engineSettingKeys(engine).some((key) => engineParamPath(key).join('.') === joinedPath) +} + +function parsePropertyDraft(value: string) { + const trimmed = value.trim() + if (trimmed === '') return '' + try { + return JSON.parse(trimmed) as unknown + } catch { + return value + } +} + +function formatPropertyDraft(value: unknown) { + if (typeof value === 'string') return value + return JSON.stringify(value, null, 2) +} + +function NodePropertiesModal({ + engines, + inheritedParams, + node, + resolvedParams, + onClose, + onDelete, + onRename, + onSet, + onUnset +}: { + engines: CaEngine[] + inheritedParams: JsonObject + node: PresetNode + resolvedParams: JsonObject + onClose: () => void + onDelete: (node: PresetNode) => boolean | void | Promise + onRename: (node: PresetNode, name: string) => void | Promise + onSet: (node: PresetNode, path: string[], value: unknown) => void + onUnset: (node: PresetNode, path: string[]) => void +}) { + const [nameDraft, setNameDraft] = React.useState(node.name) + const resolvedEngineId = getParamPath(resolvedParams, ['simulation', 'engineId']) + const caClass = getCaClassFromParams(resolvedParams) + const resolvedEngine = + typeof resolvedEngineId === 'string' + ? engines.find((engine) => engine.engine_kind === resolvedEngineId) + : undefined + const resolvedEngineCompatible = resolvedEngine ? supportsCaClass(resolvedEngine, caClass) : false + const activeEngine = resolvedEngineCompatible ? resolvedEngine : undefined + const properties = collectResolvedParams(resolvedParams).filter( + (property) => isGenericEditableProperty(property.path) && !isEngineSettingPath(property.path, activeEngine) + ) + + React.useEffect(() => { + setNameDraft(node.name) + }, [node.name]) + + async function saveName() { + const name = nameDraft.trim() + if (!name) { + setNameDraft(node.name) + return + } + if (name !== node.name) await onRename(node, name) + } + + async function deleteNode() { + if (!window.confirm(`Delete preset "${node.name}"? This cannot be undone.`)) return + const deleted = await onDelete(node) + if (deleted === false) return + onClose() + } + + return ( +
+
event.stopPropagation()}> +
+
+

Preset Editor

+

Edit preset

+
+ +
+
+
+
+ i +
+

Preset identity

+

Edit CA space, engine, and engine settings in the inspector.

+
+
+ + +
+ + {properties.map((property) => { + const localValue = getParamPath(node.params, property.path) + const inheritedValue = getParamPath(inheritedParams, property.path) + const isLocal = localValue !== undefined && !valuesEqual(localValue, inheritedValue) + return ( + + ) + })} + {properties.length === 0 ?

No properties are resolved for this node.

: null} +
+
+ Delete preset + Slides referencing this preset must be reassigned first. +
+ +
+
+
+
+ ) +} + +function PropertyEditorRow({ + inheritedValue, + isLocal, + node, + path, + value, + onSet, + onUnset +}: { + inheritedValue: unknown + isLocal: boolean + node: PresetNode + path: string[] + value: unknown + onSet: (node: PresetNode, path: string[], value: unknown) => void + onUnset: (node: PresetNode, path: string[]) => void +}) { + const [draft, setDraft] = React.useState(formatPropertyDraft(value)) + + React.useEffect(() => { + setDraft(formatPropertyDraft(value)) + }, [value]) + + return ( +
+
+ {path.join('.')} + {isLocal ? 'local override' : 'inherited'} + {!isLocal && inheritedValue !== undefined ? from parent: {formatParamValue(path, inheritedValue)} : null} +
+