From 446ceeea1daf459c758b1ba78f818b78731fc81d Mon Sep 17 00:00:00 2001 From: RemDelaporteMathurin Date: Mon, 18 Aug 2025 11:54:51 -0400 Subject: [PATCH] added functioanlity for URL sharing --- docs/usage.rst | 34 ++++++++ src/App.jsx | 104 +++++++++++++++++++++++++ src/components/GraphView.jsx | 25 +++++- src/components/ShareModal.jsx | 141 ++++++++++++++++++++++++++++++++++ src/utils/urlSharing.js | 118 ++++++++++++++++++++++++++++ 5 files changed, 420 insertions(+), 2 deletions(-) create mode 100644 src/components/ShareModal.jsx create mode 100644 src/utils/urlSharing.js diff --git a/docs/usage.rst b/docs/usage.rst index 49123ea6..86f56113 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -201,3 +201,37 @@ Export to python For advanced users, PathView allows you to export your graph as a Python script. This feature is useful for integrating your simulation into larger Python projects or for further analysis using Python libraries. This is useful for instance for performing parametric studies or sensitivity analysis, where you can easily modify parameters in the Python script and rerun the simulation. + +Sharing Graphs via URL +---------------------- + +PathView supports sharing complete graph configurations through URLs, making collaboration and graph distribution easy. + +**How to share a graph:** + +1. Create and configure your graph with all necessary nodes, connections, and parameters +2. Click the "🔗 Share URL" button in the floating action buttons (top-right area) +3. The complete graph URL is automatically copied to your clipboard +4. Share this URL with others - when they visit it, your exact graph configuration will load automatically + +**What's included in shared URLs:** + +- All node positions and configurations +- Edge connections and data flow +- Solver parameters and simulation settings +- Global variables and their values +- Event definitions +- Custom Python code + +**Best practices:** + +- URLs work best for moderately-sized graphs. For very complex graphs with many nodes, consider using the file save/load functionality instead +- URLs contain all graph data encoded in base64, so they can become quite long +- The shared graph state is completely self-contained - no server storage required + +**Example use cases:** + +- Sharing example configurations with students or colleagues +- Creating bookmarks for frequently-used graph templates +- Collaborating on model development +- Including interactive models in documentation or presentations diff --git a/src/App.jsx b/src/App.jsx index 2c40a480..a286e9bf 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -9,6 +9,12 @@ import { import '@xyflow/react/dist/style.css'; import './styles/App.css'; import { getApiEndpoint } from './config.js'; +import { + getGraphDataFromURL, + generateShareableURL, + updateURLWithGraphData, + clearGraphDataFromURL +} from './utils/urlSharing.js'; import Sidebar from './components/Sidebar'; import NodeSidebar from './components/NodeSidebar'; import { DnDProvider, useDnD } from './components/DnDContext.jsx'; @@ -22,6 +28,7 @@ import GraphView from './components/GraphView.jsx'; import EdgeDetails from './components/EdgeDetails.jsx'; import SolverPanel from './components/SolverPanel.jsx'; import ResultsPanel from './components/ResultsPanel.jsx'; +import ShareModal from './components/ShareModal.jsx'; // * Declaring variables * @@ -99,6 +106,62 @@ const DnDFlow = () => { // Python code editor state const [pythonCode, setPythonCode] = useState("# Define your Python variables and functions here\n# Example:\n# my_variable = 42\n# def my_function(x):\n# return x * 2\n"); + // State for URL sharing feedback + const [shareUrlFeedback, setShareUrlFeedback] = useState(''); + const [showShareModal, setShowShareModal] = useState(false); + const [shareableURL, setShareableURL] = useState(''); + + // Load graph data from URL on component mount + useEffect(() => { + const loadGraphFromURL = () => { + const urlGraphData = getGraphDataFromURL(); + if (urlGraphData) { + try { + // Validate that it's a valid graph file + if (!urlGraphData.nodes || !Array.isArray(urlGraphData.nodes)) { + console.warn("Invalid graph data in URL"); + return; + } + + // Load the graph data and ensure nodeColor exists on all nodes + const { + nodes: loadedNodes, + edges: loadedEdges, + nodeCounter: loadedNodeCounter, + solverParams: loadedSolverParams, + globalVariables: loadedGlobalVariables, + events: loadedEvents, + pythonCode: loadedPythonCode + } = urlGraphData; + + // Ensure all loaded nodes have a nodeColor property + const nodesWithColors = (loadedNodes || []).map(node => ({ + ...node, + data: { + ...node.data, + nodeColor: node.data.nodeColor || '#DDE6ED' + } + })); + + setNodes(nodesWithColors); + setEdges(loadedEdges || []); + setSelectedNode(null); + setNodeCounter(loadedNodeCounter ?? loadedNodes.length); + setSolverParams(loadedSolverParams ?? DEFAULT_SOLVER_PARAMS); + setGlobalVariables(loadedGlobalVariables ?? []); + setEvents(loadedEvents ?? []); + setPythonCode(loadedPythonCode ?? "# Define your Python variables and functions here\n# Example:\n# my_variable = 42\n# def my_function(x):\n# return x * 2\n"); + + console.log('Graph loaded from URL successfully'); + } catch (error) { + console.error('Error loading graph from URL:', error); + } + } + }; + + loadGraphFromURL(); + }, []); // Empty dependency array means this runs once on mount + const [defaultValues, setDefaultValues] = useState({}); const [isEditingLabel, setIsEditingLabel] = useState(false); const [tempLabel, setTempLabel] = useState(''); @@ -500,7 +563,41 @@ const DnDFlow = () => { setNodeCounter(0); setSolverParams(DEFAULT_SOLVER_PARAMS); setGlobalVariables([]); + // Clear URL when resetting graph + clearGraphDataFromURL(); + }; + + // Share current graph via URL + const shareGraphURL = async () => { + const graphData = { + version: versionInfo ? Object.fromEntries(Object.entries(versionInfo).filter(([key]) => key !== 'status')) : 'unknown', + nodes, + edges, + nodeCounter, + solverParams, + globalVariables, + events, + pythonCode + }; + + try { + const url = generateShareableURL(graphData); + if (url) { + setShareableURL(url); + setShowShareModal(true); + // Update browser URL as well + updateURLWithGraphData(graphData, true); + } else { + setShareUrlFeedback('Error generating share URL'); + setTimeout(() => setShareUrlFeedback(''), 3000); + } + } catch (error) { + console.error('Error sharing graph URL:', error); + setShareUrlFeedback('Error generating share URL'); + setTimeout(() => setShareUrlFeedback(''), 3000); + } }; + const downloadCsv = async () => { if (!csvData) return; @@ -1050,6 +1147,7 @@ const DnDFlow = () => { selectedNode, selectedEdge, deleteSelectedNode, deleteSelectedEdge, saveGraph, loadGraph, resetGraph, saveToPython, runPathsim, + shareGraphURL, dockOpen, setDockOpen, onToggleLogs, showKeyboardShortcuts, setShowKeyboardShortcuts, }} @@ -1116,6 +1214,12 @@ const DnDFlow = () => { /> )} + {/* Share URL Modal */} + setShowShareModal(false)} + shareableURL={shareableURL} + /> ); diff --git a/src/components/GraphView.jsx b/src/components/GraphView.jsx index 32cdf100..a72f4846 100644 --- a/src/components/GraphView.jsx +++ b/src/components/GraphView.jsx @@ -49,6 +49,7 @@ function FloatingButtons({ selectedNode, selectedEdge, deleteSelectedNode, deleteSelectedEdge, saveGraph, loadGraph, resetGraph, saveToPython, runPathsim, + shareGraphURL, dockOpen, onToggleLogs }) { return ( @@ -162,7 +163,27 @@ function FloatingButtons({ style={{ position: 'absolute', right: 20, - top: 150, + top: 143, + zIndex: 10, + padding: '8px 8px', + backgroundColor: '#78A083', + color: 'white', + border: 'none', + borderRadius: 5, + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + gap: '6px', + }} + onClick={shareGraphURL} + > + 🔗 + + + + {/* Header */} +
+
+ +
+

