Adding the lab project

This commit is contained in:
2026-07-01 12:10:12 +02:00
commit 48ce52b52c
56 changed files with 22289 additions and 0 deletions
+679
View File
@@ -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<string>
onCreateChild: (node: PresetNode) => void
onCreateGroup: (node: PresetNode) => void
onDelete: (node: PresetNode) => boolean | void | Promise<boolean | void>
onMove: (node: PresetNode, direction: 1 | -1) => void
onRename: (node: PresetNode, name: string) => void | Promise<void>
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<string | null, PresetNode[]>()
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<string>,
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<Set<string>>(
() => 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<HTMLDivElement>) {
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 <p className="empty">No nodes loaded.</p>
}
return (
<div className="preset-node-tree" role="tree" tabIndex={0} onKeyDown={handleTreeKeyDown}>
{tree.map((node) => (
<TreeNodeView
key={node.node.id}
item={node}
assignedNodeId={assignedNodeId}
engines={engines}
inheritedParams={{}}
selectedNodeId={selectedNodeId}
usedNodeIds={usedNodeIds}
expandedNodeIds={expandedNodeIds}
editingNodeId={editingNodeId}
openMenuNodeId={openMenuNodeId}
onCreateChild={onCreateChild}
onCreateGroup={onCreateGroup}
onDelete={onDelete}
onEdit={setEditingNodeId}
onMenuToggle={(nodeId) => setOpenMenuNodeId((current) => (current === nodeId ? '' : nodeId))}
onMove={onMove}
onRename={onRename}
onSelect={onSelect}
onSet={onSet}
onToggle={toggleNode}
onUnset={onUnset}
/>
))}
</div>
)
}
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<string>
expandedNodeIds: Set<string>
editingNodeId: string
openMenuNodeId: string
onCreateChild: (node: PresetNode) => void
onCreateGroup: (node: PresetNode) => void
onDelete: (node: PresetNode) => boolean | void | Promise<boolean | void>
onEdit: (nodeId: string) => void
onMenuToggle: (nodeId: string) => void
onMove: (node: PresetNode, direction: 1 | -1) => void
onRename: (node: PresetNode, name: string) => void | Promise<void>
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 (
<div className="tree-item">
<div className={`tree-node${selected ? ' active' : ''}${assigned ? ' assigned' : ''}${used || usedDescendantCount > 0 ? ' used' : ' unused'}`}>
<button
aria-label={item.children.length ? `${open ? 'Collapse' : 'Expand'} ${item.node.name}` : `${item.node.name} has no children`}
className="tree-node-arrow"
disabled={!item.children.length}
type="button"
onClick={() => onToggle(item.node.id)}
>
{item.children.length ? (open ? '▾' : '▸') : '•'}
</button>
<button
aria-current={selected ? 'true' : undefined}
className="tree-node-main"
role="treeitem"
type="button"
onClick={() => onSelect(item.node)}
>
<strong>{item.node.name}</strong>
<small>{usageLabel}</small>
</button>
<div className="tree-node-action-shell">
<button
aria-expanded={menuOpen}
aria-haspopup="menu"
aria-label={`Actions for ${item.node.name}`}
className="icon-button tree-node-edit node-action-trigger"
title="Preset actions"
type="button"
onClick={(event) => {
event.stopPropagation()
onMenuToggle(item.node.id)
}}
>
</button>
{menuOpen ? (
<div className="tree-node-action-menu" role="menu">
<button role="menuitem" type="button" onClick={() => {
onMenuToggle(item.node.id)
onEdit(item.node.id)
}}>
Edit
</button>
<button role="menuitem" type="button" onClick={() => {
onMenuToggle(item.node.id)
onCreateChild(item.node)
}}>
Create child preset
</button>
<button role="menuitem" type="button" onClick={() => {
onMenuToggle(item.node.id)
onCreateGroup(item.node)
}}>
Create child group
</button>
<button disabled={item.siblingIndex === 0} role="menuitem" type="button" onClick={() => {
onMenuToggle(item.node.id)
onMove(item.node, -1)
}}>
Move up
</button>
<button disabled={item.siblingIndex >= item.siblingCount - 1} role="menuitem" type="button" onClick={() => {
onMenuToggle(item.node.id)
onMove(item.node, 1)
}}>
Move down
</button>
<button className="danger" role="menuitem" type="button" onClick={() => void deleteFromMenu()}>
Delete
</button>
</div>
) : null}
</div>
</div>
{editingNodeId === item.node.id ? (
<NodePropertiesModal
engines={engines}
inheritedParams={inheritedParams}
node={item.node}
resolvedParams={resolvedParams}
onClose={() => onEdit('')}
onDelete={onDelete}
onRename={onRename}
onSet={onSet}
onUnset={onUnset}
/>
) : null}
{open && item.children.length ? (
<div className="tree-children">
{item.children.map((child) => (
<TreeNodeView
assignedNodeId={assignedNodeId}
engines={engines}
inheritedParams={resolvedParams}
item={child}
key={child.node.id}
usedNodeIds={usedNodeIds}
expandedNodeIds={expandedNodeIds}
editingNodeId={editingNodeId}
openMenuNodeId={openMenuNodeId}
onCreateChild={onCreateChild}
onCreateGroup={onCreateGroup}
onDelete={onDelete}
onEdit={onEdit}
onMenuToggle={onMenuToggle}
onMove={onMove}
onRename={onRename}
onSelect={onSelect}
onSet={onSet}
onToggle={onToggle}
onUnset={onUnset}
selectedNodeId={selectedNodeId}
/>
))}
</div>
) : null}
</div>
)
}
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<boolean | void>
onRename: (node: PresetNode, name: string) => void | Promise<void>
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 (
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
<section aria-modal="true" className="property-modal" role="dialog" onMouseDown={(event) => event.stopPropagation()}>
<header className="property-modal-header">
<div>
<p className="eyebrow">Preset Editor</p>
<h2>Edit preset</h2>
</div>
<button type="button" onClick={onClose}>
Close
</button>
</header>
<div className="property-modal-list">
<section className="preset-modal-stage preset-modal-identity">
<div className="preset-modal-stage-heading">
<span>i</span>
<div>
<h3>Preset identity</h3>
<p>Edit CA space, engine, and engine settings in the inspector.</p>
</div>
</div>
<label>
Preset name
<input
autoFocus
value={nameDraft}
onChange={(event) => setNameDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
void saveName()
}
}}
/>
</label>
<button disabled={!nameDraft.trim() || nameDraft.trim() === node.name} type="button" onClick={() => void saveName()}>
Save name
</button>
</section>
{properties.map((property) => {
const localValue = getParamPath(node.params, property.path)
const inheritedValue = getParamPath(inheritedParams, property.path)
const isLocal = localValue !== undefined && !valuesEqual(localValue, inheritedValue)
return (
<PropertyEditorRow
inheritedValue={inheritedValue}
isLocal={isLocal}
key={property.path.join('.')}
node={node}
path={property.path}
value={property.value}
onSet={onSet}
onUnset={onUnset}
/>
)
})}
{properties.length === 0 ? <p className="empty">No properties are resolved for this node.</p> : null}
<footer className="preset-modal-danger">
<div>
<strong>Delete preset</strong>
<small>Slides referencing this preset must be reassigned first.</small>
</div>
<button className="danger" type="button" onClick={() => void deleteNode()}>
Delete
</button>
</footer>
</div>
</section>
</div>
)
}
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 (
<div className="property-row">
<div className="property-row-meta">
<strong>{path.join('.')}</strong>
<span className={isLocal ? 'local' : 'inherited'}>{isLocal ? 'local override' : 'inherited'}</span>
{!isLocal && inheritedValue !== undefined ? <small>from parent: {formatParamValue(path, inheritedValue)}</small> : null}
</div>
<textarea value={draft} onChange={(event) => setDraft(event.target.value)} />
<div className="property-row-actions">
<button type="button" onClick={() => onSet(node, path, parsePropertyDraft(draft))}>
Save
</button>
<button className="danger" disabled={!isLocal} type="button" onClick={() => onUnset(node, path)}>
🗑
</button>
</div>
</div>
)
}