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}