diff --git a/src/lib/components/icons/Icon.svelte b/src/lib/components/icons/Icon.svelte index 336c362..510f8f9 100644 --- a/src/lib/components/icons/Icon.svelte +++ b/src/lib/components/icons/Icon.svelte @@ -420,6 +420,13 @@ +{:else if name === 'stage'} + + + + + + {:else if name === 'font-size-increase'} A diff --git a/src/lib/pyodide/bridge.ts b/src/lib/pyodide/bridge.ts index e9defab..1c9a9c5 100644 --- a/src/lib/pyodide/bridge.ts +++ b/src/lib/pyodide/bridge.ts @@ -27,6 +27,9 @@ import { // Re-export for use in other modules export { execDuringStreaming }; +// Import mutation queue +import { initMappings, flushQueue, clearQueue } from './mutationQueue'; + // Re-export replState as pyodideState for backwards compatibility export { replState as pyodideState }; @@ -298,7 +301,9 @@ async function runStreamingLoop( export async function runStreamingSimulation( code: string, duration: string, - onUpdate?: (result: SimulationResult) => void + onUpdate?: (result: SimulationResult) => void, + nodeVars?: Map, + connVars?: Map ): Promise { // Ensure initialized const state = get(replState); @@ -306,6 +311,13 @@ export async function runStreamingSimulation( await initRepl(); } + // Initialize mutation queue mappings for this run + if (nodeVars && connVars) { + initMappings(nodeVars, connVars); + } else { + clearQueue(); + } + streamingActive = true; // Update simulation state - preserve result structure for smooth transition @@ -421,6 +433,13 @@ if 'sim' not in dir() or sim is None: raise RuntimeError("No simulation to continue. Run a simulation first.") `); + // Apply any pending graph mutations before continuing + const mutationCode = flushQueue(); + if (mutationCode) { + await exec(mutationCode); + consoleStore.info('Applied graph mutations'); + } + // Start streaming generator with reset=False and optimized tickrate await exec(generateStreamingStartCode(durationExpr, STREAMING_TICKRATE, false)); @@ -476,6 +495,26 @@ if 'sim' not in dir() or sim is None: } } +/** + * Stage pending graph mutations into the simulation. + * If streaming: injects via execDuringStreaming (applied between generator steps). + * If paused: executes directly via exec. + * Returns true if mutations were applied, false if nothing to stage. + */ +export async function stageMutations(): Promise { + const code = flushQueue(); + if (!code) return false; + + if (streamingActive) { + execDuringStreaming(code); + consoleStore.info('Staged changes (applied during streaming)'); + } else { + await exec(code); + consoleStore.info('Staged changes applied'); + } + return true; +} + /** * Reset simulation state completely. * Use when loading a new model or creating a new graph. diff --git a/src/lib/pyodide/codeBuilder.ts b/src/lib/pyodide/codeBuilder.ts index 9122fa6..9867fc2 100644 --- a/src/lib/pyodide/codeBuilder.ts +++ b/src/lib/pyodide/codeBuilder.ts @@ -84,7 +84,7 @@ export function groupConnectionsBySource( } /** - * Generate connection lines from grouped connections + * Generate connection lines from grouped connections (anonymous, for inline use) * * @param connections - Array of connections * @param nodeVars - Map of nodeId to variable name @@ -107,6 +107,40 @@ export function generateConnectionLines( return lines; } +/** + * Generate named connection variable definitions. + * Each edge gets its own named variable for individual mutation support. + * + * @param connections - Array of connections + * @param nodeVars - Map of nodeId to variable name + * @param prefix - Variable name prefix (default 'conn') + * @returns Object with definition lines, variable names list, and id-to-varname map + */ +export function generateNamedConnections( + connections: Connection[], + nodeVars: Map, + prefix: string = 'conn' +): { lines: string[]; varNames: string[]; connVars: Map } { + const lines: string[] = []; + const varNames: string[] = []; + const connVars = new Map(); + + let idx = 0; + for (const conn of connections) { + const sourceVar = nodeVars.get(conn.sourceNodeId); + const targetVar = nodeVars.get(conn.targetNodeId); + if (!sourceVar || !targetVar) continue; + + const varName = `${prefix}_${idx}`; + lines.push(`${varName} = Connection(${sourceVar}[${conn.sourcePortIndex}], ${targetVar}[${conn.targetPortIndex}])`); + varNames.push(varName); + connVars.set(conn.id, varName); + idx++; + } + + return { lines, varNames, connVars }; +} + /** * Generate a Python list definition * diff --git a/src/lib/pyodide/index.ts b/src/lib/pyodide/index.ts index b80f30c..3dae504 100644 --- a/src/lib/pyodide/index.ts +++ b/src/lib/pyodide/index.ts @@ -8,6 +8,7 @@ export { initPyodide, runStreamingSimulation, continueStreamingSimulation, + stageMutations, resetSimulation, validateGraph, stopSimulation, @@ -23,6 +24,7 @@ export { // Code generation export { generatePythonCode, + type CodeGenResult, runGraphStreamingSimulation, exportToPython, validateGraphSimulation, @@ -51,3 +53,18 @@ export { type Backend, type BackendState } from './backend'; + +// Mutation queue for runtime graph changes +export { + queueAddBlock, + queueRemoveBlock, + queueAddConnection, + queueRemoveConnection, + queueUpdateParam, + queueUpdateSetting, + hasPendingMutations, + isActive as isMutationQueueActive, + pendingMutationCount, + getNodeVar, + getConnVar +} from './mutationQueue'; diff --git a/src/lib/pyodide/mutationQueue.ts b/src/lib/pyodide/mutationQueue.ts new file mode 100644 index 0000000..978b100 --- /dev/null +++ b/src/lib/pyodide/mutationQueue.ts @@ -0,0 +1,264 @@ +/** + * Mutation Queue + * Collects graph changes (add/remove blocks, connections, parameter/setting changes) + * as Python code strings. Changes are NOT applied automatically — the user + * explicitly stages them via a "Stage Changes" action. + * + * On "Run": queue is cleared, mappings initialized from code generation result. + * On "Stage": queue is flushed → applied to worker (via exec or execDuringStreaming). + * On "Continue": remaining queued mutations are flushed before the new generator starts. + * + * Design: + * - Structural mutations (add/remove block/connection) are queued in order. + * - Parameter and setting updates are coalesced: only the latest value per key. + * - Each mutation is wrapped in try/except for error isolation on flush. + * - pendingMutationCount is a Svelte store for UI reactivity (badge on stage button). + */ + +import { writable } from 'svelte/store'; +import type { NodeInstance, Connection } from '$lib/nodes/types'; +import { nodeRegistry } from '$lib/nodes/registry'; +import { isSubsystem } from '$lib/nodes/shapes'; +import { sanitizeName } from './codeBuilder'; + +// --- Internal state --- + +/** Active variable name mappings from the last run */ +let activeNodeVars = new Map(); // nodeId → Python var name +let activeConnVars = new Map(); // connectionId → Python var name + +/** Counter for dynamically added variables */ +let dynamicVarCounter = 0; + +/** Ordered structural mutations (add/remove block/connection) */ +const structuralQueue: string[] = []; + +/** Coalesced parameter updates: "nodeId:paramName" → Python assignment */ +const paramUpdates = new Map(); + +/** Coalesced setting updates: key → Python code */ +const settingUpdates = new Map(); + +/** Reactive store: number of pending mutations */ +export const pendingMutationCount = writable(0); + +function updateCount(): void { + pendingMutationCount.set( + structuralQueue.length + paramUpdates.size + settingUpdates.size + ); +} + +// --- Public API --- + +/** + * Check if the mutation queue is active (a simulation has been run). + */ +export function isActive(): boolean { + return activeNodeVars.size > 0; +} + +/** + * Initialize mappings from a fresh run's code generation result. + * Called when "Run" starts. Clears all pending mutations. + */ +export function initMappings(nodeVars: Map, connVars: Map): void { + activeNodeVars = new Map(nodeVars); + activeConnVars = new Map(connVars); + dynamicVarCounter = 0; + structuralQueue.length = 0; + paramUpdates.clear(); + settingUpdates.clear(); + updateCount(); +} + +/** + * Clear the mutation queue (on fresh Run). + */ +export function clearQueue(): void { + structuralQueue.length = 0; + paramUpdates.clear(); + settingUpdates.clear(); + updateCount(); +} + +/** + * Get all pending mutations as a Python code string and clear the queue. + * Each mutation is wrapped in try/except for error isolation. + * Order: settings first, then structural mutations, then parameter updates. + */ +export function flushQueue(): string | null { + const allCode: string[] = []; + + // 1. Settings (apply before structural changes) + for (const code of settingUpdates.values()) { + allCode.push(wrapTryExcept(code)); + } + + // 2. Structural mutations (add/remove in order) + for (const code of structuralQueue) { + allCode.push(wrapTryExcept(code)); + } + + // 3. Parameter updates (apply after blocks exist) + for (const code of paramUpdates.values()) { + allCode.push(wrapTryExcept(code)); + } + + structuralQueue.length = 0; + paramUpdates.clear(); + settingUpdates.clear(); + updateCount(); + + if (allCode.length === 0) return null; + return allCode.join('\n'); +} + +/** + * Check if there are pending mutations. + */ +export function hasPendingMutations(): boolean { + return structuralQueue.length > 0 || paramUpdates.size > 0 || settingUpdates.size > 0; +} + +// --- Mutation generators --- + +/** + * Queue adding a new block to the simulation. + * Skips subsystem nodes (they require recursive internal graph creation). + */ +export function queueAddBlock(node: NodeInstance): void { + if (!isActive()) return; + if (isSubsystem(node)) return; + + const typeDef = nodeRegistry.get(node.type); + if (!typeDef) return; + + const existingNames = new Set(activeNodeVars.values()); + let varName = sanitizeName(node.name); + if (!varName || existingNames.has(varName)) { + varName = `block_dyn_${dynamicVarCounter++}`; + } + activeNodeVars.set(node.id, varName); + + const validParamNames = new Set(typeDef.params.map(p => p.name)); + const paramParts: string[] = []; + for (const [name, value] of Object.entries(node.params)) { + if (value === null || value === undefined || value === '') continue; + if (name.startsWith('_')) continue; + if (!validParamNames.has(name)) continue; + paramParts.push(`${name}=${value}`); + } + const params = paramParts.join(', '); + const constructor = params ? `${typeDef.blockClass}(${params})` : `${typeDef.blockClass}()`; + + structuralQueue.push([ + `${varName} = ${constructor}`, + `sim.add_block(${varName})`, + `blocks.append(${varName})`, + `_node_id_map[id(${varName})] = "${node.id}"`, + `_node_name_map["${node.id}"] = "${node.name.replace(/"/g, '\\"')}"` + ].join('\n')); + updateCount(); +} + +/** + * Queue removing a block from the simulation. + */ +export function queueRemoveBlock(nodeId: string): void { + const varName = activeNodeVars.get(nodeId); + if (!varName) return; + + structuralQueue.push([ + `sim.remove_block(${varName})`, + `blocks.remove(${varName})`, + `_node_id_map.pop(id(${varName}), None)`, + `_node_name_map.pop("${nodeId}", None)` + ].join('\n')); + activeNodeVars.delete(nodeId); + + // Remove any coalesced param updates for this block + for (const key of paramUpdates.keys()) { + if (key.startsWith(nodeId + ':')) { + paramUpdates.delete(key); + } + } + updateCount(); +} + +/** + * Queue adding a new connection to the simulation. + */ +export function queueAddConnection(conn: Connection): void { + if (!isActive()) return; + + const sourceVar = activeNodeVars.get(conn.sourceNodeId); + const targetVar = activeNodeVars.get(conn.targetNodeId); + if (!sourceVar || !targetVar) return; + + const varName = `conn_dyn_${dynamicVarCounter++}`; + activeConnVars.set(conn.id, varName); + + structuralQueue.push([ + `${varName} = Connection(${sourceVar}[${conn.sourcePortIndex}], ${targetVar}[${conn.targetPortIndex}])`, + `sim.add_connection(${varName})`, + `connections.append(${varName})` + ].join('\n')); + updateCount(); +} + +/** + * Queue removing a connection from the simulation. + */ +export function queueRemoveConnection(connId: string): void { + const varName = activeConnVars.get(connId); + if (!varName) return; + + structuralQueue.push([ + `sim.remove_connection(${varName})`, + `connections.remove(${varName})` + ].join('\n')); + activeConnVars.delete(connId); + updateCount(); +} + +/** + * Queue a block parameter change. + * Coalesced: only the latest value per (nodeId, paramName) is kept. + */ +export function queueUpdateParam(nodeId: string, paramName: string, value: string): void { + const varName = activeNodeVars.get(nodeId); + if (!varName) return; + + paramUpdates.set(`${nodeId}:${paramName}`, `${varName}.${paramName} = ${value}`); + updateCount(); +} + +/** + * Queue a simulation setting change. + * Coalesced: only the latest code per setting key is kept. + * @param key - Coalescing key (e.g. 'dt', 'solver') + * @param code - Full Python code to execute (e.g. 'sim.dt = 0.01') + */ +export function queueUpdateSetting(key: string, code: string): void { + if (!isActive()) return; + + settingUpdates.set(key, code); + updateCount(); +} + +// --- Lookup --- + +export function getNodeVar(nodeId: string): string | undefined { + return activeNodeVars.get(nodeId); +} + +export function getConnVar(connId: string): string | undefined { + return activeConnVars.get(connId); +} + +// --- Internal helpers --- + +function wrapTryExcept(code: string): string { + const indented = code.split('\n').map(line => ` ${line}`).join('\n'); + return `try:\n${indented}\nexcept Exception as _e:\n print(f"Mutation error: {_e}", file=__import__('sys').stderr)`; +} diff --git a/src/lib/pyodide/pathsimRunner.ts b/src/lib/pyodide/pathsimRunner.ts index b78c197..9c83bb1 100644 --- a/src/lib/pyodide/pathsimRunner.ts +++ b/src/lib/pyodide/pathsimRunner.ts @@ -22,6 +22,7 @@ import { import { generateParamString, generateConnectionLines, + generateNamedConnections, generateListDefinition, sanitizeName } from './codeBuilder'; @@ -289,6 +290,13 @@ function generateSubsystemCode( } } + // Connection variables (named for mutation support) + const subConnPrefix = `${subsystemVarName}_conn`; + const subConnResult = generateNamedConnections(childConnections, internalNodeVars, subConnPrefix); + for (const line of subConnResult.lines) { + lines.push(line); + } + // Create Subsystem with inline blocks and connections using kwargs lines.push(`${subsystemVarName} = Subsystem(`); @@ -299,11 +307,10 @@ function generateSubsystemCode( } lines.push(' ],'); - // Connections list (grouped by source for multi-target syntax) + // Connections list (referencing named variables) lines.push(' connections=['); - const connLines = generateConnectionLines(childConnections, internalNodeVars, ' '); - for (const line of connLines) { - lines.push(line); + for (const connVarName of subConnResult.varNames) { + lines.push(` ${connVarName},`); } lines.push(' ],'); @@ -351,6 +358,13 @@ function groupNodesByCategory( * @param includeNodeIdMap - Include node ID mapping for web data extraction (default: true) * @param includeRun - Append sim.run() call (default: true, false for streaming) */ +/** Result of Python code generation, includes variable mappings for mutation support */ +export interface CodeGenResult { + code: string; + nodeVars: Map; // nodeId → Python variable name + connVars: Map; // connectionId → Python variable name +} + export function generatePythonCode( nodes: NodeInstance[], connections: Connection[], @@ -359,7 +373,7 @@ export function generatePythonCode( includeNodeIdMap: boolean = true, events: EventInstance[] = [], includeRun: boolean = true -): string { +): CodeGenResult { const lines: string[] = []; // Check if we have any subsystems @@ -465,14 +479,13 @@ export function generatePythonCode( lines.push(''); } - // 4. Connections (grouped by source for multi-target syntax) + // 4. Connections (named variables for mutation support) lines.push('# CONNECTIONS'); - lines.push('connections = ['); - const connLines = generateConnectionLines(connections, nodeVars, ' '); - for (const line of connLines) { + const connResult = generateNamedConnections(connections, nodeVars); + for (const line of connResult.lines) { lines.push(line); } - lines.push(']'); + lines.push(...generateListDefinition('connections', connResult.varNames)); lines.push(''); // 5. Events (if any) @@ -495,7 +508,7 @@ export function generatePythonCode( lines.push(`sim.run(duration=${getSettingOrDefault(settings, 'duration')}, reset=True)`); } - return lines.join('\n'); + return { code: lines.join('\n'), nodeVars, connVars: connResult.connVars }; } /** @@ -677,16 +690,16 @@ function generateFormattedPythonCode( lines.push(divider); lines.push(''); - // Connections (grouped by source for multi-target syntax) + // Connections (named variables for mutation support) if (connections.length === 0) { lines.push('connections = []'); } else { - lines.push('connections = ['); - const connLines = generateConnectionLines(connections, nodeVars, ' '); - for (const line of connLines) { + const connResult = generateNamedConnections(connections, nodeVars); + for (const line of connResult.lines) { lines.push(line); } - lines.push(']'); + lines.push(''); + lines.push(...generateListDefinition('connections', connResult.varNames)); } lines.push(''); @@ -741,9 +754,9 @@ export async function runGraphStreamingSimulation( onUpdate?: (result: SimulationResult) => void ): Promise { // Generate code without sim.run() - streaming will handle execution - const code = generatePythonCode(nodes, connections, settings, codeContext, true, events, false); + const result = generatePythonCode(nodes, connections, settings, codeContext, true, events, false); const duration = getSettingOrDefault(settings, 'duration'); - return runStreamingSimulation(code, String(duration), onUpdate); + return runStreamingSimulation(result.code, String(duration), onUpdate, result.nodeVars, result.connVars); } /** diff --git a/src/lib/stores/codeContext.ts b/src/lib/stores/codeContext.ts index 17dbb86..d39e4a0 100644 --- a/src/lib/stores/codeContext.ts +++ b/src/lib/stores/codeContext.ts @@ -4,6 +4,7 @@ */ import { writable, derived, get } from 'svelte/store'; +import { queueUpdateSetting, isActive as isMutationQueueActive } from '$lib/pyodide/mutationQueue'; const code = writable(''); const lastError = writable(null); @@ -40,6 +41,11 @@ export const codeContextStore = { setCode(newCode: string): void { code.set(newCode); lastError.set(null); + + // Queue code context re-execution as a mutation + if (isMutationQueueActive() && newCode.trim()) { + queueUpdateSetting('code_context', newCode.trim()); + } }, /** diff --git a/src/lib/stores/graph/connections.ts b/src/lib/stores/graph/connections.ts index a3b60c3..e28e1d3 100644 --- a/src/lib/stores/graph/connections.ts +++ b/src/lib/stores/graph/connections.ts @@ -11,6 +11,7 @@ import { getCurrentGraph, updateCurrentConnections } from './state'; +import { queueAddConnection, queueRemoveConnection } from '$lib/pyodide/mutationQueue'; /** * Add a connection between two ports @@ -56,6 +57,9 @@ export function addConnection( updateCurrentConnections(c => [...c, connection]); + // Queue mutation for runtime graph changes (no-op if no simulation active) + queueAddConnection(connection); + return connection; } @@ -63,6 +67,7 @@ export function addConnection( * Remove a connection */ export function removeConnection(id: string): void { + queueRemoveConnection(id); updateCurrentConnections(c => c.filter(conn => conn.id !== id)); } diff --git a/src/lib/stores/graph/nodes.ts b/src/lib/stores/graph/nodes.ts index 3607191..b6201e1 100644 --- a/src/lib/stores/graph/nodes.ts +++ b/src/lib/stores/graph/nodes.ts @@ -26,6 +26,7 @@ import { regenerateGraphIds, createPorts } from './helpers'; import { syncPortNamesFromLabels } from './ports'; import { triggerSelectNodes } from '$lib/stores/viewActions'; import { getPortLabelConfigs } from '$lib/nodes/uiConfig'; +import { queueAddBlock, queueRemoveBlock, queueAddConnection, queueRemoveConnection, queueUpdateParam } from '$lib/pyodide/mutationQueue'; /** * Add a new node to the current graph context @@ -72,6 +73,9 @@ export function addNode( addNodeToCurrentLevel(node); + // Queue mutation for runtime graph changes (no-op if no simulation active) + queueAddBlock(node); + return node; } @@ -89,6 +93,14 @@ export function removeNode(id: string): void { return; } + // Queue mutations for connections being removed with this node + for (const conn of currentGraph.connections) { + if (conn.sourceNodeId === id || conn.targetNodeId === id) { + queueRemoveConnection(conn.id); + } + } + queueRemoveBlock(id); + // Remove node and its connections updateCurrentNodesAndConnections( // Map updater for nodes (root) @@ -206,6 +218,13 @@ export function updateNodeColor(id: string, color: string | undefined): void { export function updateNodeParams(id: string, params: Record): void { updateNodeById(id, node => ({ ...node, params: { ...node.params, ...params } })); + // Queue parameter mutations (no-op if no simulation active) + for (const [paramName, value] of Object.entries(params)) { + if (value === null || value === undefined || value === '') continue; + if (paramName.startsWith('_')) continue; + queueUpdateParam(id, paramName, String(value)); + } + // Sync port names if this block has label-driven ports const node = getCurrentGraph().nodes.get(id); if (node) { @@ -349,6 +368,14 @@ export function duplicateSelected(): string[] { } } + // Queue mutations for duplicated nodes and connections + for (const node of nodesToAdd) { + queueAddBlock(node); + } + for (const conn of newConnections) { + queueAddConnection(conn); + } + // Add all nodes and connections in one update if (nodesToAdd.length > 0 || newConnections.length > 0) { updateCurrentNodesAndConnections( @@ -437,6 +464,14 @@ export function pasteNodes( const newNodeIds = nodes.map(n => n.id); + // Queue mutations for pasted nodes and connections + for (const node of nodes) { + queueAddBlock(node); + } + for (const conn of connections) { + queueAddConnection(conn); + } + // Add all nodes and connections updateCurrentNodesAndConnections( // Map updater for nodes (root) diff --git a/src/lib/stores/graph/ports.ts b/src/lib/stores/graph/ports.ts index 5ddd916..18f77b2 100644 --- a/src/lib/stores/graph/ports.ts +++ b/src/lib/stores/graph/ports.ts @@ -16,6 +16,7 @@ import { updateNodeById, updateCurrentNodesAndConnections } from './state'; +import { queueRemoveConnection } from '$lib/pyodide/mutationQueue'; type PortDirection = 'input' | 'output'; @@ -175,6 +176,17 @@ function removePort(nodeId: string, direction: PortDirection, syncFollowUp = fal // Remove port and affected connections const removedIndex = currentPorts.length - 1; + // Queue removal of connections affected by port removal + const graph = getCurrentGraph(); + for (const c of graph.connections) { + const isAffected = config.connectionKey === 'targetNodeId' + ? c.targetNodeId === nodeId && c.targetPortIndex >= removedIndex + : c.sourceNodeId === nodeId && c.sourcePortIndex >= removedIndex; + if (isAffected) { + queueRemoveConnection(c.id); + } + } + updateCurrentNodesAndConnections( // Map updater for nodes (root) nodes => { diff --git a/src/lib/stores/settings.ts b/src/lib/stores/settings.ts index 4bf5119..97d90b8 100644 --- a/src/lib/stores/settings.ts +++ b/src/lib/stores/settings.ts @@ -5,6 +5,29 @@ import { writable, get } from 'svelte/store'; import type { SimulationSettings, SolverType } from '$lib/nodes/types'; import { DEFAULT_SIMULATION_SETTINGS, INITIAL_SIMULATION_SETTINGS } from '$lib/nodes/types'; +import { queueUpdateSetting } from '$lib/pyodide/mutationQueue'; + +/** Map UI setting names to pathsim Simulation attribute mutations */ +const SETTING_MUTATIONS: Record { attr: string; code: string }> = { + dt: (v) => ({ attr: 'dt', code: `sim.dt = ${v}` }), + dt_min: (v) => ({ attr: 'dt_min', code: `sim.dt_min = ${v}` }), + dt_max: (v) => ({ attr: 'dt_max', code: `sim.dt_max = ${v}` }), + rtol: (v) => ({ attr: 'rtol', code: `sim.tolerance_lte_rel = ${v}` }), + atol: (v) => ({ attr: 'atol', code: `sim.tolerance_lte_abs = ${v}` }), + ftol: (v) => ({ attr: 'ftol', code: `sim.tolerance_fpi = ${v}` }), + solver: (v) => ({ attr: 'solver', code: `sim._set_solver(${v})` }) +}; + +function queueSettingChanges(newSettings: Partial): void { + for (const [key, value] of Object.entries(newSettings)) { + if (value === null || value === undefined || value === '') continue; + const mutationFn = SETTING_MUTATIONS[key]; + if (mutationFn) { + const { attr, code } = mutationFn(String(value)); + queueUpdateSetting(attr, code); // attr is the coalescing key, code is the Python to execute + } + } +} const settings = writable({ ...INITIAL_SIMULATION_SETTINGS }); @@ -16,6 +39,7 @@ export const settingsStore = { */ update(newSettings: Partial): void { settings.update((s) => ({ ...s, ...newSettings })); + queueSettingChanges(newSettings); }, /** @@ -23,6 +47,7 @@ export const settingsStore = { */ setDuration(duration: string): void { settings.update((s) => ({ ...s, duration })); + // Duration is not a sim attribute — it's passed to run_streaming() }, /** @@ -30,6 +55,7 @@ export const settingsStore = { */ setDt(dt: string): void { settings.update((s) => ({ ...s, dt })); + queueSettingChanges({ dt }); }, /** @@ -37,6 +63,7 @@ export const settingsStore = { */ setSolver(solver: SolverType): void { settings.update((s) => ({ ...s, solver })); + queueSettingChanges({ solver }); }, /** diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 6cea56f..1847eb9 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,7 +1,7 @@ {$pendingMutationCount} + + {/if} @@ -1024,17 +1036,7 @@ use:tooltip={{ text: $currentFileName ? `Save '${$currentFileName}'` : "Save", shortcut: "Ctrl+S" }} aria-label="Save" > - - {#if saveFlash === 'save'} - - - - {:else} - - - - {/if} - +