diff --git a/src/App.jsx b/src/App.jsx index aee0c912..946a3f5b 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -15,10 +15,10 @@ import Plot from 'react-plotly.js'; import ContextMenu from './ContextMenu.jsx'; // Importing node components -import {ProcessNode, ProcessNodeHorizontal} from './ProcessNode'; +import { ProcessNode, ProcessNodeHorizontal } from './ProcessNode'; import DelayNode from './DelayNode'; import SourceNode from './ConstantNode'; -import {AmplifierNode, AmplifierNodeReverse} from './AmplifierNode'; +import { AmplifierNode, AmplifierNodeReverse } from './AmplifierNode'; import IntegratorNode from './IntegratorNode'; import AdderNode from './AdderNode'; import ScopeNode from './ScopeNode'; @@ -75,6 +75,7 @@ export default function App() { const [copiedNode, setCopiedNode] = useState(null); const [copyFeedback, setCopyFeedback] = useState(''); const ref = useRef(null); + const [csvData, setCsvData] = useState(null); // Solver parameters state const [solverParams, setSolverParams] = useState({ @@ -132,20 +133,20 @@ export default function App() { } } else { // Fallback for browsers (like Firefox and Safari) that don't support File System Access API - const blob = new Blob([JSON.stringify(graphData, null, 2)], { - type: 'application/json' + const blob = new Blob([JSON.stringify(graphData, null, 2)], { + type: 'application/json' }); - + const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'fuel-cycle-graph.json'; - + document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); - + alert('Graph downloaded successfully!'); } }; @@ -168,16 +169,16 @@ export default function App() { const file = await fileHandle.getFile(); const text = await file.text(); - + try { const graphData = JSON.parse(text); - + // Validate that it's a valid graph file if (!graphData.nodes || !Array.isArray(graphData.nodes)) { alert("Invalid file format. Please select a valid graph JSON file."); return; } - + // Load the graph data const { nodes: loadedNodes, edges: loadedEdges, nodeCounter: loadedNodeCounter, solverParams: loadedSolverParams, globalVariables: loadedGlobalVariables } = graphData; setNodes(loadedNodes || []); @@ -196,7 +197,7 @@ export default function App() { extra_params: '{}' }); setGlobalVariables(loadedGlobalVariables ?? []); - + alert('Graph loaded successfully!'); } catch (error) { console.error('Error parsing file:', error); @@ -215,21 +216,21 @@ export default function App() { fileInput.type = 'file'; fileInput.accept = '.json'; fileInput.style.display = 'none'; - + fileInput.onchange = (event) => { const file = event.target.files[0]; if (!file) return; - + const reader = new FileReader(); reader.onload = (e) => { try { const graphData = JSON.parse(e.target.result); - + if (!graphData.nodes || !Array.isArray(graphData.nodes)) { alert("Invalid file format. Please select a valid graph JSON file."); return; } - + const { nodes: loadedNodes, edges: loadedEdges, nodeCounter: loadedNodeCounter, solverParams: loadedSolverParams, globalVariables: loadedGlobalVariables } = graphData; setNodes(loadedNodes || []); setEdges(loadedEdges || []); @@ -247,18 +248,18 @@ export default function App() { extra_params: '{}' }); setGlobalVariables(loadedGlobalVariables ?? []); - + alert('Graph loaded successfully!'); } catch (error) { console.error('Error parsing file:', error); alert('Error reading file. Please make sure it\'s a valid JSON file.'); } }; - + reader.readAsText(file); document.body.removeChild(fileInput); }; - + document.body.appendChild(fileInput); fileInput.click(); } @@ -283,6 +284,58 @@ export default function App() { }); setGlobalVariables([]); }; + const downloadCsv = async () => { + if (!csvData) return; + + const { time, series } = csvData; + const labels = Object.keys(series); + const header = ["time", ...labels].join(","); + const rows = [header]; + + time.forEach((t, i) => { + const row = [t]; + for (const label of labels) { + const val = series[label][i] ?? "NaN"; + row.push(val); + } + rows.push(row.join(",")); + }); + + const csvString = rows.join("\n"); + const blob = new Blob([csvString], { type: "text/csv" }); + const filename = `simulation_${new Date().toISOString().replace(/[:.]/g, "-")}.csv`; + + try { + if ("showSaveFilePicker" in window) { + const options = { + suggestedName: filename, + types: [{ + description: "CSV File", + accept: { "text/csv": [".csv"] } + }] + }; + + const handle = await window.showSaveFilePicker(options); + const writable = await handle.createWritable(); + await writable.write(blob); + await writable.close(); + } else { + throw new Error("showSaveFilePicker not supported"); + } + } catch (err) { + console.warn("Falling back to automatic download:", err); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(a.href); + } + }; + + + // Allows user to save to python script const saveToPython = async () => { try { @@ -377,6 +430,7 @@ export default function App() { if (result.success) { // Store results and switch to results tab setSimulationResults(result.plot); + setCsvData(result.csv_data); setActiveTab('results'); alert('Pathsim simulation completed successfully! Check the Results tab.'); } else { @@ -387,26 +441,26 @@ export default function App() { alert('Failed to run Pathsim simulation. Make sure the backend is running.'); } }; - + // Functions for managing global variables const isValidPythonIdentifier = (name) => { // Check if name is empty if (!name) return false; - + // Python identifier rules: // - Must start with letter or underscore // - Can contain letters, digits, underscores // - Cannot be a Python keyword const pythonKeywords = [ - 'False', 'None', 'True', 'and', 'as', 'assert', 'break', 'class', 'continue', - 'def', 'del', 'elif', 'else', 'except', 'finally', 'for', 'from', 'global', - 'if', 'import', 'in', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', + 'False', 'None', 'True', 'and', 'as', 'assert', 'break', 'class', 'continue', + 'def', 'del', 'elif', 'else', 'except', 'finally', 'for', 'from', 'global', + 'if', 'import', 'in', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', 'return', 'try', 'while', 'with', 'yield' ]; - + // Check if it's a keyword if (pythonKeywords.includes(name)) return false; - + // Check pattern: must start with letter or underscore, followed by letters, digits, or underscores const pattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/; return pattern.test(name); @@ -430,12 +484,12 @@ export default function App() { setGlobalVariables(globalVariables.map(variable => { if (variable.id === id) { const updatedVariable = { ...variable, [field]: value }; - + // Validate name field if (field === 'name') { updatedVariable.nameError = value !== '' && !isValidPythonIdentifier(value); } - + return updatedVariable; } return variable; @@ -451,7 +505,7 @@ export default function App() { if (params.sourceHandle) { edgeId += `-from_${params.sourceHandle}`; } - + if (params.targetHandle) { edgeId += `-to_${params.targetHandle}`; } @@ -532,34 +586,34 @@ export default function App() { const addNode = () => { // Get available node types from nodeTypes object const availableTypes = Object.keys(nodeTypes); - + // Create options string for the prompt const typeOptions = availableTypes.map((type, index) => `${index + 1}. ${type}`).join('\n'); - + const userInput = prompt( `Select a node type by entering the number:\n\n${typeOptions}\n\nEnter your choice (1-${availableTypes.length}):` ); - + // If user cancels the prompt if (!userInput) { return; } - + // Parse the user input const choiceIndex = parseInt(userInput) - 1; - + // Validate the choice if (isNaN(choiceIndex) || choiceIndex < 0 || choiceIndex >= availableTypes.length) { alert('Invalid choice. Please enter a number between 1 and ' + availableTypes.length); return; } - + const selectedType = availableTypes[choiceIndex]; const newNodeId = nodeCounter.toString(); - + // Create appropriate data based on node type let nodeData = { label: `${selectedType} ${newNodeId}` }; - + // Add type-specific default parameters switch (selectedType) { case 'process': @@ -578,10 +632,10 @@ export default function App() { nodeData = { ...nodeData, amplitude: '1', T: '1', t_rise: '0.0', t_fall: '0.0', tau: '0.0', duty: '0.5' }; break; case 'amplifier': - nodeData = { ...nodeData, gain: ''}; + nodeData = { ...nodeData, gain: '' }; break; case 'amplifier_reverse': - nodeData = { ...nodeData, gain: ''}; + nodeData = { ...nodeData, gain: '' }; break; case 'multiplier': break; @@ -600,7 +654,7 @@ export default function App() { nodeData = { ...nodeData, tau: '' }; break; case 'rng': - nodeData = { ...nodeData, sampling_rate: ''}; + nodeData = { ...nodeData, sampling_rate: '' }; break; case 'pid': nodeData = { ...nodeData, Kp: '', Ki: '', Kd: '', f_max: '' }; @@ -627,24 +681,24 @@ export default function App() { // For any other types, just use basic data break; } - + const newNode = { id: newNodeId, type: selectedType, position: { x: 200 + nodes.length * 50, y: 200 }, data: nodeData, }; - + setNodes((nds) => [...nds, newNode]); setNodeCounter((count) => count + 1); }; - + // Function to pop context menu when right-clicking on a node const onNodeContextMenu = useCallback( (event, node) => { // Prevent native context menu from showing event.preventDefault(); - + // Calculate position of the context menu. We want to make sure it // doesn't get positioned off-screen. const pane = ref.current.getBoundingClientRect(); @@ -659,7 +713,7 @@ export default function App() { }, [setMenu], ); - + // Function to delete the selected node const deleteSelectedNode = () => { if (selectedNode) { @@ -697,9 +751,9 @@ export default function App() { const duplicateNode = useCallback((nodeId, options = {}) => { const node = nodes.find(n => n.id === nodeId); if (!node) return; - + const newNodeId = nodeCounter.toString(); - + // Calculate position based on source (context menu vs keyboard) let position; if (options.fromKeyboard) { @@ -715,7 +769,7 @@ export default function App() { y: node.position.y + 50, }; } - + const newNode = { ...node, selected: false, @@ -727,12 +781,13 @@ export default function App() { label: node.data.label ? node.data.label.replace(node.id, newNodeId) : `${node.type} ${newNodeId}` } }; - + setNodes((nds) => [...nds, newNode]); setNodeCounter((count) => count + 1); setMenu(null); // Close the context menu }, [nodes, nodeCounter, setNodeCounter, setNodes, setMenu]); - + + // Keyboard event handler for deleting selected items useEffect(() => { const handleKeyDown = (event) => { @@ -746,12 +801,12 @@ export default function App() { event.preventDefault(); setCopiedNode(selectedNode); setCopyFeedback(`Copied: ${selectedNode.data.label || selectedNode.id}`); - + // Clear feedback after 2 seconds setTimeout(() => { setCopyFeedback(''); }, 2000); - + console.log('Node copied:', selectedNode.id); return; } @@ -1035,6 +1090,8 @@ export default function App() { > Run + +
Define global variables that can be used in node definitions throughout your model.
- + {globalVariables.length === 0 ? (