+ Copy this URL to share your workflow with others. +

+
+ + {/* URL input and copy button */} +
+ e.target.select()} + /> + +
+ + {/* Additional info */} +
+

+ This URL contains your complete graph configuration including nodes, connections, parameters, and code. +

+
+ + + ); +}; + +export default ShareModal; diff --git a/src/utils/urlSharing.js b/src/utils/urlSharing.js new file mode 100644 index 00000000..2195d09f --- /dev/null +++ b/src/utils/urlSharing.js @@ -0,0 +1,118 @@ +/** + * URL sharing utilities for PathView + * Handles encoding and decoding graph data in URLs + */ + +/** + * Encode graph data to a base64 URL parameter + * @param {Object} graphData - The complete graph data object + * @returns {string} - Base64 encoded string + */ +export function encodeGraphData(graphData) { + try { + const jsonString = JSON.stringify(graphData); + // Use btoa for base64 encoding, but handle Unicode strings properly + const utf8Bytes = new TextEncoder().encode(jsonString); + const binaryString = Array.from(utf8Bytes, byte => String.fromCharCode(byte)).join(''); + return btoa(binaryString); + } catch (error) { + console.error('Error encoding graph data:', error); + return null; + } +} + +/** + * Decode graph data from a base64 URL parameter + * @param {string} encodedData - Base64 encoded graph data + * @returns {Object|null} - Decoded graph data object or null if error + */ +export function decodeGraphData(encodedData) { + try { + // Decode base64 and handle Unicode properly + const binaryString = atob(encodedData); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + const jsonString = new TextDecoder().decode(bytes); + return JSON.parse(jsonString); + } catch (error) { + console.error('Error decoding graph data:', error); + return null; + } +} + +/** + * Generate a shareable URL with the current graph data + * @param {Object} graphData - The complete graph data object + * @returns {string} - Complete shareable URL + */ +export function generateShareableURL(graphData) { + try { + const encodedData = encodeGraphData(graphData); + if (!encodedData) { + throw new Error('Failed to encode graph data'); + } + + const baseURL = window.location.origin + window.location.pathname; + const url = new URL(baseURL); + url.searchParams.set('graph', encodedData); + + return url.toString(); + } catch (error) { + console.error('Error generating shareable URL:', error); + return null; + } +} + +/** + * Extract graph data from current URL parameters + * @returns {Object|null} - Graph data object or null if not found/error + */ +export function getGraphDataFromURL() { + try { + const urlParams = new URLSearchParams(window.location.search); + const encodedData = urlParams.get('graph'); + + if (!encodedData) { + return null; + } + + return decodeGraphData(encodedData); + } catch (error) { + console.error('Error extracting graph data from URL:', error); + return null; + } +} + +/** + * Update the current URL with graph data without page reload + * @param {Object} graphData - The complete graph data object + * @param {boolean} replaceState - Whether to replace current history state (default: false) + */ +export function updateURLWithGraphData(graphData, replaceState = false) { + try { + const shareableURL = generateShareableURL(graphData); + if (shareableURL) { + if (replaceState) { + window.history.replaceState({}, '', shareableURL); + } else { + window.history.pushState({}, '', shareableURL); + } + } + } catch (error) { + console.error('Error updating URL with graph data:', error); + } +} + +/** + * Clear graph data from URL without page reload + */ +export function clearGraphDataFromURL() { + try { + const baseURL = window.location.origin + window.location.pathname; + window.history.replaceState({}, '', baseURL); + } catch (error) { + console.error('Error clearing graph data from URL:', error); + } +}