From ef53a09178060b2731f92883ef44b61b0fc460ac Mon Sep 17 00:00:00 2001 From: Tasnim Zulfiqar Date: Fri, 1 Aug 2025 17:40:36 -0400 Subject: [PATCH 01/12] Drag and drop branch (draft, do not merge) --- src/App.jsx | 3255 ++++++++++++++++++++++---------------------- src/DnDContext.jsx | 19 + src/Sidebar.jsx | 26 + 3 files changed, 1709 insertions(+), 1591 deletions(-) create mode 100644 src/DnDContext.jsx create mode 100644 src/Sidebar.jsx diff --git a/src/App.jsx b/src/App.jsx index 92dcbb98..932ede48 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,6 +1,9 @@ +// * Imports * import React, { useState, useCallback, useEffect, useRef } from 'react'; import { ReactFlow, + ReactFlowProvider, + useReactFlow, MiniMap, Controls, Background, @@ -12,7 +15,8 @@ import '@xyflow/react/dist/style.css'; import './App.css'; import Plot from 'react-plotly.js'; import { getApiEndpoint } from './config.js'; - +import Sidebar from './Sidebar'; +import { DnDProvider, useDnD } from './DnDContext.jsx'; import ContextMenu from './ContextMenu.jsx'; // Importing node components @@ -32,6 +36,8 @@ import { Splitter2Node, Splitter3Node } from './Splitters'; import BubblerNode from './BubblerNode'; import WallNode from './WallNode'; +// * Declaring variables * + // Add nodes as a node type for this script const nodeTypes = { process: ProcessNode, @@ -62,7 +68,11 @@ const nodeTypes = { const initialNodes = []; const initialEdges = []; -// Main App component +// Setting up id for Drag and Drop +let id = 0; +const getId = () => `dndnode_${id++}`; + +// * Main App component * export default function App() { // State management for nodes and edges: adds the initial nodes and edges to the graph and handles node selection const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); @@ -78,178 +88,172 @@ export default function App() { const ref = useRef(null); const [csvData, setCsvData] = useState(null); - // Solver parameters state - const [solverParams, setSolverParams] = useState({ - dt: '0.01', - dt_min: '1e-6', - dt_max: '1.0', - Solver: 'SSPRK22', - tolerance_fpi: '1e-6', - iterations_max: '100', - log: 'true', - simulation_duration: '50.0', - extra_params: '{}' - }); - - // Global variables state - const [globalVariables, setGlobalVariables] = useState([]); - const [defaultValues, setDefaultValues] = useState({}); - - // Function to fetch default values for a node type - const fetchDefaultValues = async (nodeType) => { - try { - const response = await fetch(getApiEndpoint(`/default-values/${nodeType}`)); - if (response.ok) { - const defaults = await response.json(); - return defaults; - } else { - console.error('Failed to fetch default values'); - return {}; - } - } catch (error) { - console.error('Error fetching default values:', error); - return {}; - } - }; - - // Function to save a graph to computer with "Save As" dialog - const saveGraph = async () => { - const graphData = { - nodes, - edges, - nodeCounter, - solverParams, - globalVariables - }; + // For Drag and Drop functionality + const DnDFlow = () => { + const reactFlowWrapper = useRef(null); + // const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); + // const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const { screenToFlowPosition } = useReactFlow(); + const [type] = useDnD(); - // Check if File System Access API is supported - if ('showSaveFilePicker' in window) { - try { - // Modern approach: Use File System Access API for proper "Save As" dialog - const fileHandle = await window.showSaveFilePicker({ - suggestedName: 'fuel-cycle-graph.json', - types: [{ - description: 'JSON files', - accept: { - 'application/json': ['.json'] - } - }] - }); + // const onConnect = useCallback((params) => setEdges((eds) => addEdge(params, eds)), []); - // Create a writable stream and write the data - const writable = await fileHandle.createWritable(); - await writable.write(JSON.stringify(graphData, null, 2)); - await writable.close(); + const onDragOver = useCallback((event) => { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + }, []); - // Success message - alert('Graph saved successfully!'); - } catch (error) { - if (error.name !== 'AbortError') { - console.error('Error saving file:', error); - alert('Failed to save file.'); + const onDrop = useCallback( + (event) => { + event.preventDefault(); + + // check if the dropped element is valid + if (!type) { + return; } - // User cancelled the dialog - no error message needed - } - } 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 position = screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }); + const newNode = { + id: getId(), + type, + position, + data: { label: `${type} node` }, + }; + + setNodes((nds) => nds.concat(newNode)); + }, + [screenToFlowPosition, type], + ); + const onDragStart = (event, nodeType) => { + setType(nodeType); + event.dataTransfer.setData('text/plain', nodeType); + event.dataTransfer.effectAllowed = 'move'; + }; - 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); + // Solver parameters state + const [solverParams, setSolverParams] = useState({ + dt: '0.01', + dt_min: '1e-6', + dt_max: '1.0', + Solver: 'SSPRK22', + tolerance_fpi: '1e-6', + iterations_max: '100', + log: 'true', + simulation_duration: '50.0', + extra_params: '{}' + }); - alert('Graph downloaded successfully!'); - } - }; + // Global variables state + const [globalVariables, setGlobalVariables] = useState([]); + const [defaultValues, setDefaultValues] = useState({}); - // Function to load a saved graph from computer - const loadGraph = async () => { - // Check if File System Access API is supported - if ('showOpenFilePicker' in window) { + // Function to fetch default values for a node type + const fetchDefaultValues = async (nodeType) => { try { - // Modern approach: Use File System Access API - const [fileHandle] = await window.showOpenFilePicker({ - types: [{ - description: 'JSON files', - accept: { - 'application/json': ['.json'] - } - }], - multiple: false - }); + const response = await fetch(getApiEndpoint(`/default-values/${nodeType}`)); + if (response.ok) { + const defaults = await response.json(); + return defaults; + } else { + console.error('Failed to fetch default values'); + return {}; + } + } catch (error) { + console.error('Error fetching default values:', error); + return {}; + } + }; - const file = await fileHandle.getFile(); - const text = await file.text(); + // Function to save a graph to computer with "Save As" dialog + const saveGraph = async () => { + const graphData = { + nodes, + edges, + nodeCounter, + solverParams, + globalVariables + }; + // Check if File System Access API is supported + if ('showSaveFilePicker' in window) { 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 || []); - setEdges(loadedEdges || []); - setSelectedNode(null); - setNodeCounter(loadedNodeCounter ?? loadedNodes.length); - setSolverParams(loadedSolverParams ?? { - dt: '0.01', - dt_min: '1e-6', - dt_max: '1.0', - Solver: 'SSPRK22', - tolerance_fpi: '1e-6', - iterations_max: '100', - log: 'true', - simulation_duration: '50.0', - extra_params: '{}' + // Modern approach: Use File System Access API for proper "Save As" dialog + const fileHandle = await window.showSaveFilePicker({ + suggestedName: 'fuel-cycle-graph.json', + types: [{ + description: 'JSON files', + accept: { + 'application/json': ['.json'] + } + }] }); - setGlobalVariables(loadedGlobalVariables ?? []); - alert('Graph loaded successfully!'); + // Create a writable stream and write the data + const writable = await fileHandle.createWritable(); + await writable.write(JSON.stringify(graphData, null, 2)); + await writable.close(); + + // Success message + alert('Graph saved successfully!'); } catch (error) { - console.error('Error parsing file:', error); - alert('Error reading file. Please make sure it\'s a valid JSON file.'); - } - } catch (error) { - if (error.name !== 'AbortError') { - console.error('Error opening file:', error); - alert('Failed to open file.'); + if (error.name !== 'AbortError') { + console.error('Error saving file:', error); + alert('Failed to save file.'); + } + // User cancelled the dialog - no error message needed } - // User cancelled the dialog - no error message needed + } 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 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!'); } - } else { - // Fallback for browsers that don't support File System Access API - const fileInput = document.createElement('input'); - 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) => { + }; + + // Function to load a saved graph from computer + const loadGraph = async () => { + // Check if File System Access API is supported + if ('showOpenFilePicker' in window) { + try { + // Modern approach: Use File System Access API + const [fileHandle] = await window.showOpenFilePicker({ + types: [{ + description: 'JSON files', + accept: { + 'application/json': ['.json'] + } + }], + multiple: false + }); + + const file = await fileHandle.getFile(); + const text = await file.text(); + try { - const graphData = JSON.parse(e.target.result); + 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 || []); setEdges(loadedEdges || []); @@ -273,429 +277,372 @@ export default function App() { console.error('Error parsing file:', error); alert('Error reading file. Please make sure it\'s a valid JSON file.'); } - }; + } catch (error) { + if (error.name !== 'AbortError') { + console.error('Error opening file:', error); + alert('Failed to open file.'); + } + // User cancelled the dialog - no error message needed + } + } else { + // Fallback for browsers that don't support File System Access API + const fileInput = document.createElement('input'); + 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 || []); + setSelectedNode(null); + setNodeCounter(loadedNodeCounter ?? loadedNodes.length); + setSolverParams(loadedSolverParams ?? { + dt: '0.01', + dt_min: '1e-6', + dt_max: '1.0', + Solver: 'SSPRK22', + tolerance_fpi: '1e-6', + iterations_max: '100', + log: 'true', + simulation_duration: '50.0', + 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); - }; + reader.readAsText(file); + document.body.removeChild(fileInput); + }; - document.body.appendChild(fileInput); - fileInput.click(); - } - }; - - // Allows user to clear user inputs and go back to default settings - const resetGraph = () => { - setNodes(initialNodes); - setEdges(initialEdges); - setSelectedNode(null); - setNodeCounter(0); - setSolverParams({ - dt: '0.01', - dt_min: '1e-6', - dt_max: '1.0', - Solver: 'SSPRK22', - tolerance_fpi: '1e-6', - iterations_max: '100', - log: 'true', - simulation_duration: '50.0', - extra_params: '{}' - }); - 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); + document.body.appendChild(fileInput); + fileInput.click(); } - 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"] } - }] - }; + // Allows user to clear user inputs and go back to default settings + const resetGraph = () => { + setNodes(initialNodes); + setEdges(initialEdges); + setSelectedNode(null); + setNodeCounter(0); + setSolverParams({ + dt: '0.01', + dt_min: '1e-6', + dt_max: '1.0', + Solver: 'SSPRK22', + tolerance_fpi: '1e-6', + iterations_max: '100', + log: 'true', + simulation_duration: '50.0', + extra_params: '{}' + }); + 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 handle = await window.showSaveFilePicker(options); - const writable = await handle.createWritable(); - await writable.write(blob); - await writable.close(); - } else { - throw new Error("showSaveFilePicker not supported"); + 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); } - } 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 { - const graphData = { - nodes, - edges, - nodeCounter, - solverParams, - globalVariables - }; + }; - const response = await fetch(getApiEndpoint('/convert-to-python'), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ graph: graphData }), - }); - const result = await response.json(); - if (result.success) { - // Check if File System Access API is supported - if ('showSaveFilePicker' in window) { - try { - // Modern approach: Use File System Access API for proper "Save As" dialog - const fileHandle = await window.showSaveFilePicker({ - suggestedName: 'fuel_cycle_script.py', - types: [{ - description: 'Python files', - accept: { - 'text/x-python': ['.py'] - } - }] - }); + // Allows user to save to python script + const saveToPython = async () => { + try { + const graphData = { + nodes, + edges, + nodeCounter, + solverParams, + globalVariables + }; - // Create a writable stream and write the Python script - const writable = await fileHandle.createWritable(); - await writable.write(result.script); - await writable.close(); + const response = await fetch(getApiEndpoint('/convert-to-python'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ graph: graphData }), + }); - alert('Python script generated and saved successfully!'); - } catch (error) { - if (error.name !== 'AbortError') { - console.error('Error saving Python file:', error); - alert('Failed to save Python script.'); + const result = await response.json(); + + if (result.success) { + // Check if File System Access API is supported + if ('showSaveFilePicker' in window) { + try { + // Modern approach: Use File System Access API for proper "Save As" dialog + const fileHandle = await window.showSaveFilePicker({ + suggestedName: 'fuel_cycle_script.py', + types: [{ + description: 'Python files', + accept: { + 'text/x-python': ['.py'] + } + }] + }); + + // Create a writable stream and write the Python script + const writable = await fileHandle.createWritable(); + await writable.write(result.script); + await writable.close(); + + alert('Python script generated and saved successfully!'); + } catch (error) { + if (error.name !== 'AbortError') { + console.error('Error saving Python file:', error); + alert('Failed to save Python script.'); + } + // User cancelled the dialog - no error message needed } - // User cancelled the dialog - no error message needed + } else { + // Fallback for browsers (Firefox, Safari) that don't support File System Access API + const blob = new Blob([result.script], { type: 'text/x-python' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'fuel_cycle_script.py'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + alert('Python script generated and downloaded to your default downloads folder!'); } } else { - // Fallback for browsers (Firefox, Safari) that don't support File System Access API - const blob = new Blob([result.script], { type: 'text/x-python' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'fuel_cycle_script.py'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - - alert('Python script generated and downloaded to your default downloads folder!'); + alert(`Error generating Python script: ${result.error}`); } - } else { - alert(`Error generating Python script: ${result.error}`); - } - } catch (error) { - console.error('Error:', error); - alert('Failed to generate Python script. Make sure the backend is running.'); - } - }; - // Function to run pathsim simulation - const runPathsim = async () => { - try { - const graphData = { - nodes, - edges, - solverParams, - globalVariables - }; - - const response = await fetch(getApiEndpoint('/run-pathsim'), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ graph: graphData }), - }); - - const result = await response.json(); - - 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 { - alert(`Error running Pathsim simulation: ${result.error}`); + } catch (error) { + console.error('Error:', error); + alert('Failed to generate Python script. Make sure the backend is running.'); } - } catch (error) { - console.error('Error:', error); - 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', - '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); - }; - - const addGlobalVariable = () => { - const newVariable = { - id: Date.now().toString(), - name: '', - value: '', - nameError: false }; - setGlobalVariables([...globalVariables, newVariable]); - }; + // Function to run pathsim simulation + const runPathsim = async () => { + try { + const graphData = { + nodes, + edges, + solverParams, + globalVariables + }; - const removeGlobalVariable = (id) => { - setGlobalVariables(globalVariables.filter(variable => variable.id !== id)); - }; + const response = await fetch(getApiEndpoint('/run-pathsim'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ graph: graphData }), + }); - const updateGlobalVariable = (id, field, value) => { - setGlobalVariables(globalVariables.map(variable => { - if (variable.id === id) { - const updatedVariable = { ...variable, [field]: value }; + const result = await response.json(); - // Validate name field - if (field === 'name') { - updatedVariable.nameError = value !== '' && !isValidPythonIdentifier(value); + 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 { + alert(`Error running Pathsim simulation: ${result.error}`); } - - return updatedVariable; - } - return variable; - })); - }; - - //When user connects two nodes by dragging, creates an edge according to the styles in our makeEdge function - const onConnect = useCallback( - (params) => { - let edgeId = `e${params.source}-${params.target}`; - - // If sourceHandle or targetHandle is specified, append it to the edge ID - if (params.sourceHandle) { - edgeId += `-from_${params.sourceHandle}`; - } - - if (params.targetHandle) { - edgeId += `-to_${params.targetHandle}`; + } catch (error) { + console.error('Error:', error); + alert('Failed to run Pathsim simulation. Make sure the backend is running.'); } - const newEdge = makeEdge({ - id: edgeId, - source: params.source, - target: params.target, - sourceHandle: params.sourceHandle, - targetHandle: params.targetHandle, - }); - - setEdges([...edges, newEdge]); - }, - [edges, setEdges] - ); - // Function that when we click on a node, sets that node as the selected node - const onNodeClick = (event, node) => { - setSelectedNode(node); - setSelectedEdge(null); // Clear selected edge when selecting a node - // Reset all edge styles when selecting a node - setEdges((eds) => - eds.map((e) => ({ - ...e, - style: { - ...e.style, - strokeWidth: 2, - stroke: '#ECDFCC', - }, - markerEnd: { - ...e.markerEnd, - color: '#ECDFCC', - }, - })) - ); - }; - // Function that when we click on an edge, sets that edge as the selected edge - const onEdgeClick = (event, edge) => { - setSelectedEdge(edge); - setSelectedNode(null); // Clear selected node when selecting an edge - // Update edge styles to highlight the selected edge - setEdges((eds) => - eds.map((e) => ({ - ...e, - style: { - ...e.style, - strokeWidth: e.id === edge.id ? 3 : 2, - stroke: e.id === edge.id ? '#ffd700' : '#ECDFCC', - }, - markerEnd: { - ...e.markerEnd, - color: e.id === edge.id ? '#ffd700' : '#ECDFCC', - }, - })) - ); - }; - // Function to deselect everything when clicking on the background - const onPaneClick = () => { - setSelectedNode(null); - setSelectedEdge(null); - setMenu(null); // Close context menu when clicking on pane - // Reset all edge styles when deselecting - setEdges((eds) => - eds.map((e) => ({ - ...e, - style: { - ...e.style, - strokeWidth: 2, - stroke: '#ECDFCC', - }, - markerEnd: { - ...e.markerEnd, - color: '#ECDFCC', - }, - })) - ); - }; - // Function to add a new node to the graph - const addNode = async () => { - // 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'); + // 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', + '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); + }; - const userInput = prompt( - `Select a node type by entering the number:\n\n${typeOptions}\n\nEnter your choice (1-${availableTypes.length}):` - ); + const addGlobalVariable = () => { + const newVariable = { + id: Date.now().toString(), + name: '', + value: '', + nameError: false + }; + setGlobalVariables([...globalVariables, newVariable]); + }; - // If user cancels the prompt - if (!userInput) { - return; - } + const removeGlobalVariable = (id) => { + setGlobalVariables(globalVariables.filter(variable => variable.id !== id)); + }; - // Parse the user input - const choiceIndex = parseInt(userInput) - 1; + const updateGlobalVariable = (id, field, value) => { + setGlobalVariables(globalVariables.map(variable => { + if (variable.id === id) { + const updatedVariable = { ...variable, [field]: value }; - // Validate the choice - if (isNaN(choiceIndex) || choiceIndex < 0 || choiceIndex >= availableTypes.length) { - alert('Invalid choice. Please enter a number between 1 and ' + availableTypes.length); - return; - } + // Validate name field + if (field === 'name') { + updatedVariable.nameError = value !== '' && !isValidPythonIdentifier(value); + } - const selectedType = availableTypes[choiceIndex]; - const newNodeId = nodeCounter.toString(); - - // Fetch default values for this node type - const defaults = await fetchDefaultValues(selectedType); - - // Store default values for this node type - setDefaultValues(prev => ({ - ...prev, - [selectedType]: defaults - })); - - // Create node data with label and initialize all expected fields as empty strings - let nodeData = { label: `${selectedType} ${newNodeId}` }; - - // Initialize all expected parameters as empty strings - Object.keys(defaults).forEach(key => { - nodeData[key] = ''; - }); - - const newNode = { - id: newNodeId, - type: selectedType, - position: { x: 200 + nodes.length * 50, y: 200 }, - data: nodeData, + return updatedVariable; + } + return variable; + })); }; - setNodes((nds) => [...nds, newNode]); - setNodeCounter((count) => count + 1); - }; + //When user connects two nodes by dragging, creates an edge according to the styles in our makeEdge function + const onConnect = useCallback( + (params) => { + let edgeId = `e${params.source}-${params.target}`; - // Function to pop context menu when right-clicking on a node - const onNodeContextMenu = useCallback( - (event, node) => { - // Prevent native context menu from showing - event.preventDefault(); + // If sourceHandle or targetHandle is specified, append it to the edge ID + if (params.sourceHandle) { + edgeId += `-from_${params.sourceHandle}`; + } - // Calculate position of the context menu. We want to make sure it - // doesn't get positioned off-screen. - const pane = ref.current.getBoundingClientRect(); - setMenu({ - id: node.id, - top: event.clientY < pane.height - 200 && event.clientY, - left: event.clientX < pane.width - 200 && event.clientX, - right: event.clientX >= pane.width - 200 && pane.width - event.clientX, - bottom: - event.clientY >= pane.height - 200 && pane.height - event.clientY, - }); - }, - [setMenu], - ); - - // Function to delete the selected node - const deleteSelectedNode = () => { - if (selectedNode) { - setNodes((nds) => nds.filter((node) => node.id !== selectedNode.id)); + if (params.targetHandle) { + edgeId += `-to_${params.targetHandle}`; + } + const newEdge = makeEdge({ + id: edgeId, + source: params.source, + target: params.target, + sourceHandle: params.sourceHandle, + targetHandle: params.targetHandle, + }); + + setEdges([...edges, newEdge]); + }, + [edges, setEdges] + ); + // Function that when we click on a node, sets that node as the selected node + const onNodeClick = (event, node) => { + setSelectedNode(node); + setSelectedEdge(null); // Clear selected edge when selecting a node + // Reset all edge styles when selecting a node + setEdges((eds) => + eds.map((e) => ({ + ...e, + style: { + ...e.style, + strokeWidth: 2, + stroke: '#ECDFCC', + }, + markerEnd: { + ...e.markerEnd, + color: '#ECDFCC', + }, + })) + ); + }; + // Function that when we click on an edge, sets that edge as the selected edge + const onEdgeClick = (event, edge) => { + setSelectedEdge(edge); + setSelectedNode(null); // Clear selected node when selecting an edge + // Update edge styles to highlight the selected edge setEdges((eds) => - eds.filter((edge) => edge.source !== selectedNode.id && edge.target !== selectedNode.id) + eds.map((e) => ({ + ...e, + style: { + ...e.style, + strokeWidth: e.id === edge.id ? 3 : 2, + stroke: e.id === edge.id ? '#ffd700' : '#ECDFCC', + }, + markerEnd: { + ...e.markerEnd, + color: e.id === edge.id ? '#ffd700' : '#ECDFCC', + }, + })) ); + }; + // Function to deselect everything when clicking on the background + const onPaneClick = () => { setSelectedNode(null); - } - }; - // Function to delete the selected edge - const deleteSelectedEdge = () => { - if (selectedEdge) { - setEdges((eds) => { - const filteredEdges = eds.filter((edge) => edge.id !== selectedEdge.id); - // Reset styles for remaining edges - return filteredEdges.map((e) => ({ + setSelectedEdge(null); + setMenu(null); // Close context menu when clicking on pane + // Reset all edge styles when deselecting + setEdges((eds) => + eds.map((e) => ({ ...e, style: { ...e.style, @@ -706,1154 +653,1280 @@ export default function App() { ...e.markerEnd, color: '#ECDFCC', }, - })); - }); - setSelectedEdge(null); - } - }; - - // Function to duplicate a node - 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) { - // For keyboard shortcuts, place the duplicate at a more visible offset - position = { - x: node.position.x + 100, - y: node.position.y + 100, - }; - } else { - // For context menu, use smaller offset - position = { - x: node.position.x + 50, - y: node.position.y + 50, - }; - } - - const newNode = { - ...node, - selected: false, - dragging: false, - id: newNodeId, - position, - data: { - ...node.data, - label: node.data.label ? node.data.label.replace(node.id, newNodeId) : `${node.type} ${newNodeId}` - } + })) + ); }; + // Function to add a new node to the graph + const addNode = async () => { + // Get available node types from nodeTypes object + const availableTypes = Object.keys(nodeTypes); - setNodes((nds) => [...nds, newNode]); - setNodeCounter((count) => count + 1); - setMenu(null); // Close the context menu - }, [nodes, nodeCounter, setNodeCounter, setNodes, setMenu]); + // 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}):` + ); - // Keyboard event handler for deleting selected items - useEffect(() => { - const handleKeyDown = (event) => { - // Don't trigger deletion if user is typing in an input field - if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') { + // If user cancels the prompt + if (!userInput) { return; } - // Handle Ctrl+C (copy) - if (event.ctrlKey && event.key === 'c' && selectedNode) { - event.preventDefault(); - setCopiedNode(selectedNode); - setCopyFeedback(`Copied: ${selectedNode.data.label || selectedNode.id}`); - - // Clear feedback after 2 seconds - setTimeout(() => { - setCopyFeedback(''); - }, 2000); + // Parse the user input + const choiceIndex = parseInt(userInput) - 1; - console.log('Node copied:', selectedNode.id); + // Validate the choice + if (isNaN(choiceIndex) || choiceIndex < 0 || choiceIndex >= availableTypes.length) { + alert('Invalid choice. Please enter a number between 1 and ' + availableTypes.length); return; } - // Handle Ctrl+V (paste) - if (event.ctrlKey && event.key === 'v' && copiedNode) { + const selectedType = availableTypes[choiceIndex]; + const newNodeId = nodeCounter.toString(); + + // Fetch default values for this node type + const defaults = await fetchDefaultValues(selectedType); + + // Store default values for this node type + setDefaultValues(prev => ({ + ...prev, + [selectedType]: defaults + })); + + // Create node data with label and initialize all expected fields as empty strings + let nodeData = { label: `${selectedType} ${newNodeId}` }; + + // Initialize all expected parameters as empty strings + Object.keys(defaults).forEach(key => { + nodeData[key] = ''; + }); + + 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(); - duplicateNode(copiedNode.id, { fromKeyboard: true }); - return; + + // Calculate position of the context menu. We want to make sure it + // doesn't get positioned off-screen. + const pane = ref.current.getBoundingClientRect(); + setMenu({ + id: node.id, + top: event.clientY < pane.height - 200 && event.clientY, + left: event.clientX < pane.width - 200 && event.clientX, + right: event.clientX >= pane.width - 200 && pane.width - event.clientX, + bottom: + event.clientY >= pane.height - 200 && pane.height - event.clientY, + }); + }, + [setMenu], + ); + + // Function to delete the selected node + const deleteSelectedNode = () => { + if (selectedNode) { + setNodes((nds) => nds.filter((node) => node.id !== selectedNode.id)); + setEdges((eds) => + eds.filter((edge) => edge.source !== selectedNode.id && edge.target !== selectedNode.id) + ); + setSelectedNode(null); } + }; + // Function to delete the selected edge + const deleteSelectedEdge = () => { + if (selectedEdge) { + setEdges((eds) => { + const filteredEdges = eds.filter((edge) => edge.id !== selectedEdge.id); + // Reset styles for remaining edges + return filteredEdges.map((e) => ({ + ...e, + style: { + ...e.style, + strokeWidth: 2, + stroke: '#ECDFCC', + }, + markerEnd: { + ...e.markerEnd, + color: '#ECDFCC', + }, + })); + }); + setSelectedEdge(null); + } + }; - // Handle Ctrl+D (duplicate selected node directly) - if (event.ctrlKey && event.key === 'd' && selectedNode) { - event.preventDefault(); - duplicateNode(selectedNode.id, { fromKeyboard: true }); - return; + // Function to duplicate a node + 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) { + // For keyboard shortcuts, place the duplicate at a more visible offset + position = { + x: node.position.x + 100, + y: node.position.y + 100, + }; + } else { + // For context menu, use smaller offset + position = { + x: node.position.x + 50, + y: node.position.y + 50, + }; } - if (event.key === 'Delete' || event.key === 'Backspace') { - if (selectedEdge) { - deleteSelectedEdge(); - } else if (selectedNode) { - deleteSelectedNode(); + const newNode = { + ...node, + selected: false, + dragging: false, + id: newNodeId, + position, + data: { + ...node.data, + label: node.data.label ? node.data.label.replace(node.id, newNodeId) : `${node.type} ${newNodeId}` } - } - }; + }; - document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('keydown', handleKeyDown); - }; - }, [selectedEdge, selectedNode, copiedNode, duplicateNode, setCopyFeedback]); - - return ( -
- {/* Tab Navigation */} -
- - - - -
+ setNodes((nds) => [...nds, newNode]); + setNodeCounter((count) => count + 1); + setMenu(null); // Close the context menu + }, [nodes, nodeCounter, setNodeCounter, setNodes, setMenu]); - {/* Graph Editor Tab */} - {activeTab === 'graph' && ( -
- - - - - {menu && } - {copyFeedback && ( -
- {copyFeedback} -
- )} - - - - - - - - - - -
- Keyboard Shortcuts:
- Ctrl+C: Copy selected node
- Ctrl+V: Paste copied node
- Ctrl+D: Duplicate selected node
- Del/Backspace: Delete selection
- Right-click: Context menu -
-
- {selectedNode && ( -
-

{selectedNode.data.label}

- {(() => { - // Get default values for this node type - const nodeDefaults = defaultValues[selectedNode.type] || {}; - - // Create a list of all possible parameters (both current data and defaults) - const allParams = new Set([ - ...Object.keys(selectedNode.data), - ...Object.keys(nodeDefaults) - ]); - - return Array.from(allParams) - .map(key => { - const currentValue = selectedNode.data[key] || ''; - const defaultValue = nodeDefaults[key]; - const placeholder = defaultValue !== undefined && defaultValue !== null ? - String(defaultValue) : ''; - - return ( -
- - { - const newValue = e.target.value; - const updatedNode = { - ...selectedNode, - data: { ...selectedNode.data, [key]: newValue }, - }; - - setNodes((nds) => - nds.map((node) => - node.id === selectedNode.id ? updatedNode : node - ) - ); - setSelectedNode(updatedNode); - }} - style={{ - width: '100%', - marginTop: 4, - padding: '8px', - borderRadius: '4px', - border: '1px solid #555', - backgroundColor: '#2a2a3e', - color: '#ffffff', - fontSize: '14px' - }} - /> -
- ); - }); - })()} - -
- -
- )} - {selectedEdge && ( -
-

Selected Edge

-
- ID: {selectedEdge.id} -
-
- Source: {selectedEdge.source} -
-
- Target: {selectedEdge.target} -
-
- Type: {selectedEdge.type} -
-
- - -
- )} -
- )} + // Keyboard event handler for deleting selected items + useEffect(() => { + const handleKeyDown = (event) => { + // Don't trigger deletion if user is typing in an input field + if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') { + return; + } - {/* Solver Parameters Tab */} - {activeTab === 'solver' && ( + // Handle Ctrl+C (copy) + if (event.ctrlKey && event.key === 'c' && selectedNode) { + 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; + } + + // Handle Ctrl+V (paste) + if (event.ctrlKey && event.key === 'v' && copiedNode) { + event.preventDefault(); + duplicateNode(copiedNode.id, { fromKeyboard: true }); + return; + } + + // Handle Ctrl+D (duplicate selected node directly) + if (event.ctrlKey && event.key === 'd' && selectedNode) { + event.preventDefault(); + duplicateNode(selectedNode.id, { fromKeyboard: true }); + return; + } + + if (event.key === 'Delete' || event.key === 'Backspace') { + if (selectedEdge) { + deleteSelectedEdge(); + } else if (selectedNode) { + deleteSelectedNode(); + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [selectedEdge, selectedNode, copiedNode, duplicateNode, setCopyFeedback]); + + + + + + + + + return ( +
+ {/* Tab Navigation */}
-
-

- Solver Parameters -

-
-
-
- - setSolverParams({ ...solverParams, dt: e.target.value })} + + + + +
+ + {/* Graph Editor Tab */} + {activeTab === 'graph' && ( +
+
+
+ + + + + + {menu && } + {copyFeedback && ( +
+ {copyFeedback} +
+ )} +
- -
- - setSolverParams({ ...solverParams, dt_min: e.target.value })} + onClick={deleteSelectedEdge} + disabled={!selectedEdge} + > + Delete Edge + +
- -
- - setSolverParams({ ...solverParams, dt_max: e.target.value })} + onClick={deleteSelectedNode} + disabled={!selectedNode} + > + Delete Node + +
- -
- - -
- -
- - setSolverParams({ ...solverParams, tolerance_fpi: e.target.value })} + Save File + +
- -
- - setSolverParams({ ...solverParams, iterations_max: e.target.value })} + onClick={loadGraph} + > + Load File + +
- -
- - setSolverParams({ ...solverParams, simulation_duration: e.target.value })} + onClick={resetGraph} + > + Reset Graph + +
+ onClick={saveToPython} + > + Save to
Python + + -
- -
-
- -