diff --git a/README.md b/README.md index 2e7eee5..379d69d 100644 --- a/README.md +++ b/README.md @@ -2,32 +2,131 @@ # ClusterSim -A cluster state simulator for measurement-based quantum computation, in browser. Try it here: [https://clustersim.app](https://clustersim.app) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Python Version](https://img.shields.io/badge/python-3.12%2B-blue.svg)](https://www.python.org/) +**ClusterSim** is an interactive, browser-based simulator for measurement-based quantum computation (MBQC) and stabilizer circuits using a graph state representation. -> Based on the paper 'Fast simulation of stabilizer circuits using a graph state representation' by Simon Anders and Hans J. Briegel ([here](https://arxiv.org/abs/quant-ph/0504117v2)) +Live Web Application: [https://clustersim.app](https://clustersim.app) + +> Based on the paper *"Fast simulation of stabilizer circuits using a graph state representation"* by Simon Anders and Hans J. Briegel ([arXiv:quant-ph/0504117v2](https://arxiv.org/abs/quant-ph/0504117v2)). + +--- + +## Key Features + +- **Interactive 2D Cytoscape Interface (`graph2d_app.py`)**: + - **Quantum Gate Application**: Apply Pauli gates ($X$, $Y$, $Z$), Clifford gates ($H$, $S$, $S^\dagger$), Controlled operations ($CX$, $CZ$), and Local Complementations ($LC$, $LCR$). + - **Measurements**: Measure qubits in $X$, $Y$, or $Z$ bases with random or forced outcomes ($0$ or $1$). + - **Fusion Gates**: Execute 2-qubit fusion gates ($XXZZ$, $XZZX$, $XYYZ$) with success/failure modes and outcome control. + - **Graph Manipulations**: Add/remove nodes, toggle/delete edges, and copy/duplicate subgraphs. + - **Compound Group Containers**: Hierarchically group and ungroup selected qubits into parent container boxes. + - **Snapshot-Based Undo / Redo**: Step backward and forward through any sequence of quantum operations or plot customizations. + - **Move Log & String Export**: Track step-by-step move history and load/export graph state string representations. + - **Styling & Export Options**: Customize node labels, preset Cytoscape shapes, snap nodes to a grid, and export to SVG or JSON. + +- **3D Grid Visualization (`grid3d_app.py`)**: + - Interactive 3D lattice cluster state simulation and visualization. ## Installation -To install, use: +### Prerequisites + +- Python `>= 3.12` +- [`uv`](https://github.com/astral-sh/uv) (recommended) or standard `pip` + +### Install from Source + +```bash +git clone https://github.com/zhiihan/ClusterSim.git +cd ClusterSim + +# Using pip: +pip install -e . + +# Or using uv: +uv sync +``` + +--- + +## Quick Start + +### 1. Launch the 2D Graph Simulator + +```bash +python graph2d_app.py +``` + +Open your browser and navigate to `http://127.0.0.1:8050/`. + +#### Keyboard Shortcuts (2D App) +- **$N$**: Add node +- **$E$**: Toggle edge between selected nodes +- **Shift + $E$**: Delete edge between selected nodes +- **Backspace**: Delete selected node(s) +- **$H$**: Apply Hadamard ($H$) gate +- **$W$**: Apply Local Complementation ($LC$) +- **$G$**: Group / Ungroup selected nodes +- **Ctrl + $C$ / Ctrl + $V$**: Copy and paste/duplicate selected nodes +- **Ctrl + $Z$ / Ctrl + $Y$**: Undo / Redo + +### 2. Launch the 3D Grid Simulator + +```bash +python grid3d_app.py +``` + +Open your browser and navigate to `http://127.0.0.1:8050/`. + +--- + +## Project Structure -`pip install .` +```text +ClusterSim/ +├── assets/ # Static assets and clientside JS listeners +├── src/ +│ └── clustersim/ +│ └── simulator/ +│ ├── cluster_state.py # Core ClusterState stabilizer simulation engine +│ └── frontend_cluster_state.py # 2D layout adapter (metadata, groups, undo stack) +│ └── components/ +│ ├── components_2d/ # Dash 2D UI components (action panel, Cytoscape, figure) +│ └── components_3d/ # Dash 3D UI components +├── tests/ # Pytest test suite +├── graph2d_app.py # 2D Dash Application Entry Point +├── grid3d_app.py # 3D Dash Application Entry Point +├── pyproject.toml # Project configuration & dependencies +└── README.md # Documentation +``` + +--- -After installation, run +## Testing & Quality +Run the test suite using `pytest`: + +```bash +pytest ``` -python grid3dfigure.py + +Run code formatting checks with `ruff`: + +```bash +ruff format --check ``` +--- + +## References + +1. S. Anders, H. J. Briegel, *"Fast simulation of stabilizer circuits using a graph state representation"*, [Phys. Rev. A 73, 022334 (2006)](https://arxiv.org/abs/quant-ph/0504117v2). +2. M. Hein, J. Eisert, H. J. Briegel, *"Multi-party entanglement in graph states"*, [Phys. Rev. A 69, 062311 (2004)](https://arxiv.org/abs/quant-ph/0307130). +3. D. Schlingemann, *"Cluster states, graph states, and stabilizer codes"*, [quant-ph/0111080](https://arxiv.org/abs/quant-ph/0111080). + +--- -### Graph State +## License -* This implementation is based around graph states. -These were introduced in the paper about entanglement purification ([here](https://arxiv.org/abs/quant-ph/0512218)) to study the entanglement properties of certain multi-qubit systems -* Takes their name from graphs in maths -* Each qubit corresponds to a vertex of the graph, and each edge indicates which qubits have interacted. -* There is a bijection between stabilizer states (the states that can appear in a stabilizer circuit) and graph states. That is, every graph state has a corresponding stabilizer state, and every stabilizer state has a corresponding graph state. -* This can be shown as: Any stabilizer state can be transformed to a graph state by applying a tensor product of local Clifford operations. These are known as *vertex operators* (VOPs). See [this paper](https://arxiv.org/abs/quant-ph/0111080) and [this paper](https://journals.aps.org/pra/abstract/10.1103/PhysRevA.69.022316). -* The standard approach is to store a tableau of stabillzer operators (an $n \times n$ matrix of Pauli operators). -* The improved algorithm needs only the graph state and the list of VOPs, and requires space $\mathcal{O}(n \log n)$. -* To then change the state, measurement is studied in [this paper](https://journals.aps.org/pra/abstract/10.1103/PhysRevA.69.062311), and gate application in [the paper mentioned above](https://arxiv.org/pdf/quant-ph/0504117v2.pdf). +This project is licensed under the [MIT License](LICENSE). diff --git a/assets/clientside.js b/assets/clientside.js index 6f7c460..ec543b4 100644 --- a/assets/clientside.js +++ b/assets/clientside.js @@ -1,6 +1,11 @@ window.dash_clientside = window.dash_clientside || {}; window.dash_clientside.clientside = { keydown_listener: function (dummy) { + if (window._keydown_listener_attached) { + return window.dash_clientside.no_update; + } + window._keydown_listener_attached = true; + document.addEventListener('keydown', function (e) { // Do not capture keys if the user is typing in an input or textarea const active = document.activeElement; @@ -87,6 +92,14 @@ window.dash_clientside.clientside = { btn.click(); } } + // Check if G (Group / Ungroup) + if (!e.ctrlKey && !e.metaKey && !e.shiftKey && (e.key === 'g' || e.key === 'G')) { + const btn = document.getElementById('key-g-btn'); + if (btn) { + e.preventDefault(); + btn.click(); + } + } }, true); return window.dash_clientside.no_update; } diff --git a/assets/custom_style.css b/assets/custom_style.css index 3d64154..3fd4b6c 100644 --- a/assets/custom_style.css +++ b/assets/custom_style.css @@ -17,8 +17,8 @@ body { /* Fullscreen cytoscape container positioned below the top panel */ .cytoscape-container { position: absolute; - top: 194px; - /* 12px top + 170px panel + 12px gap */ + top: 114px; + /* 12px top + 90px panel + 12px gap */ left: 16px; right: 16px; bottom: 16px; @@ -40,7 +40,8 @@ body { top: 12px; left: 16px; right: 16px; - height: 170px; + min-height: 90px; + height: auto !important; /* Compact height that fully displays controls without clipping */ z-index: 10; background: rgba(255, 255, 255, 0.96) !important; @@ -49,7 +50,7 @@ body { border: 1px solid #dee2e6 !important; border-radius: 12px !important; box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important; - padding: 12px 20px; + padding: 6px 20px; overflow: visible; /* Allows dropdown menus to float over the canvas */ } @@ -94,4 +95,59 @@ pre { font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 13px; color: #212529; +} + +/* Responsive Toolbar for Mobile */ +@media (max-width: 1199px) { + .glass-top-panel { + height: auto !important; + min-height: 90px; + padding: 8px 16px !important; + } + .scrollable-toolbar-container { + overflow-x: auto; + overflow-y: hidden; + width: 100%; + scrollbar-width: none; /* Firefox */ + } + .scrollable-toolbar-container::-webkit-scrollbar { + display: none; /* Chrome, Safari, Opera */ + } + .scrollable-toolbar-container .row { + display: flex !important; + flex-wrap: nowrap !important; + width: max-content !important; + } + .scrollable-toolbar-container .btn-group { + flex-wrap: nowrap !important; + } +} + +@media (max-width: 767px) { + .glass-top-panel h2 { + font-size: 14px !important; + } + .glass-top-panel #ui { + font-size: 0.65rem !important; + padding: 1px 4px !important; + } +} + +/* Floating Rounded-Rectangle Offcanvas Sidebar */ +.offcanvas { + top: 12px !important; + bottom: 12px !important; + right: 16px !important; + height: calc(100vh - 24px) !important; + border-radius: 12px !important; + border: 1px solid #dee2e6 !important; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important; + background: rgba(255, 255, 255, 0.98) !important; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +/* Ensure modal containers share the same 12px border radius */ +.modal-content { + border-radius: 12px !important; } \ No newline at end of file diff --git a/graph2d_app.py b/graph2d_app.py index b950fcd..f520a03 100644 --- a/graph2d_app.py +++ b/graph2d_app.py @@ -1,6 +1,6 @@ from dash import Dash, html, dcc, Input, Output, ClientsideFunction -from components.components_2d import tab_ui_2d, figure_2d +from components.components_2d import tab_ui_2d, figure_2d, cytoscape_offcanvas from clustersim.app import BrowserState from clustersim.simulator import ClusterState @@ -27,6 +27,7 @@ ], className="app-container", ), + cytoscape_offcanvas, dcc.Store(id="browser-data"), dcc.Store(id="graph-data"), dcc.Store(id="draw-plot"), # This is a dummy variable @@ -42,6 +43,7 @@ html.Button(id="key-shift-e-btn", style={"display": "none"}), html.Button(id="key-h-btn", style={"display": "none"}), html.Button(id="key-w-btn", style={"display": "none"}), + html.Button(id="key-g-btn", style={"display": "none"}), html.Div( id="none", children=[], diff --git a/src/clustersim/simulator/__init__.py b/src/clustersim/simulator/__init__.py index f9fd646..48ece4e 100644 --- a/src/clustersim/simulator/__init__.py +++ b/src/clustersim/simulator/__init__.py @@ -1,3 +1,4 @@ from .cluster_state import ClusterState +from .frontend_cluster_state import FrontendClusterState -__all__ = ["ClusterState"] +__all__ = ["ClusterState", "FrontendClusterState"] diff --git a/src/clustersim/simulator/cluster_state.py b/src/clustersim/simulator/cluster_state.py index 1daf0c2..6c01421 100644 --- a/src/clustersim/simulator/cluster_state.py +++ b/src/clustersim/simulator/cluster_state.py @@ -323,9 +323,6 @@ def _parse_ops_from_string(string: str) -> list[tuple[str, list[int], str | None full_text = " ".join(lines) - if not full_text: - raise ValueError("Cannot construct ClusterState from empty text.") - parsed_ops = [] # Regex to find commands: NAME followed optionally by [numbers] diff --git a/src/clustersim/simulator/frontend_cluster_state.py b/src/clustersim/simulator/frontend_cluster_state.py new file mode 100644 index 0000000..9a5d6a3 --- /dev/null +++ b/src/clustersim/simulator/frontend_cluster_state.py @@ -0,0 +1,657 @@ +from __future__ import annotations +import random +import copy +import json +from typing import Any, Dict, List, Optional, Tuple, Set, Union +import uuid +import rustworkx as rx +import graphsim +from clustersim.simulator.cluster_state import ClusterState + + +class FrontendClusterState: + """ + Adapter and wrapper class that extends ClusterState with 2D layout and frontend metadata, + such as node 2D positions, compound parent groups, custom styling, persistent node values, + and snapshot-based Undo/Redo capability. + """ + + def __init__( + self, + num_nodes: int = 0, + cluster_state: Optional[ClusterState] = None, + positions: Optional[ + Union[List[Dict[str, float]], Dict[int, Dict[str, float]]] + ] = None, + groups: Optional[Dict[str, Dict[str, Any]]] = None, + ): + """ + Initialize FrontendClusterState. + + Args: + num_nodes (int): Number of quantum nodes (if cluster_state is not provided). + cluster_state (ClusterState, optional): Existing ClusterState instance. + positions (list or dict, optional): Initial 2D positions for nodes. + groups (dict, optional): Initial compound parent groups definitions. + """ + if cluster_state is not None: + self.cluster_state = cluster_state + else: + self.cluster_state = ClusterState(num_nodes) + + n = len(self.cluster_state) + + # Initialize node metadata list (1-to-1 with qubits 0..n-1) + self.node_metadata: List[Dict[str, Any]] = [] + + def _get_init_pos(i: int) -> Dict[str, float]: + if positions: + if isinstance(positions, list) and i < len(positions): + return dict(positions[i]) + if isinstance(positions, dict) and i in positions: + return dict(positions[i]) + return { + "x": float(random.randint(0, 300)), + "y": float(random.randint(0, 300)), + } + + for i in range(n): + self.node_metadata.append( + { + "position": _get_init_pos(i), + "parent": None, + "value": uuid.uuid4().hex[:8], + "label": str(i), + "classes": "", + "style": {}, + } + ) + + self.groups: Dict[str, Dict[str, Any]] = copy.deepcopy(groups) if groups else {} + self.undo_stack: List[Dict[str, Any]] = [] + self.redo_stack: List[Dict[str, Any]] = [] + + def __len__(self) -> int: + return len(self.cluster_state) + + @property + def num_nodes(self) -> int: + return len(self.cluster_state) + + @property + def stabilizers(self) -> List[str]: + return self.cluster_state.stabilizers + + @property + def vertex_operators(self) -> List[str]: + return self.cluster_state.vertex_operators + + @property + def adjacency_list(self) -> List[List[int]]: + return self.cluster_state.adjacency_list + + def __repr__(self) -> str: + rep = f"FrontendClusterState({len(self)} qubits, {len(self.groups)} groups)\n" + rep += repr(self.cluster_state) + return rep + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, FrontendClusterState): + return False + return ( + self.cluster_state == other.cluster_state + and self.node_metadata == other.node_metadata + and self.groups == other.groups + ) + + def _q(self, qubit: Union[int, str]) -> int: + """Resolve qubit identifier to integer index.""" + q = self.resolve_qubit(qubit) + return q if q is not None else int(qubit) + + def _cyto_node_id(self, idx: int) -> str: + """Get Cytoscape element ID for a qubit node, including parent tag if grouped.""" + parent = self.node_metadata[idx].get("parent") + return f"{idx}_{parent}" if parent else str(idx) + + # ------------------------------------------------------------------ + # Undo / Redo Snapshot Management + # ------------------------------------------------------------------ + + def snapshot(self) -> Dict[str, Any]: + """Create a complete serializable snapshot of the current state.""" + return { + "to_cytoscape": self.to_cytoscape_elements(), + "node_metadata": copy.deepcopy(self.node_metadata), + "groups": copy.deepcopy(self.groups), + } + + def push_state(self) -> None: + """Push current snapshot onto the undo stack and clear the redo stack.""" + self.undo_stack.append(self.snapshot()) + self.redo_stack.clear() + + def undo(self) -> bool: + """ + Restore the previous state from the undo stack. + + Returns: + bool: True if undo was successful, False if undo stack was empty. + """ + if not self.undo_stack: + return False + + # Save current state to redo stack + self.redo_stack.append(self.snapshot()) + + # Pop previous state + prev_state = self.undo_stack.pop() + self._restore_from_snapshot(prev_state) + return True + + def redo(self) -> bool: + """ + Restore the next state from the redo stack. + + Returns: + bool: True if redo was successful, False if redo stack was empty. + """ + if not self.redo_stack: + return False + + # Save current state to undo stack + self.undo_stack.append(self.snapshot()) + + # Pop next state + next_state = self.redo_stack.pop() + self._restore_from_snapshot(next_state) + return True + + def _restore_from_snapshot(self, state_dict: Dict[str, Any]) -> None: + """Restore instance state from a snapshot dictionary.""" + restored = FrontendClusterState.from_cytoscape_elements( + state_dict["to_cytoscape"] + ) + self.cluster_state = restored.cluster_state + self.node_metadata = copy.deepcopy(state_dict["node_metadata"]) + self.groups = copy.deepcopy(state_dict["groups"]) + + # ------------------------------------------------------------------ + # Delegated Quantum Gate Operations + # ------------------------------------------------------------------ + + def measure(self, qubit: Union[int, str], force: int = -1, basis: str = "Z") -> int: + return self.cluster_state.measure(self._q(qubit), force=force, basis=basis) + + def MX(self, qubit: Union[int, str], force: int = -1) -> int: + return self.cluster_state.MX(self._q(qubit), force=force) + + def MY(self, qubit: Union[int, str], force: int = -1) -> int: + return self.cluster_state.MY(self._q(qubit), force=force) + + def MZ(self, qubit: Union[int, str], force: int = -1) -> int: + return self.cluster_state.MZ(self._q(qubit), force=force) + + def M(self, qubit: Union[int, str], force: int = -1) -> int: + return self.cluster_state.M(self._q(qubit), force=force) + + def I(self, qubit: Union[int, str]) -> None: + self.cluster_state.I(self._q(qubit)) + + def X(self, qubit: Union[int, str]) -> None: + self.cluster_state.X(self._q(qubit)) + + def Y(self, qubit: Union[int, str]) -> None: + self.cluster_state.Y(self._q(qubit)) + + def Z(self, qubit: Union[int, str]) -> None: + self.cluster_state.Z(self._q(qubit)) + + def H(self, qubit: Union[int, str]) -> None: + self.cluster_state.H(self._q(qubit)) + + def S(self, qubit: Union[int, str]) -> None: + self.cluster_state.S(self._q(qubit)) + + def SH(self, qubit: Union[int, str]) -> None: + self.cluster_state.SH(self._q(qubit)) + + def S_DAG(self, qubit: Union[int, str]) -> None: + self.cluster_state.S_DAG(self._q(qubit)) + + def CX(self, control: Union[int, str], qubit: Union[int, str]) -> None: + self.cluster_state.CX(self._q(control), self._q(qubit)) + + def CZ(self, control: Union[int, str], qubit: Union[int, str]) -> None: + self.cluster_state.CZ(self._q(control), self._q(qubit)) + + def fusion_gate( + self, + qubit1: Union[int, str], + qubit2: Union[int, str], + fusion_type: str = "XXZZ", + mode: str = "success", + force: int = 0, + ) -> None: + self.cluster_state.fusion_gate( + self._q(qubit1), + self._q(qubit2), + fusion_type=fusion_type, + mode=mode, + force=force, + ) + + def local_complementation(self, qubit: Union[int, str]) -> None: + self.cluster_state.local_complementation(self._q(qubit)) + + def local_complementation_rewrite(self, qubit: Union[int, str]) -> None: + self.cluster_state.local_complementation_rewrite(self._q(qubit)) + + def apply_VOP( + self, qubit: Union[int, str], vop: Union[Tuple[int, int], int, str] + ) -> None: + self.cluster_state.apply_VOP(self._q(qubit), vop) + + def add_edge(self, qubit1: Union[int, str], qubit2: Union[int, str]) -> None: + self.cluster_state.add_edge(self._q(qubit1), self._q(qubit2)) + + def remove_edge(self, qubit1: Union[int, str], qubit2: Union[int, str]) -> None: + self.cluster_state.remove_edge(self._q(qubit1), self._q(qubit2)) + + def toggle_edge(self, qubit1: Union[int, str], qubit2: Union[int, str]) -> None: + self.cluster_state.toggle_edge(self._q(qubit1), self._q(qubit2)) + + # ------------------------------------------------------------------ + # Node Addition & Removal (with Metadata & Index Re-mapping) + # ------------------------------------------------------------------ + + def add_node( + self, + vop: str = "IA", + position: Optional[Dict[str, float]] = None, + group: Optional[str] = None, + value: Optional[Any] = None, + label: Optional[str] = None, + ) -> int: + """ + Add a new quantum node to ClusterState and initialize its frontend metadata. + """ + idx = self.num_nodes + self.cluster_state.add_node(vop=vop) + + if position is None: + position = { + "x": float(random.randint(0, 300)), + "y": float(random.randint(0, 300)), + } + + if value is None: + value = uuid.uuid4().hex[:8] + + if label is None: + label = str(idx) + + self.node_metadata.append( + { + "position": dict(position), + "parent": group, + "value": value, + "label": label, + "classes": "", + "style": {}, + } + ) + return idx + + def resolve_qubit(self, identifier: Union[int, str]) -> Optional[int]: + """ + Translate a Cytoscape node ID, qubit index, or persistent value into a valid ClusterState qubit index (0..N-1). + """ + if identifier is None: + return None + + s_id = str(identifier) + + # Parse qubit index prefix from compound node ID like "0_group_1" or plain digit string "0" + prefix = s_id.split("_")[0] if not s_id.startswith("group_") else s_id + if prefix.isdigit(): + q = int(prefix) + if 0 <= q < len(self): + return q + + # Check if matching persistent value attribute + for i, meta in enumerate(self.node_metadata): + if meta.get("value") == identifier or str(meta.get("value")) == s_id: + return i + + # Fallback to raw integer index within bounds + if isinstance(identifier, int) and 0 <= identifier < len(self): + return identifier + + return None + + def remove_node(self, qubits: Union[int, str, List[Union[int, str]]]) -> List[int]: + """ + Remove one or more nodes. + Translates string IDs, qubit indices, or values to valid qubit indices. + ClusterState automatically relabels remaining nodes contiguously from 0 to N-1. + FrontendClusterState transfers metadata (position, group, persistent value, style) + from old node indices to their new contiguous indices. + """ + if isinstance(qubits, (int, str)): + raw_list = [qubits] + else: + raw_list = list(qubits) + + resolved_qubits = [] + for q in raw_list: + idx = self.resolve_qubit(q) + if idx is not None and idx not in resolved_qubits: + resolved_qubits.append(idx) + + if not resolved_qubits: + return [] + + qubits_to_remove = set(resolved_qubits) + + # Call ClusterState remove_node which relabels remaining nodes + self.cluster_state.remove_node(list(qubits_to_remove)) + + # Re-map node metadata list: keep only non-removed nodes in original relative order + self.node_metadata = [ + meta + for i, meta in enumerate(self.node_metadata) + if i not in qubits_to_remove + ] + return resolved_qubits + + # ------------------------------------------------------------------ + # Frontend Metadata & Group Management + # ------------------------------------------------------------------ + + def get_position(self, qubit: Union[int, str]) -> Dict[str, float]: + return self.node_metadata[self._q(qubit)]["position"] + + def set_position(self, qubit: Union[int, str], position: Dict[str, float]) -> None: + self.node_metadata[self._q(qubit)]["position"] = dict(position) + + def get_group(self, qubit: Union[int, str]) -> Optional[str]: + return self.node_metadata[self._q(qubit)]["parent"] + + def set_group(self, qubit: Union[int, str], group_id: Optional[str]) -> None: + idx = self._q(qubit) + if group_id is not None and group_id not in self.groups: + self.add_group(group_id) + self.node_metadata[idx]["parent"] = group_id + + def get_value(self, qubit: Union[int, str]) -> Any: + return self.node_metadata[self._q(qubit)]["value"] + + def set_value(self, qubit: Union[int, str], value: Any) -> None: + self.node_metadata[self._q(qubit)]["value"] = value + + def add_group( + self, + group_id: str, + label: Optional[str] = None, + style: Optional[Dict[str, Any]] = None, + value: Optional[str] = None, + ) -> None: + """Add or update a compound parent group container.""" + if label is None: + label = "" + if style is None: + style = {} + if value is None: + if group_id in self.groups and self.groups[group_id].get("value"): + value = self.groups[group_id]["value"] + elif group_id.startswith("group_") and len(group_id) > 6: + value = group_id[6:] + else: + value = uuid.uuid4().hex[:8] + + self.groups[group_id] = {"label": label, "style": style, "value": value} + + def get_group_children(self, group_id: str) -> List[int]: + """Return list of qubit indices that belong to the specified group.""" + return [ + i + for i, meta in enumerate(self.node_metadata) + if meta.get("parent") == str(group_id) + ] + + def expand_selection( + self, selected_items: List[Union[int, str, Dict[str, Any]]] + ) -> List[int]: + """ + Expand selected items (which may contain group container IDs, qubit indices, or Cytoscape node dicts) + into a deduplicated list of valid qubit indices. + If a group container ID is present, it is expanded to all of its child qubits. + """ + result: List[int] = [] + for item in selected_items: + s_item = str(item.get("id", "")) if isinstance(item, dict) else str(item) + + if s_item in self.groups: + children = self.get_group_children(s_item) + for q in children: + if q not in result: + result.append(q) + else: + q = self.resolve_qubit( + item if not isinstance(item, dict) else item.get("id") + ) + if q is not None and q not in result: + result.append(q) + return result + + def remove_group(self, group_id: str, unassign_children: bool = True) -> None: + """Remove a compound group container.""" + if group_id in self.groups: + del self.groups[group_id] + if unassign_children: + for meta in self.node_metadata: + if meta.get("parent") == group_id: + meta["parent"] = None + + # ------------------------------------------------------------------ + # Cytoscape Converters + # ------------------------------------------------------------------ + + def to_cytoscape_elements(self) -> List[Dict[str, Any]]: + """ + Export complete 2D layout and quantum state to Cytoscape elements list. + """ + elements: List[Dict[str, Any]] = [] + + # 1. Qubit Nodes (must come before compound group containers so parent update is processed first) + for i in range(len(self)): + meta = self.node_metadata[i] + cyto_id = self._cyto_node_id(i) + parent_id = str(meta["parent"]) if meta.get("parent") else None + + node_data = { + "id": cyto_id, + "label": meta.get("label", str(i)), + "value": meta.get("value", i), + "vop": self.vertex_operators[i], + } + if meta.get("shape"): + node_data["shape"] = meta["shape"] + elif meta.get("style", {}).get("shape"): + node_data["shape"] = meta["style"]["shape"] + + if parent_id: + node_data["parent"] = parent_id + + node_elem = { + "data": node_data, + "position": meta.get("position", {"x": 0.0, "y": 0.0}), + } + if meta.get("classes"): + node_elem["classes"] = meta["classes"] + if meta.get("style"): + node_elem["style"] = meta["style"] + + elements.append(node_elem) + + # 2. Compound Parent Group Container Nodes + for group_id, group_info in self.groups.items(): + val = group_info.get("value") + if not val: + val = ( + group_id[6:] + if group_id.startswith("group_") and len(group_id) > 6 + else uuid.uuid4().hex[:8] + ) + group_info["value"] = val + elements.append( + { + "data": { + "id": str(group_id), + "label": group_info.get("label", ""), + "value": val, + }, + "classes": "group container", + } + ) + + # 3. Edges between Qubit Nodes + for u, neighbors in enumerate(self.adjacency_list): + for v in neighbors: + if u < v: + elements.append( + { + "data": { + "source": self._cyto_node_id(u), + "target": self._cyto_node_id(v), + } + } + ) + + return elements + + @classmethod + def from_cytoscape_elements( + cls, elements: List[Dict[str, Any]] + ) -> FrontendClusterState: + """ + Reconstruct FrontendClusterState from a list of Cytoscape elements. + """ + groups: Dict[str, Dict[str, Any]] = {} + node_elements: List[Dict[str, Any]] = [] + edge_elements: List[Dict[str, Any]] = [] + + for item in elements: + data = item.get("data", {}) + # Distinguish group nodes vs qubit nodes vs edges + if "source" in data and "target" in data: + edge_elements.append(item) + elif "vop" in data or ( + data.get("value") is not None + and not str(data.get("id", "")).startswith("group_") + ): + node_elements.append(item) + elif "id" in data: + # Group container node + group_id = str(data["id"]) + val = data.get("value") + if not val: + val = ( + group_id[6:] + if group_id.startswith("group_") and len(group_id) > 6 + else uuid.uuid4().hex[:8] + ) + groups[group_id] = { + "label": data.get("label", group_id), + "style": item.get("style", {}), + "value": val, + } + + # Sort qubit nodes by integer ID + def get_node_id(elem): + raw_id = str(elem.get("data", {}).get("id", "0")) + if "_" in raw_id and not raw_id.startswith("group_"): + raw_id = raw_id.split("_")[0] + try: + return int(raw_id) + except (ValueError, KeyError): + return 0 + + node_elements.sort(key=get_node_id) + num_nodes = len(node_elements) + + # Build PyGraph for ClusterState reconstruction + rx_graph = rx.PyGraph() + for i in range(num_nodes): + elem = node_elements[i] + vop_val = elem.get("data", {}).get("vop", "IA") + rx_graph.add_node({"vop": vop_val}) + + for edge in edge_elements: + src_str = str(edge["data"]["source"]) + tgt_str = str(edge["data"]["target"]) + u = int(src_str.split("_")[0]) if "_" in src_str else int(src_str) + v = int(tgt_str.split("_")[0]) if "_" in tgt_str else int(tgt_str) + rx_graph.add_edge(u, v, None) + + base_cluster_state = ClusterState.from_rustworkx(rx_graph) + frontend_state = cls( + num_nodes=num_nodes, cluster_state=base_cluster_state, groups=groups + ) + + # Restore node metadata + for i in range(num_nodes): + elem = node_elements[i] + data = elem.get("data", {}) + pos = elem.get("position", {"x": 0.0, "y": 0.0}) + node_shape = data.get("shape") or elem.get("style", {}).get("shape") + style_dict = dict(elem.get("style", {})) + if node_shape: + style_dict["shape"] = node_shape + frontend_state.node_metadata[i] = { + "position": dict(pos), + "parent": data.get("parent"), + "value": data.get("value", i), + "label": data.get("label", str(i)), + "shape": node_shape, + "classes": elem.get("classes", ""), + "style": style_dict, + } + + return frontend_state + + # ------------------------------------------------------------------ + # JSON & Dict Serialization + # ------------------------------------------------------------------ + + def to_dict(self) -> Dict[str, Any]: + """Convert FrontendClusterState to a serializable dictionary.""" + return { + "cluster_state_json": self.cluster_state.to_json(), + "node_metadata": self.node_metadata, + "groups": self.groups, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> FrontendClusterState: + """Create FrontendClusterState from a dictionary.""" + base_state = ClusterState.from_json(data["cluster_state_json"]) + obj = cls( + num_nodes=len(base_state), + cluster_state=base_state, + groups=data.get("groups", {}), + ) + if "node_metadata" in data: + obj.node_metadata = copy.deepcopy(data["node_metadata"]) + return obj + + def to_json(self) -> str: + """Serialize FrontendClusterState to JSON string.""" + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> FrontendClusterState: + """Deserialize FrontendClusterState from JSON string.""" + data = json.loads(json_str) + return cls.from_dict(data) diff --git a/src/components/components_2d/__init__.py b/src/components/components_2d/__init__.py index 26152a2..2236544 100644 --- a/src/components/components_2d/__init__.py +++ b/src/components/components_2d/__init__.py @@ -5,6 +5,7 @@ ) from .move_log_2d import move_log from .figure_2d import figure_2d, tab_ui_2d +from .cytoscape_panel import cytoscape_offcanvas __all__ = [ @@ -14,4 +15,5 @@ "qubit_panel", "preprocess_cyto_data_elements", "postprocess_cyto_data_elements", + "cytoscape_offcanvas", ] diff --git a/src/components/components_2d/action_panel.py b/src/components/components_2d/action_panel.py index 46947ac..6af570e 100644 --- a/src/components/components_2d/action_panel.py +++ b/src/components/components_2d/action_panel.py @@ -1,22 +1,68 @@ import itertools -from clustersim.simulator import ClusterState +from clustersim.simulator import ClusterState, FrontendClusterState from textwrap import dedent as d from dash import dcc, callback, Input, Output, State, no_update, callback_context, html import dash_bootstrap_components as dbc from typing import Any import random +TOOLTIPS = { + "duplicate": "Copy (Ctrl+C) / Paste (Ctrl+V)", + "remove-node": "Delete Node (Backspace)", + "add-node": "Add Node (N)", + "toggle-edge": "Toggle Edge (E)", + "remove-edge": "Delete Edge (Shift+E)", + "H": "Hadamard Gate (H)", + "LC": "Local Complementation (W)", + "fusion-gate-xxzz": "Fusion Gate XXZZ", + "fusion-gate-xzzx": "Fusion Gate XZZX", + "fusion-gate-xyyz": "Fusion Gate XYYZ", + "X": "Pauli X", + "Y": "Pauli Y", + "Z": "Pauli Z", + "S": "Pauli S", + "S_DAG": "Pauli S Dagger", + "CX": "Controlled X", + "CZ": "Controlled Z", + "LCR": "Rewrite", + "MX": "Measure X", + "MY": "Measure Y", + "MZ": "Measure Z", +} + qubit_panel = dbc.Row( [ # Column 1: Select Measurement Basis dbc.Col( [ - dcc.Markdown( - "**Select Measurement Basis**", - style={"margin": "0 0 4px 0", "fontSize": "0.8rem"}, + html.Div( + "Measurement Basis", + style={"margin": "0 0 2px 0", "fontSize": "0.65rem", "fontWeight": "bold", "color": "#64748b", "textTransform": "uppercase", "letterSpacing": "0.5px"}, ), html.Div( [ + html.Div( + [ + html.Span("Force:", style={"fontSize": "0.7rem", "fontWeight": "600"}), + dbc.Select( + id="force-measurement", + options=[ + {"label": "Force 0", "value": 0}, + {"label": "Force 1", "value": 1}, + {"label": "Random", "value": -1}, + ], + placeholder="Force 0", + value=0, + style={ + "fontSize": "0.75rem", + "padding": "2px", + "height": "24px", + "width": "75px", + }, + ), + ], + className="d-flex align-items-center gap-1 mb-1", + ), dbc.ButtonGroup( [ dbc.Button( @@ -24,132 +70,133 @@ outline=True, color="primary", id="MZ", - style={"fontSize": "0.85rem", "padding": "4px 6px"}, + style={"fontSize": "0.75rem", "padding": "2px 4px"}, ), dbc.Button( "MY", outline=True, color="primary", id="MY", - style={"fontSize": "0.85rem", "padding": "4px 6px"}, + style={"fontSize": "0.75rem", "padding": "2px 4px"}, ), dbc.Button( "MX", outline=True, color="primary", id="MX", - style={"fontSize": "0.85rem", "padding": "4px 6px"}, + style={"fontSize": "0.75rem", "padding": "2px 4px"}, ), ], className="w-100", ), - dbc.Select( - id="force-measurement", - options=[ - {"label": "Force 0", "value": 0}, - {"label": "Force 1", "value": 1}, - {"label": "Random", "value": -1}, - ], - placeholder="Force 0", - value=0, - style={ - "fontSize": "0.85rem", - "padding": "4px 8px", - "height": "auto", - }, - className="w-100 mt-2", - ), - ], + ] ), ], - width=2, + width="auto", + className="flex-shrink-0", ), # Column 2: Fusion Controls dbc.Col( [ - dcc.Markdown( - "**Fusion Controls**", - style={"margin": "0 0 4px 0", "fontSize": "0.8rem"}, + html.Div( + "Fusion Controls", + style={"margin": "0 0 2px 0", "fontSize": "0.65rem", "fontWeight": "bold", "color": "#64748b", "textTransform": "uppercase", "letterSpacing": "0.5px"}, ), html.Div( [ - dbc.Row( + html.Div( [ - dbc.Col( - [ - html.Span("Force Meas.", style={"fontSize": "0.85rem", "fontWeight": "600", "display": "block", "marginBottom": "2px"}), - dbc.Select( - id="fusion-force-measurement", - options=[ - {"label": "00", "value": 0}, - {"label": "10", "value": 1}, - {"label": "01", "value": 2}, - {"label": "11", "value": 3}, - {"label": "Random", "value": -1}, - ], - placeholder="00", - value=0, - style={"fontSize": "0.85rem", "padding": "2px 4px", "height": "auto"}, - ), + html.Span("Force:", style={"fontSize": "0.7rem", "fontWeight": "600"}), + dbc.Select( + id="fusion-force-measurement", + options=[ + {"label": "00", "value": 0}, + {"label": "10", "value": 1}, + {"label": "01", "value": 2}, + {"label": "11", "value": 3}, + {"label": "Random", "value": -1}, ], - width=6, - className="pe-1" + placeholder="00", + value=0, + style={ + "fontSize": "0.75rem", + "padding": "2px", + "height": "24px", + "width": "60px", + }, ), - dbc.Col( - [ - html.Span("Fusion Mode", style={"fontSize": "0.85rem", "fontWeight": "600", "display": "block", "marginBottom": "2px"}), - dbc.Select( - id="fusion-mode", - options=[ - { - "label": "Success", - "value": "success", - }, - { - "label": "Failure", - "value": "failure", - }, - {"label": "Random", "value": "random"}, - ], - value="success", - style={"fontSize": "0.85rem", "padding": "2px 4px", "height": "auto"}, - ), + html.Span("Mode:", style={"fontSize": "0.7rem", "fontWeight": "600", "marginLeft": "4px"}), + dbc.Select( + id="fusion-mode", + options=[ + { + "label": "Success", + "value": "success", + }, + { + "label": "Failure", + "value": "failure", + }, + {"label": "Random", "value": "random"}, ], - width=6, - className="ps-1" + value="success", + style={ + "fontSize": "0.75rem", + "padding": "2px", + "height": "24px", + "width": "75px", + }, ), ], - className="mb-2 g-0" + className="d-flex align-items-center gap-1 mb-1", ), - dbc.Row( + dbc.ButtonGroup( [ - dbc.Col( - [ - dbc.ButtonGroup( - [ - dbc.Button("XXZZ", outline=True, color="primary", id="fusion-gate-xxzz", style={"fontSize": "0.8rem", "padding": "4px 2px"}), - dbc.Button("XZZX", outline=True, color="primary", id="fusion-gate-xzzx", style={"fontSize": "0.8rem", "padding": "4px 2px"}), - dbc.Button("XYYZ", outline=True, color="primary", id="fusion-gate-xyyz", style={"fontSize": "0.8rem", "padding": "4px 2px"}), - ], - className="w-100" - ), - ], - width=12 + dbc.Button( + "XXZZ", + outline=True, + color="primary", + id="fusion-gate-xxzz", + style={ + "fontSize": "0.75rem", + "padding": "2px 4px", + }, + ), + dbc.Button( + "XZZX", + outline=True, + color="primary", + id="fusion-gate-xzzx", + style={ + "fontSize": "0.75rem", + "padding": "2px 4px", + }, + ), + dbc.Button( + "XYYZ", + outline=True, + color="primary", + id="fusion-gate-xyyz", + style={ + "fontSize": "0.75rem", + "padding": "2px 4px", + }, ), ], - className="g-0", + className="w-100", ), - ], + ] ), ], - width=3, + width="auto", + className="flex-shrink-0", ), # Column 3: Apply Clifford Gates dbc.Col( [ - dcc.Markdown( - "**Apply Clifford Gates**", - style={"margin": "0 0 4px 0", "fontSize": "0.8rem"}, + html.Div( + "Clifford Gates", + style={"margin": "0 0 2px 0", "fontSize": "0.65rem", "fontWeight": "bold", "color": "#64748b", "textTransform": "uppercase", "letterSpacing": "0.5px"}, ), html.Div( [ @@ -160,31 +207,31 @@ outline=True, color="primary", id="X", - style={"fontSize": "0.85rem", "padding": "4px 6px"}, + style={"fontSize": "0.75rem", "padding": "2px 4px", "minWidth": "35px"}, ), dbc.Button( "Y", outline=True, color="primary", id="Y", - style={"fontSize": "0.85rem", "padding": "4px 6px"}, + style={"fontSize": "0.75rem", "padding": "2px 4px", "minWidth": "35px"}, ), dbc.Button( "Z", outline=True, color="primary", id="Z", - style={"fontSize": "0.85rem", "padding": "4px 6px"}, + style={"fontSize": "0.75rem", "padding": "2px 4px", "minWidth": "35px"}, ), dbc.Button( "H", outline=True, color="primary", id="H", - style={"fontSize": "0.85rem", "padding": "4px 6px"}, + style={"fontSize": "0.75rem", "padding": "2px 4px", "minWidth": "35px"}, ), ], - className="w-100 mb-2", + className="w-100 mb-1", ), dbc.ButtonGroup( [ @@ -193,28 +240,28 @@ outline=True, color="primary", id="S", - style={"fontSize": "0.85rem", "padding": "4px 6px"}, + style={"fontSize": "0.75rem", "padding": "2px 4px", "minWidth": "35px"}, ), dbc.Button( "S†", outline=True, color="primary", id="S_DAG", - style={"fontSize": "0.85rem", "padding": "4px 6px"}, + style={"fontSize": "0.75rem", "padding": "2px 4px", "minWidth": "35px"}, ), dbc.Button( "CX", outline=True, color="primary", id="CX", - style={"fontSize": "0.85rem", "padding": "4px 6px"}, + style={"fontSize": "0.75rem", "padding": "2px 4px", "minWidth": "35px"}, ), dbc.Button( "CZ", outline=True, color="primary", id="CZ", - style={"fontSize": "0.85rem", "padding": "4px 6px"}, + style={"fontSize": "0.75rem", "padding": "2px 4px", "minWidth": "35px"}, ), ], className="w-100", @@ -222,14 +269,15 @@ ] ), ], - width=3, + width="auto", + className="flex-shrink-0", ), # Column 4: Graph operations dbc.Col( [ - dcc.Markdown( - "**Graph operations**", - style={"margin": "0 0 4px 0", "fontSize": "0.8rem"}, + html.Div( + "Graph Operations", + style={"margin": "0 0 2px 0", "fontSize": "0.65rem", "fontWeight": "bold", "color": "#64748b", "textTransform": "uppercase", "letterSpacing": "0.5px"}, ), html.Div( [ @@ -240,59 +288,54 @@ outline=True, color="primary", id="add-node", - style={"fontSize": "0.8rem", "padding": "4px 6px"}, + style={"fontSize": "0.75rem", "padding": "2px 4px"}, ), dbc.Button( - "Del Node", + "Toggle Edge", outline=True, color="primary", - id="remove-node", - style={"fontSize": "0.8rem", "padding": "4px 6px"}, + id="toggle-edge", + style={"fontSize": "0.75rem", "padding": "2px 4px"}, ), - ], - className="w-100 mb-2", - ), - dbc.ButtonGroup( - [ dbc.Button( - "Toggle", + "LC", outline=True, color="primary", - id="toggle-edge", - style={"fontSize": "0.8rem", "padding": "4px 6px"}, + id="LC", + style={"fontSize": "0.75rem", "padding": "2px 4px"}, ), dbc.Button( - "Del Edge", + "Copy", outline=True, color="primary", - id="remove-edge", - style={"fontSize": "0.8rem", "padding": "4px 6px"}, + id="duplicate", + style={"fontSize": "0.75rem", "padding": "2px 4px"}, ), ], - className="w-100 mb-2", + className="w-100 mb-1", ), dbc.ButtonGroup( [ dbc.Button( - "LC", + "Delete Node", outline=True, color="primary", - id="LC", - style={"fontSize": "0.8rem", "padding": "4px 6px"}, + id="remove-node", + style={"fontSize": "0.75rem", "padding": "2px 4px"}, ), dbc.Button( - "LC Rewrite", + "Delete Edge", outline=True, color="primary", - id="LCR", - style={"fontSize": "0.8rem", "padding": "4px 6px"}, + id="remove-edge", + style={"fontSize": "0.75rem", "padding": "2px 4px"}, ), dbc.Button( - "Copy", + "LC Rewrite", outline=True, color="primary", - id="duplicate", - style={"fontSize": "0.8rem", "padding": "4px 6px"}, + id="LCR", + style={"fontSize": "0.75rem", "padding": "2px 4px"}, ), ], className="w-100", @@ -307,51 +350,47 @@ ] ), ], - width=3, + width="auto", + className="flex-shrink-0", ), - # Column 5: Advanced Options (Gear icon) + # Column 5: Advanced Options (Gear and Hamburger icon) dbc.Col( [ - html.Div(style={"height": "20px"}), html.Div( [ + dbc.Button( + "☰", + id="open-offcanvas-btn", + outline=True, + color="secondary", + style={ + "fontSize": "1.1rem", + "padding": "1px 6px", + "marginBottom": "2px", + }, + title="Plot Customization Options", + ), dbc.Button( "⚙", id="open-modal-btn", outline=True, color="secondary", - style={"fontSize": "1.2rem", "padding": "2px 8px"}, + style={"fontSize": "1.1rem", "padding": "1px 6px"}, title="Advanced Options", ), ], - className="w-100 d-flex justify-content-center mt-1", + className="w-100 d-flex flex-column align-items-center mt-1", ), ], - width=1, + width="auto", + className="flex-shrink-0", ), - dbc.Tooltip("Copy (Ctrl+C) / Paste (Ctrl+V)", target="duplicate", placement="top"), - dbc.Tooltip("Delete Node (Backspace)", target="remove-node", placement="top"), - dbc.Tooltip("Add Node (N)", target="add-node", placement="top"), - dbc.Tooltip("Toggle Edge (E)", target="toggle-edge", placement="top"), - dbc.Tooltip("Delete Edge (Shift+E)", target="remove-edge", placement="top"), - dbc.Tooltip("Hadamard Gate (H)", target="H", placement="top"), - dbc.Tooltip("Local Complementation (W)", target="LC", placement="top"), - dbc.Tooltip("Fusion Gate XXZZ", target="fusion-gate-xxzz", placement="top"), - dbc.Tooltip("Fusion Gate XZZX", target="fusion-gate-xzzx", placement="top"), - dbc.Tooltip("Fusion Gate XYYZ", target="fusion-gate-xyyz", placement="top"), - dbc.Tooltip("Pauli X", target="X", placement="top"), - dbc.Tooltip("Pauli Y", target="Y", placement="top"), - dbc.Tooltip("Pauli Z", target="Z", placement="top"), - dbc.Tooltip("Pauli S", target="S", placement="top"), - dbc.Tooltip("Pauli S Dagger", target="S_DAG", placement="top"), - dbc.Tooltip("Controlled X", target="CX", placement="top"), - dbc.Tooltip("Controlled Z", target="CZ", placement="top"), - dbc.Tooltip("Rewrite", target="LCR", placement="top"), - dbc.Tooltip("Measure X", target="MX", placement="top"), - dbc.Tooltip("Measure Y", target="MY", placement="top"), - dbc.Tooltip("Measure Z", target="MZ", placement="top"), + *[ + dbc.Tooltip(text, target=target, placement="top") + for target, text in TOOLTIPS.items() + ], ], - className="flex-nowrap", + className="flex-nowrap justify-content-between w-100", ) @@ -360,9 +399,18 @@ "MX": ("measure", {"force": 0, "basis": "X"}), "MY": ("measure", {"force": 0, "basis": "Y"}), "MZ": ("measure", {"force": 0, "basis": "Z"}), - "fusion-gate-xxzz": ("fusion_gate", {"force": 0, "mode": "success", "fusion_type": "XXZZ"}), - "fusion-gate-xzzx": ("fusion_gate", {"force": 0, "mode": "success", "fusion_type": "XZZX"}), - "fusion-gate-xyyz": ("fusion_gate", {"force": 0, "mode": "success", "fusion_type": "XYYZ"}), + "fusion-gate-xxzz": ( + "fusion_gate", + {"force": 0, "mode": "success", "fusion_type": "XXZZ"}, + ), + "fusion-gate-xzzx": ( + "fusion_gate", + {"force": 0, "mode": "success", "fusion_type": "XZZX"}, + ), + "fusion-gate-xyyz": ( + "fusion_gate", + {"force": 0, "mode": "success", "fusion_type": "XYYZ"}, + ), # simple operations with no extra args "LCR": ("local_complementation_rewrite", {}), "LC": ("local_complementation", {}), @@ -383,8 +431,28 @@ } +def _normalize_history(history_data: Any) -> dict[str, list[Any]]: + if not history_data or not isinstance(history_data, dict): + return {"undo_stack": [], "redo_stack": []} + return { + "undo_stack": list(history_data.get("undo_stack", [])), + "redo_stack": list(history_data.get("redo_stack", [])), + } + + +KEY_MAPPINGS = { + "backspace-btn": "remove-node", + "key-n-btn": "add-node", + "key-e-btn": "toggle-edge", + "key-shift-e-btn": "remove-edge", + "key-h-btn": "H", + "key-w-btn": "LC", +} + + @callback( Output("ui", "children"), + Output("ui", "color"), Output("figure-app", "elements", allow_duplicate=True), Output("simulator-representation", "children"), Output("move-log", "children"), @@ -411,47 +479,38 @@ def handle_buttons(*args): kwargs = {} - log = args[-4] - - # The last three args are selectedNodeData, selectedEdgeData, and elements - selected_node_data = args[-3] - selected_edge_data = args[-2] - cyto_data = args[-1] - - history_data = args[-8] - if not history_data or not isinstance(history_data, dict): - history_data = {"undo_stack": [], "redo_stack": []} - else: - history_data = { - "undo_stack": list(history_data.get("undo_stack", [])), - "redo_stack": list(history_data.get("redo_stack", [])), - } - - # Record the current state in history before performing the action - _, current_positions = preprocess_cyto_data_elements(cyto_data) - history_data["undo_stack"].append({"move_log": log, "positions": current_positions}) + num_inputs = len(button_operations) + len(KEY_MAPPINGS) + 1 + ( + clipboard_data, + raw_history_data, + fusion_mode_val, + fusion_force_val, + force_meas_val, + log, + selected_node_data, + selected_edge_data, + cyto_data, + ) = args[num_inputs:] + + state = FrontendClusterState.from_cytoscape_elements(cyto_data) + + history_data = _normalize_history(raw_history_data) + + # Record the current elements state in history for Undo/Redo + history_data["undo_stack"].append( + {"action_type": "quantum_operation", "move_log": log, "elements": cyto_data} + ) history_data["redo_stack"] = [] # Determine which button triggered the callback triggered_id = callback_context.triggered_id - if triggered_id == "backspace-btn": - triggered_id = "remove-node" - elif triggered_id == "key-n-btn": - triggered_id = "add-node" - elif triggered_id == "key-e-btn": - triggered_id = "toggle-edge" - elif triggered_id == "key-shift-e-btn": - triggered_id = "remove-edge" - elif triggered_id == "key-h-btn": - triggered_id = "H" - elif triggered_id == "key-w-btn": - triggered_id = "LC" + triggered_id = KEY_MAPPINGS.get(triggered_id, triggered_id) if triggered_id == "ctrl-v-btn": - clipboard_data = args[-9] if not clipboard_data: return ( - "Clipboard is empty!", + "Error: Clipboard is empty!", + "danger", no_update, no_update, no_update, @@ -467,13 +526,21 @@ def handle_buttons(*args): method_name, base_args = button_operations[triggered_id] method_args = base_args.copy() if method_name == "fusion_gate": - method_args["force"] = int(args[-6]) - method_args["mode"] = args[-7] + method_args["force"] = int(fusion_force_val) + method_args["mode"] = fusion_mode_val elif method_name == "measure": - method_args["force"] = int(args[-5]) - - selected_nodes = [i["value"] for i in selected_node_data] + method_args["force"] = int(force_meas_val) + + selected_nodes = [] + for i in selected_node_data: + elem_id = str(i.get("id")) if i.get("id") is not None else "" + if elem_id and elem_id in state.groups: + selected_nodes.append(elem_id) + elif elem_id and elem_id.isdigit(): + selected_nodes.append(elem_id) + elif i.get("value") is not None: + selected_nodes.append(i["value"]) # If no nodes selected but an edge is selected, derive nodes from the edge # for edge operations (add/remove/toggle) @@ -488,31 +555,42 @@ def handle_buttons(*args): src = edge.get("source") tgt = edge.get("target") if src is not None: - edge_nodes.append(int(src)) + src_str = str(src) + prefix = src_str.split("_")[0] if not src_str.startswith("group_") else src_str + if prefix.isdigit(): + edge_nodes.append(int(prefix)) if tgt is not None: - edge_nodes.append(int(tgt)) + tgt_str = str(tgt) + prefix = tgt_str.split("_")[0] if not tgt_str.startswith("group_") else tgt_str + if prefix.isdigit(): + edge_nodes.append(int(prefix)) selected_nodes = list( dict.fromkeys(edge_nodes) ) # deduplicate, preserve order if not selected_nodes and method_name != "add_node": - return no_update, no_update, no_update, no_update, no_update + return no_update, no_update, no_update, no_update, no_update, no_update - return apply_operation_wrapper( + res = apply_operation_wrapper( method_name, selected_nodes, cyto_data, log, method_args, history_data, **kwargs ) + if res is not no_update and isinstance(res, tuple) and len(res) == 5: + ui, cyto_data_new, repr_g, log_new, history_data_new = res + color = "danger" if ui.startswith("Error") else "success" + return ui, color, cyto_data_new, repr_g, log_new, history_data_new + return no_update, no_update, no_update, no_update, no_update, no_update def apply_operation_wrapper( method_name: str, - selected_nodes: list[int], + selected_nodes: list, cyto_data, log, method_args, history_data, **kwargs, ): - """Take the buttons for all the call backs and apply the corresponding method in the simulator. + """Take the buttons for all the callbacks and apply the corresponding method in FrontendClusterState. Args: operation_name (str): ClusterState.method_name @@ -522,27 +600,27 @@ def apply_operation_wrapper( Returns: _type_: processed cyto_data for use in figure-app.elements """ - - cyto_data, positions = preprocess_cyto_data_elements(cyto_data) - g = ClusterState.from_cytoscape(cyto_data) + state = FrontendClusterState.from_cytoscape_elements(cyto_data) + expanded_qubits = state.expand_selection(selected_nodes) + display_nodes = list(expanded_qubits) if method_name in ["add_edge", "remove_edge", "toggle_edge"]: - for pair in itertools.combinations(selected_nodes, 2): - getattr(g, method_name)(*pair, **method_args) - ui = f"Applied {method_name} to {selected_nodes}" + for pair in itertools.combinations(expanded_qubits, 2): + getattr(state, method_name)(*pair, **method_args) + ui = f"Applied {method_name} to {display_nodes}" elif method_name in ["CX", "CZ"]: - for pair in itertools.batched(selected_nodes, n=2): + for pair in itertools.batched(expanded_qubits, n=2): if len(pair) < 2: return ( - "Odd number of gates!", + "Error: Odd number of gates!", no_update, no_update, no_update, no_update, ) - getattr(g, method_name)(*pair, **method_args) + getattr(state, method_name)(*pair, **method_args) - ui = f"Applied {method_name} to {selected_nodes}" + ui = f"Applied {method_name} to {display_nodes}" elif method_name in [ "X", "Y", @@ -553,122 +631,156 @@ def apply_operation_wrapper( "S", "S_DAG", ]: - for i in selected_nodes: - getattr(g, method_name)(i, **method_args) + for i in expanded_qubits: + getattr(state, method_name)(i, **method_args) - ui = f"Applied {method_name} to {selected_nodes}" + ui = f"Applied {method_name} to {display_nodes}" elif method_name in ["measure"]: outcomes = [] - for i in selected_nodes: - outcomes.append(getattr(g, method_name)(i, **method_args)) + for i in expanded_qubits: + outcomes.append(getattr(state, method_name)(i, **method_args)) - ui = f"Measured selected nodes {selected_nodes} with outcomes {outcomes}" + ui = f"Measured selected nodes {display_nodes} with outcomes {outcomes}" elif method_name in ["fusion_gate"]: - for pair in itertools.batched(selected_nodes, n=2): + for pair in itertools.batched(expanded_qubits, n=2): if len(pair) < 2: return ( - "Odd number of qubits!", + "Error: Odd number of qubits!", no_update, no_update, no_update, no_update, ) - getattr(g, method_name)(*pair, **method_args) + getattr(state, method_name)(*pair, **method_args) - ui = f"Applied {method_name} to {selected_nodes}" + ui = f"Applied {method_name} to {display_nodes}" elif method_name in ["add_node"]: - getattr(g, method_name)(**method_args) - new_node_id = len(g) - 1 - selected_positions = [] - if selected_nodes: - for parent_id in selected_nodes: - parent_pos = next( - ( - item["position"] - for item in cyto_data["elements"]["nodes"] - if item.get("data") and item["data"].get("value") == parent_id - ), - None, - ) - if parent_pos: - selected_positions.append(parent_pos) + resolved_selected = [] + if expanded_qubits: + for q in expanded_qubits: + idx = state.resolve_qubit(q) + if idx is not None and idx < len(state): + resolved_selected.append(idx) + selected_positions.append(state.get_position(idx)) if selected_positions: avg_x = sum(p["x"] for p in selected_positions) / len(selected_positions) avg_y = sum(p["y"] for p in selected_positions) / len(selected_positions) new_pos = {"x": avg_x + 50, "y": avg_y + 50} else: - new_pos = {"x": random.randint(0, 100), "y": random.randint(0, 100)} + new_pos = { + "x": float(random.randint(0, 100)), + "y": float(random.randint(0, 100)), + } - positions.append(new_pos) + new_node_id = state.add_node(position=new_pos, **method_args) # Connect the new node to every selected node - for neighbour in selected_nodes: - g.add_edge(new_node_id, neighbour) - if selected_nodes: - ui = f"Added node {new_node_id} with edges to {selected_nodes}!" + for neighbour in resolved_selected: + if neighbour < new_node_id: + state.add_edge(new_node_id, neighbour) + if expanded_qubits: + ui = f"Added node {new_node_id} with edges to {display_nodes}!" else: ui = f"Added node {new_node_id}!" elif method_name in ["remove_node"]: - getattr(g, method_name)(selected_nodes, **method_args) - ui = f"Removed nodes {selected_nodes}" - - # When a node is removed, the positions need to be updated to adjust - new_positions = [] - for cyto_index, pos in enumerate(positions): - if cyto_index not in selected_nodes: - new_positions.append(pos) + groups_to_remove = [q for q in selected_nodes if str(q) in state.groups] - cyto_data_new = g.to_cytoscape(export_elements=True) - cyto_data_new = postprocess_cyto_data_elements(cyto_data_new, new_positions) + for g_id in groups_to_remove: + state.remove_group(str(g_id), unassign_children=True) - log += f"REMOVE_NODE {' '.join(map(str, selected_nodes))}\n" + if expanded_qubits: + state.remove_node(expanded_qubits) - return ui, cyto_data_new, repr(g), log, history_data + ui = f"Removed nodes {display_nodes}" elif method_name == "duplicate": - selected_nodes.sort() - for parent_id in selected_nodes: - parent_pos = next( - ( - item["position"] - for item in cyto_data["elements"]["nodes"] - if item.get("data") and item["data"].get("value") == parent_id - ), - {"x": 0, "y": 0}, - ) - positions.append({"x": parent_pos["x"] + 50, "y": parent_pos["y"] + 50}) - - g = getattr(g, method_name)(selected_nodes, **method_args) - ui = f"Duplicated nodes {selected_nodes}" + import uuid + import copy + resolved_selected = list(expanded_qubits) + resolved_selected.sort() + + # 1. Duplicate backend simulator state + state.cluster_state = state.cluster_state.duplicate(resolved_selected) + + # 2. Track mapping from original parent group ID to new duplicated group ID + group_mapping = {} + + # 3. Append corresponding metadata for the newly created nodes + for q in resolved_selected: + meta = state.node_metadata[q] + pos = meta.get("position", {"x": 0.0, "y": 0.0}) + new_pos = {"x": pos["x"] + 50, "y": pos["y"] + 50} + + orig_parent = meta.get("parent") + new_parent = None + if orig_parent is not None: + if orig_parent not in group_mapping: + new_group_id = f"group_{uuid.uuid4().hex[:8]}" + orig_group = state.groups.get(orig_parent, {}) + new_label = orig_group.get("label", orig_parent) + new_style = copy.deepcopy(orig_group.get("style", {})) + new_val = uuid.uuid4().hex[:8] + + state.add_group(new_group_id, label=new_label, style=new_style, value=new_val) + group_mapping[orig_parent] = new_group_id + + new_parent = group_mapping[orig_parent] + + new_meta = { + "position": new_pos, + "parent": new_parent, + "value": uuid.uuid4().hex[:8], + "label": str(len(state.node_metadata)), + "classes": meta.get("classes", ""), + "style": copy.deepcopy(meta.get("style", {})), + } + if "shape" in meta: + new_meta["shape"] = meta["shape"] + state.node_metadata.append(new_meta) + + ui = f"Duplicated nodes {display_nodes}" else: raise NotImplementedError(f"Do not know {method_name}") - cyto_data_new = g.to_cytoscape(export_elements=True) - cyto_data_new = postprocess_cyto_data_elements(cyto_data_new, positions) + cyto_data_new = state.to_cytoscape_elements() + + # Map selected_nodes to resolved integer/group IDs for the log + log_nodes = [] + for n in selected_nodes: + if str(n) in state.groups: + log_nodes.append(str(n)) + else: + q = state.resolve_qubit(n) + if q is not None: + log_nodes.append(str(q)) + else: + log_nodes.append(str(n)) if method_name == "measure": log += f"M{method_args['basis']}" if method_args["force"] != -1: log += f"[{method_args['force']}]" - log += f" {' '.join(map(str, selected_nodes))}\n" + log += f" {' '.join(map(str, log_nodes))}\n" log += f"# OUTCOME {' '.join(map(str, outcomes))}\n" elif method_name == "fusion_gate": - log += f"FUSION_GATE[{method_args['fusion_type']}] {' '.join(map(str, selected_nodes))}\n" + log += f"FUSION_GATE[{method_args['fusion_type']}] {' '.join(map(str, log_nodes))}\n" elif method_name == "local_complementation": - log += f"LC {' '.join(map(str, selected_nodes))}\n" + log += f"LC {' '.join(map(str, log_nodes))}\n" elif method_name == "local_complementation_rewrite": - log += f"LCR {' '.join(map(str, selected_nodes))}\n" + log += f"LCR {' '.join(map(str, log_nodes))}\n" elif method_name == "add_node": - new_node_id = len(g) - 1 + new_node_id = len(state) - 1 log += f"ADD_NODE {new_node_id}\n" - for neighbour in selected_nodes: + for neighbour in log_nodes: log += f"ADD_EDGE {new_node_id} {neighbour}\n" + elif method_name == "remove_node": + log += f"REMOVE_NODE {' '.join(map(str, expanded_qubits))}\n" else: - log += f"{method_name.upper()} {' '.join(map(str, selected_nodes))}\n" + log += f"{method_name.upper()} {' '.join(map(str, log_nodes))}\n" - return ui, cyto_data_new, repr(g), log, history_data + return ui, cyto_data_new, repr(state.cluster_state), log, history_data def preprocess_cyto_data_elements( @@ -678,14 +790,12 @@ def preprocess_cyto_data_elements( edges = [] positions = [] for item in cyto_data: - if item.get("data"): - if item["data"].get("vop"): - nodes.append(item) # This is a node - positions.append(item["position"]) - elif item["data"].get("source"): - edges.append(item) # This is an edge - else: - raise NotImplementedError("Unknown or no data") + data = item.get("data", {}) + if data.get("vop"): + nodes.append(item) + positions.append(item.get("position", {"x": 0.0, "y": 0.0})) + elif data.get("source"): + edges.append(item) return { "data": [], "multigraph": False, @@ -700,16 +810,47 @@ def postprocess_cyto_data_elements( if item.get("data"): if item["data"].get("vop"): item["position"] = pos - else: - raise NotImplementedError("Unknown or no data") return cyto_data +def _make_load_response( + cyto_data=no_update, + sim_rep=no_update, + move_log=no_update, + ui_msg="Action completed!", + history_data=no_update, + valid=False, + invalid=False, + feedback="", + color=None, +): + if ui_msg is not no_update and ui_msg is not None: + if color is None: + color = "primary" + if invalid is True or (isinstance(ui_msg, str) and ui_msg.startswith("Error")): + color = "danger" + elif valid is True: + color = "success" + + return ( + cyto_data, + sim_rep, + move_log, + ui_msg, + color or no_update, + history_data, + valid, + invalid, + feedback, + ) + + @callback( Output("figure-app", "elements", allow_duplicate=True), Output("simulator-representation", "children", allow_duplicate=True), Output("move-log", "children", allow_duplicate=True), Output("ui", "children", allow_duplicate=True), + Output("ui", "color", allow_duplicate=True), Output("history-store", "data", allow_duplicate=True), Output("load-graph-input", "valid", allow_duplicate=True), Output("load-graph-input", "invalid", allow_duplicate=True), @@ -719,6 +860,9 @@ def postprocess_cyto_data_elements( Input("redo-button", "n_clicks"), Input("ctrl-y-btn", "n_clicks"), Input("load-button", "n_clicks"), + Input("reset-button", "n_clicks"), + Input("upload-graph-json", "contents"), + State("upload-graph-json", "filename"), State("load-graph-input", "value"), State("move-log", "children"), State("figure-app", "elements"), @@ -731,6 +875,9 @@ def load_graph( _redo, _ctrl_y, _load, + _reset, + upload_contents, + upload_filename, load_graph_input, move_log, cyto_data, @@ -739,130 +886,196 @@ def load_graph( """Load, undo, or redo from move log or saved text.""" triggered_id = callback_context.triggered_id + if triggered_id == "reset-button": + load_graph_input = "" + triggered_id = "load-button" + if triggered_id == "ctrl-z-btn": triggered_id = "undo-button" elif triggered_id == "ctrl-y-btn": triggered_id = "redo-button" - if not history_data or not isinstance(history_data, dict): - history_data = {"undo_stack": [], "redo_stack": []} - else: - history_data = { - "undo_stack": list(history_data.get("undo_stack", [])), - "redo_stack": list(history_data.get("redo_stack", [])), - } - - _, positions = preprocess_cyto_data_elements(cyto_data) - - valid = False - invalid = False - feedback = "" - - if not load_graph_input and triggered_id == "load-button": - return ( - no_update, - no_update, - no_update, - "Cannot load empty input!", - no_update, - False, - True, - "Cannot load empty input!", - ) + history_data = _normalize_history(history_data) if triggered_id == "undo-button": if not history_data["undo_stack"]: - return ( - no_update, - no_update, - no_update, - "Nothing to undo!", - no_update, - False, - False, - "", - ) + return _make_load_response(ui_msg="Error: Nothing to undo!") # Pop the state to restore prev_state = history_data["undo_stack"].pop() # Save the current state to the redo stack - _, current_positions = preprocess_cyto_data_elements(cyto_data) history_data["redo_stack"].append( - {"move_log": move_log, "positions": current_positions} + { + "action_type": prev_state.get("action_type", "quantum_operation"), + "move_log": move_log, + "elements": cyto_data, + } ) - move_log = prev_state["move_log"] - positions = prev_state["positions"] - g, parsed_log = ClusterState.from_string(move_log, return_log=True) + action_type = prev_state.get("action_type", "quantum_operation") + move_log = prev_state.get("move_log", move_log) + cyto_data_new = prev_state["elements"] + + if action_type == "frontend_customization": + # Undoing a plot customization (group creation/removal, label, shape): + # Restore FrontendClusterState elements without resetting simulator state or move_log + state = FrontendClusterState.from_cytoscape_elements(cyto_data_new) + return _make_load_response( + cyto_data_new, + repr(state.cluster_state), + move_log, + "Undid plot customization!", + history_data, + ) + else: + # Undoing a quantum operation: restore quantum state while preserving active groups and plot customizations + curr_state = FrontendClusterState.from_cytoscape_elements(cyto_data) + state = FrontendClusterState.from_cytoscape_elements(cyto_data_new) + + # Preserve active groups from curr_state if present + for g_id, g_info in curr_state.groups.items(): + if g_id not in state.groups: + state.add_group( + g_id, label=g_info.get("label"), style=g_info.get("style") + ) + + cyto_data_new = state.to_cytoscape_elements() + + return _make_load_response( + cyto_data_new, + repr(state.cluster_state), + move_log, + "Undid operation!", + history_data, + ) elif triggered_id == "redo-button": if not history_data["redo_stack"]: - return ( - no_update, - no_update, - no_update, - "Nothing to redo!", - no_update, - False, - False, - "", - ) + return _make_load_response(ui_msg="Error: Nothing to redo!") # Pop the state to restore next_state = history_data["redo_stack"].pop() # Save the current state to the undo stack - _, current_positions = preprocess_cyto_data_elements(cyto_data) history_data["undo_stack"].append( - {"move_log": move_log, "positions": current_positions} + { + "action_type": next_state.get("action_type", "quantum_operation"), + "move_log": move_log, + "elements": cyto_data, + } ) - move_log = next_state["move_log"] - positions = next_state["positions"] - g, parsed_log = ClusterState.from_string(move_log, return_log=True) + action_type = next_state.get("action_type", "quantum_operation") + move_log = next_state.get("move_log", move_log) + cyto_data_new = next_state["elements"] + + state = FrontendClusterState.from_cytoscape_elements(cyto_data_new) + return _make_load_response( + cyto_data_new, + repr(state.cluster_state), + move_log, + "Action completed!", + history_data, + ) elif triggered_id == "load-button": try: - g, parsed_log = ClusterState.from_string(load_graph_input, return_log=True) - valid = True - invalid = False + load_graph_input_str = (load_graph_input or "").strip() + if load_graph_input_str.startswith("{") or load_graph_input_str.startswith("["): + import json + data = json.loads(load_graph_input_str) + if isinstance(data, dict) and "cluster_state_json" in data: + state = FrontendClusterState.from_dict(data) + g = state.cluster_state + new_move_log = "# Reconstructed from Frontend JSON string\n" + else: + g = ClusterState.from_json(data) + state = FrontendClusterState(num_nodes=len(g), cluster_state=g) + new_move_log = "# Reconstructed from Graph JSON string\n" + else: + g, parsed_log = ClusterState.from_string(load_graph_input, return_log=True) + state = FrontendClusterState(num_nodes=len(g), cluster_state=g) + new_move_log = parsed_log + + # Record the state before loading to the undo stack + history_data["undo_stack"].append( + { + "action_type": "quantum_operation", + "move_log": move_log, + "elements": cyto_data, + } + ) + history_data["redo_stack"] = [] + cyto_data_new = state.to_cytoscape_elements() except Exception as e: err_msg = str(e) - return ( - no_update, - no_update, - no_update, - f"Failed to load: {err_msg}", - no_update, - False, - True, - err_msg, + return _make_load_response( + ui_msg=f"Error: Failed to load: {err_msg}", invalid=True, feedback=err_msg ) - if len(positions) < len(g): - for i in range(len(g) - len(positions)): - positions += [ - {"x": random.randint(0, 300), "y": random.randint(0, 300)} - ] - positions = positions[: len(g)] - move_log = parsed_log - # Clear history on new graph load - history_data = {"undo_stack": [], "redo_stack": []} + return _make_load_response( + cyto_data_new, + repr(g), + new_move_log, + "Action completed!", + history_data, + valid=True, + ) - cyto_data_new = g.to_cytoscape(export_elements=True) - cyto_data_new = postprocess_cyto_data_elements(cyto_data_new, positions) + elif triggered_id == "upload-graph-json": + if not upload_contents: + return no_update + try: + import base64 + import json + content_type, content_string = upload_contents.split(",") + decoded_bytes = base64.b64decode(content_string) + decoded = decoded_bytes.decode("utf-8") + + # Check if JSON + try: + data = json.loads(decoded) + if isinstance(data, dict) and "cluster_state_json" in data: + state = FrontendClusterState.from_dict(data) + g = state.cluster_state + new_move_log = f"# Reconstructed from Frontend JSON file: {upload_filename}\n" + else: + g = ClusterState.from_json(data) + state = FrontendClusterState(num_nodes=len(g), cluster_state=g) + new_move_log = f"# Reconstructed from Graph JSON file: {upload_filename}\n" + except json.JSONDecodeError: + # Treat as move log operations text + g, parsed_log = ClusterState.from_string(decoded, return_log=True) + state = FrontendClusterState(num_nodes=len(g), cluster_state=g) + new_move_log = parsed_log + + # Record the state before loading to the undo stack + history_data["undo_stack"].append( + { + "action_type": "quantum_operation", + "move_log": move_log, + "elements": cyto_data, + } + ) + history_data["redo_stack"] = [] + cyto_data_new = state.to_cytoscape_elements() + except Exception as e: + err_msg = str(e) + return _make_load_response( + ui_msg=f"Error: Failed to load file: {err_msg}", invalid=True, feedback=err_msg + ) - return ( - cyto_data_new, - repr(g), - move_log, - "Action completed!", - history_data, - valid, - invalid, - feedback, - ) + return _make_load_response( + cyto_data_new, + repr(g), + new_move_log, + f"Successfully loaded {upload_filename}!", + history_data, + valid=True, + ) + + return _make_load_response(history_data=history_data) @callback( @@ -872,20 +1085,22 @@ def load_graph( prevent_initial_call=True, ) def export_graph_to_json(n_clicks, cyto_data): - if not n_clicks: + if not n_clicks or not cyto_data: return no_update - cyto_data, positions = preprocess_cyto_data_elements(cyto_data) - g = ClusterState.from_cytoscape(cyto_data) - return dcc.send_string(g.to_json(), "cytoscape_graph.json") + state = FrontendClusterState.from_cytoscape_elements(cyto_data) + return dcc.send_string(state.to_json(), "cytoscape_graph.json") @callback( Output("clipboard-store", "data"), Input("ctrl-c-btn", "n_clicks"), State("figure-app", "selectedNodeData"), + State("figure-app", "elements"), prevent_initial_call=True, ) -def copy_nodes(n_clicks, selected_node_data): - if not selected_node_data: +def copy_nodes(n_clicks, selected_node_data, cyto_data): + if not selected_node_data or not cyto_data: return [] - return [node["value"] for node in selected_node_data] + state = FrontendClusterState.from_cytoscape_elements(cyto_data) + expanded_qubits = state.expand_selection(selected_node_data) + return [state.get_value(q) for q in expanded_qubits] diff --git a/src/components/components_2d/cytoscape_panel.py b/src/components/components_2d/cytoscape_panel.py new file mode 100644 index 0000000..52218b9 --- /dev/null +++ b/src/components/components_2d/cytoscape_panel.py @@ -0,0 +1,390 @@ +import uuid +import dash_bootstrap_components as dbc +from dash import html, dcc, callback, Input, Output, State, no_update, callback_context +from clustersim.simulator import FrontendClusterState + +# Preset shapes supported by Cytoscape JS +SHAPE_OPTIONS = [ + {"label": "Ellipse (Default)", "value": "ellipse"}, + {"label": "Rectangle", "value": "rectangle"}, + {"label": "Round Rectangle", "value": "round-rectangle"}, + {"label": "Triangle", "value": "triangle"}, + {"label": "Pentagon", "value": "pentagon"}, + {"label": "Hexagon", "value": "hexagon"}, + {"label": "Heptagon", "value": "heptagon"}, + {"label": "Octagon", "value": "octagon"}, + {"label": "Star", "value": "star"}, + {"label": "Diamond", "value": "diamond"}, + {"label": "Vee", "value": "vee"}, + {"label": "Rhombus", "value": "rhombus"}, +] + +layout_dropdown = dcc.Dropdown( + ["random", "grid", "circle", "breadthfirst", "cose", "concentric", "preset"], + "preset", + id="graph_layout_dropdown", +) + +node_labels = dbc.Select( + id="node_labels", + options=[ + {"label": "Value", "value": "data(value)"}, + {"label": "ID", "value": "data(id)"}, + {"label": "Label", "value": "data(label)"}, + {"label": "VOP", "value": "data(vop)"}, + ], + placeholder="Label", + value="data(label)", + size="sm", +) + +# Offcanvas UI component definition +cytoscape_offcanvas = dbc.Offcanvas( + [ + html.Div( + [ + html.Div( + [ + html.Label( + "Graph Layout", className="form-label small fw-bold mb-1" + ), + layout_dropdown, + ], + className="mb-3", + ), + html.Div( + [ + html.Label( + "Node Labels Display", + className="form-label small fw-bold mb-1", + ), + node_labels, + ], + className="mb-3", + ), + html.Hr(className="my-3"), + ] + ), + html.Div( + [ + html.Div( + [ + html.Label( + "Custom Label", className="form-label small fw-bold mb-1" + ), + dbc.Input( + id="custom-label-input", + type="text", + placeholder="Enter label text...", + size="sm", + ), + ], + className="mb-2", + ), + html.Div( + [ + html.Label( + "Node Shape", className="form-label small fw-bold mb-1" + ), + dbc.Select( + id="node-shape-select", + options=SHAPE_OPTIONS, + value="ellipse", + size="sm", + placeholder="Ellipse (Default)", + ), + ], + className="mb-3", + ), + html.Div( + [ + dbc.Button( + "Apply Label", + id="apply-label-btn", + color="primary", + size="sm", + className="w-100 mb-2", + ), + dbc.Button( + "Apply Shape", + id="apply-shape-btn", + color="primary", + size="sm", + className="w-100 mb-2", + ), + dbc.Button( + "Create Group from Selection", + id="create-group-btn", + color="primary", + size="sm", + className="w-100 mb-2", + ), + dbc.Tooltip( + "Group / Ungroup Selection (G)", + target="create-group-btn", + placement="top", + ), + ], + className="d-flex flex-column mb-3", + ), + html.Hr(className="my-3"), + ] + ), + html.Div( + [ + html.Div( + [ + dbc.Button( + "Snap to Grid", + id="snap-to-grid", + color="secondary", + outline=True, + size="sm", + className="w-100 mb-2", + ), + dbc.Button( + "Export SVG", + id="btn-get-svg", + color="secondary", + outline=True, + size="sm", + className="w-100 mb-2", + ), + dbc.Button( + "Export JSON", + id="btn-get-json", + color="secondary", + outline=True, + size="sm", + className="w-100 mb-2", + ), + dcc.Download(id="download-json"), + ], + className="d-flex flex-column", + ), + ] + ), + ], + id="cytoscape-offcanvas", + title="Plot Options & Customization", + is_open=False, + scrollable=True, + backdrop=False, + placement="end", + style={"width": "400px"}, +) + + +@callback( + Output("cytoscape-offcanvas", "is_open"), + Input("open-offcanvas-btn", "n_clicks"), + State("cytoscape-offcanvas", "is_open"), + prevent_initial_call=True, +) +def toggle_offcanvas(n_clicks, is_open): + if n_clicks: + return not is_open + return is_open + + +@callback( + Output("custom-label-input", "value"), + Output("node-shape-select", "value"), + Output("create-group-btn", "children"), + Input("figure-app", "selectedNodeData"), + Input("figure-app", "selectedEdgeData"), + State("figure-app", "elements"), +) +def update_offcanvas_inputs(selected_nodes, selected_edges, elements): + label_val = "" + shape_val = no_update + btn_text = "Create Group from Selection" + + if selected_nodes and elements: + state = FrontendClusterState.from_cytoscape_elements(elements) + + def _is_node_grouped(n): + n_id = str(n.get("id")) + if n_id in state.groups: + return True + q = state.resolve_qubit(n_id) + return q is not None and state.get_group(q) is not None + + if any(_is_node_grouped(n) for n in selected_nodes): + btn_text = "Ungroup Selection" + + if selected_nodes and len(selected_nodes) == 1: + node = selected_nodes[0] + node_id = str(node.get("id")) + actual = next( + ( + el + for el in (elements or []) + if str(el.get("data", {}).get("id")) == node_id + ), + None, + ) + data = actual.get("data", {}) if actual else node + + label_val = data.get("label", "") + if "vop" in data and label_val == str(data.get("value", data.get("id"))): + label_val = "" + shape_val = data.get("shape") or ( + actual.get("style", {}).get("shape") if actual else "ellipse" + ) + + elif selected_edges and len(selected_edges) == 1: + edge = selected_edges[0] + src, tgt = str(edge.get("source")), str(edge.get("target")) + actual = next( + ( + el + for el in (elements or []) + if str(el.get("data", {}).get("source")) == src + and str(el.get("data", {}).get("target")) == tgt + ), + None, + ) + data = actual.get("data", {}) if actual else edge + label_val = data.get("label", "") + + return label_val, shape_val, btn_text + + +@callback( + Output("figure-app", "elements", allow_duplicate=True), + Output("history-store", "data", allow_duplicate=True), + Input("apply-label-btn", "n_clicks"), + Input("apply-shape-btn", "n_clicks"), + Input("create-group-btn", "n_clicks"), + Input("key-g-btn", "n_clicks"), + State("figure-app", "elements"), + State("figure-app", "selectedNodeData"), + State("figure-app", "selectedEdgeData"), + State("custom-label-input", "value"), + State("node-shape-select", "value"), + State("history-store", "data"), + State("move-log", "children"), + prevent_initial_call=True, +) +def handle_cytoscape_options( + btn_label, + btn_shape, + btn_create_group, + key_g, + elements, + selected_nodes, + selected_edges, + custom_label, + node_shape, + history_data, + move_log, +): + ctx = callback_context + if not ctx.triggered or not elements: + return no_update, no_update + + trigger_id = ctx.triggered[0]["prop_id"].split(".")[0] + if trigger_id == "key-g-btn": + trigger_id = "create-group-btn" + lbl_text = custom_label.strip() if custom_label else "" + + if not history_data or not isinstance(history_data, dict): + history_data = {"undo_stack": [], "redo_stack": []} + else: + history_data = { + "undo_stack": list(history_data.get("undo_stack", [])), + "redo_stack": list(history_data.get("redo_stack", [])), + } + + # Record state before applying customization + history_data["undo_stack"].append( + { + "action_type": "frontend_customization", + "move_log": move_log or "", + "elements": elements, + } + ) + history_data["redo_stack"] = [] + + state = FrontendClusterState.from_cytoscape_elements(elements) + + if trigger_id == "apply-label-btn": + if selected_nodes: + for n in selected_nodes: + n_id = str(n.get("id")) + if n_id in state.groups: + state.groups[n_id]["label"] = lbl_text + else: + q = state.resolve_qubit(n_id) + if q is not None and 0 <= q < len(state): + state.node_metadata[q]["label"] = ( + lbl_text if lbl_text else str(q) + ) + return state.to_cytoscape_elements(), history_data + + elif trigger_id == "apply-shape-btn": + if selected_nodes: + expanded_qubits = state.expand_selection(selected_nodes) + for q in expanded_qubits: + if 0 <= q < len(state): + state.node_metadata[q]["shape"] = node_shape + if "style" not in state.node_metadata[q]: + state.node_metadata[q]["style"] = {} + state.node_metadata[q]["style"]["shape"] = node_shape + return state.to_cytoscape_elements(), history_data + + elif trigger_id == "create-group-btn": + if not selected_nodes: + return no_update, no_update + + selected_group_ids = [] + selected_qubits = [] + + for n in selected_nodes: + n_id = str(n.get("id")) + if n_id in state.groups: + selected_group_ids.append(n_id) + else: + q = state.resolve_qubit(n_id) + if q is not None: + selected_qubits.append(q) + + has_group_container = len(selected_group_ids) > 0 + grouped_qubits = [q for q in selected_qubits if state.get_group(q) is not None] + is_ungroup_action = has_group_container or len(grouped_qubits) > 0 + + if is_ungroup_action: + # Ungroup action: remove selected group containers and unassign grouped qubits + for g_id in selected_group_ids: + state.remove_group(g_id, unassign_children=True) + + affected_groups = set() + for q in grouped_qubits: + g_id = state.get_group(q) + if g_id: + affected_groups.add(g_id) + state.set_group(q, None) + + # Clean up any affected groups that no longer have member nodes + for g_id in affected_groups: + remaining = any( + meta.get("parent") == g_id for meta in state.node_metadata + ) + if not remaining: + state.remove_group(g_id) + else: + # Group action: create a new group for selected un-grouped qubits + if not selected_qubits: + return no_update, no_update + + g_id = f"group_{uuid.uuid4().hex[:8]}" + g_label = lbl_text if lbl_text else "" + state.add_group(g_id, label=g_label) + for q in selected_qubits: + if q < len(state): + state.set_group(q, g_id) + + return state.to_cytoscape_elements(), history_data + + return no_update, no_update diff --git a/src/components/components_2d/figure_2d.py b/src/components/components_2d/figure_2d.py index c8f29b6..3b501a2 100644 --- a/src/components/components_2d/figure_2d.py +++ b/src/components/components_2d/figure_2d.py @@ -1,4 +1,53 @@ -from dash import dcc, html, callback, Input, Output, State, no_update, callback_context +from dash import ( + dcc, + html, + callback, + clientside_callback, + Input, + Output, + State, + no_update, + callback_context, +) + +COLOR_PALETTE = { + "IA": "#93C5FD", + "IB": "#60A5FA", + "IC": "#3B82F6", + "ID": "#2563EB", + "IE": "#1D4ED8", + "IF": "#1F3A8A", + "XA": "#6EE7B7", + "XB": "#34D399", + "XC": "#10B981", + "XD": "#059669", + "XE": "#166534", + "XF": "#14532D", + "YA": "#FCD34D", + "YB": "#FBBF24", + "YC": "#F59E0B", + "YD": "#EA580C", + "YE": "#C2410C", + "YF": "#9A3412", + "ZA": "#DDD6FE", + "ZB": "#C084FC", + "ZC": "#A855F7", + "ZD": "#9333EA", + "ZE": "#7E22CE", + "ZF": "#581C87", +} + +COLOR_STYLES = [ + { + "selector": f'[vop *= "{label}"]', + "style": { + "background-color": color, + "border-width": 2, + "border-color": "#000000", + }, + } + for label, color in COLOR_PALETTE.items() +] from textwrap import dedent as d import dash_bootstrap_components as dbc from clustersim.simulator import ClusterState @@ -18,7 +67,6 @@ cyto.load_extra_layouts() -# Initialize the figure of the user's browsing section def _init_state(): G = ClusterState(0) cyto_data = G.to_cytoscape(export_elements=True) @@ -27,12 +75,15 @@ def _init_state(): return cyto_data, repr(G) +INIT_CYTO_ELEMENTS, INIT_SIM_REP = _init_state() + + figure_2d = cyto.Cytoscape( id="figure-app", layout={"name": "preset"}, style={"width": "100%", "height": "100%"}, stylesheet=[{"selector": "node", "style": {"label": "data(vop)"}}], - elements=_init_state()[0], + elements=INIT_CYTO_ELEMENTS, selectedNodeData=[], boxSelectionEnabled=True, wheelSensitivity=0.2, @@ -40,25 +91,6 @@ def _init_state(): maxZoom=20, ) -layout_dropdown = dcc.Dropdown( - ["random", "grid", "circle", "breadthfirst", "cose", "concentric", "preset"], - "preset", - id="graph_layout_dropdown", -) - -node_labels = dbc.Select( - id="node_labels", - options=[ - {"label": "Value", "value": "data(value)"}, - {"label": "ID", "value": "data(id)"}, - {"label": "Label", "value": "data(label)"}, - {"label": "VOP", "value": "data(vop)"}, - ], - placeholder="Value", - value="data(value)", -) - - tab_1 = dbc.Row([dbc.Col(qubit_panel, width=12)]) tab_3 = dbc.Row( @@ -87,52 +119,73 @@ def _init_state(): "", type="invalid", id="load-feedback-invalid" ), ], - width=6, + width=4, + ), + dbc.Col( + dbc.Button( + "Load", id="load-button", style={"width": "100%"} + ), + width=2, + ), + dbc.Col( + dbc.Button( + "Reset", id="reset-button", style={"width": "100%"}, color="danger", outline=True + ), + width=2, + ), + dbc.Col( + dbc.Button( + "Undo", id="undo-button", style={"width": "100%"} + ), + width=2, + ), + dbc.Col( + dbc.Button( + "Redo", id="redo-button", style={"width": "100%"} + ), + width=2, + ), + dbc.Tooltip( + "Undo (Ctrl+Z)", target="undo-button", placement="top" + ), + dbc.Tooltip( + "Redo (Ctrl+Y)", target="redo-button", placement="top" ), - dbc.Col(dbc.Button("Load", id="load-button", style={"width": "100%"}), width=2), - dbc.Col(dbc.Button("Undo", id="undo-button", style={"width": "100%"}), width=2), - dbc.Col(dbc.Button("Redo", id="redo-button", style={"width": "100%"}), width=2), - dbc.Tooltip("Undo (Ctrl+Z)", target="undo-button", placement="top"), - dbc.Tooltip("Redo (Ctrl+Y)", target="redo-button", placement="top"), ], className="align-items-center", ), + html.Div(style={"height": "10px"}), + dbc.Row( + [ + dbc.Col( + dcc.Upload( + id="upload-graph-json", + children=html.Div( + [ + "Drag & Drop or ", + html.A("Select JSON/Text File", style={"textDecoration": "underline", "cursor": "pointer"}) + ], + style={ + "border": "1px dashed #ced4da", + "borderRadius": "5px", + "padding": "10px", + "textAlign": "center", + "backgroundColor": "#f8f9fa", + "color": "#495057", + } + ), + multiple=False, + ), + width=12, + ) + ] + ), ], width=12, ) ] ) -tab_5 = dbc.Row( - [ - dbc.Col( - [ - dcc.Markdown("**Select Layout**", style={"margin": "0 0 4px 0"}), - layout_dropdown, - ], - width=4, - ), - dbc.Col( - [ - dcc.Markdown("**Select Node Labels**", style={"margin": "0 0 4px 0"}), - node_labels, - ], - width=4, - ), - dbc.Col( - [ - dcc.Markdown("**Plot Options**", style={"margin": "0 0 4px 0"}), - dbc.Button("Snap to Grid", id="snap-to-grid", style={"width": "100%"}), - dbc.Button("Export SVG", id="btn-get-svg"), - dbc.Button("Export JSON", id="btn-get-json"), - dcc.Download(id="download-json"), - ], - width=4, - className="d-flex flex-column justify-content-end", - ), - ] -) - tab_ui_2d = html.Div( [ # Store for active modal tab @@ -186,11 +239,6 @@ def _init_state(): id="modal-tab-reset", style={"cursor": "pointer"}, ), - dbc.NavLink( - "Plot Options", - id="modal-tab-plot", - style={"cursor": "pointer"}, - ), ], vertical=True, pills=True, @@ -230,7 +278,7 @@ def _init_state(): "fontFamily": "SFMono-Regular, Menlo, Monaco, Consolas, monospace", "fontSize": "13px", }, - children=_init_state()[1], + children=INIT_SIM_REP, ), ], id="modal-content-rep", @@ -301,12 +349,6 @@ def _init_state(): id="modal-content-reset", style={"display": "none"}, ), - # Page 5: Plot Options - html.Div( - tab_5, - id="modal-content-plot", - style={"display": "none"}, - ), ], style={"paddingLeft": "20px"}, ) @@ -331,8 +373,8 @@ def _init_state(): html.H2( "Cluster Sim", style={ - "margin": "0 0 4px 0", - "fontSize": "22px", + "margin": "0 0 2px 0", + "fontSize": "18px", "fontWeight": "bold", }, ), @@ -342,9 +384,9 @@ def _init_state(): color="primary", id="ui", style={ - "padding": "4px 10px", + "padding": "2px 6px", "margin": "0", - "fontSize": "0.8rem", + "fontSize": "0.75rem", }, ), delay_show=100, @@ -352,16 +394,18 @@ def _init_state(): custom_spinner=dbc.Spinner(color="primary", size="sm"), ), ], - width=2, + xs=3, + md=2, className="d-flex flex-column justify-content-center", ), # Center: Operations panel dbc.Col( - tab_1, - width=10, + html.Div(tab_1, className="scrollable-toolbar-container"), + xs=9, + md=10, ), ], - className="align-items-start mb-2 flex-nowrap", + className="align-items-center flex-nowrap", ), ] ) @@ -376,17 +420,36 @@ def displaySelectedNodeData(data_list: List[Dict[str, Any]]): if not data_list: return "Click on the graph to select nodes, or SHIFT+click to select multiple nodes." else: - logging.debug(f"Selected {[i['value'] for i in data_list]}") - return f"Selected {[i['value'] for i in data_list]}" - - -@callback( + selected_ids = [] + for i in data_list: + if "id" in i: + raw_id = i["id"] + if isinstance(raw_id, str) and ( + raw_id.startswith("group_") or "group" in str(i.get("classes", "")) + ): + group_disp = i.get("label") or raw_id + selected_ids.append(group_disp) + elif isinstance(raw_id, int) or ( + isinstance(raw_id, str) and raw_id.isdigit() + ): + selected_ids.append(int(raw_id)) + else: + selected_ids.append(raw_id) + selected_values = [i["value"] for i in data_list if "value" in i] + logging.debug(f"Selected {selected_values}") + return f"Selected {selected_ids}" + + +clientside_callback( + """ + function(value) { + return {name: value}; + } + """, Output("figure-app", "layout"), Input("graph_layout_dropdown", "value"), prevent_initial_call=True, ) -def update_layout(value): - return {"name": value} @callback( @@ -395,49 +458,8 @@ def update_layout(value): Input("node_labels", "value"), ) def update_stylesheet(_, node_labels: str): - label_style = [{"selector": "node", "style": {"label": node_labels}}] - color_palette = { - "IA": "#93C5FD", - "IB": "#60A5FA", - "IC": "#3B82F6", - "ID": "#2563EB", - "IE": "#1D4ED8", - "IF": "#1F3A8A", - "XA": "#6EE7B7", - "XB": "#34D399", - "XC": "#10B981", - "XD": "#059669", - "XE": "#166534", - "XF": "#14532D", - "YA": "#FCD34D", - "YB": "#FBBF24", - "YC": "#F59E0B", - "YD": "#EA580C", - "YE": "#C2410C", - "YF": "#9A3412", - "ZA": "#DDD6FE", - "ZB": "#C084FC", - "ZC": "#A855F7", - "ZD": "#9333EA", - "ZE": "#7E22CE", - "ZF": "#581C87", - } - - color_styles = [] - for label, color in color_palette.items(): - color_styles.append( - { - "selector": f'[vop *= "{label}"]', - "style": { - "background-color": color, - "border-width": 2, - "border-color": "#000000", - }, - } - ) - selected_style = [ { "selector": "node:selected", @@ -448,7 +470,58 @@ def update_stylesheet(_, node_labels: str): } ] - return label_style + color_styles + selected_style + parent_label = "data(value)" if node_labels == "data(value)" else "data(label)" + + custom_styles = [ + { + "selector": "node", + "style": { + "shape": "data(shape)", + }, + }, + { + "selector": "edge", + "style": { + "label": "data(label)", + "text-rotation": "autorotate", + "text-margin-y": -10, + "font-size": "10px", + "color": "#475569", + }, + }, + { + "selector": "node:parent", + "style": { + "background-opacity": 0.333, + "background-color": "#cbd5e1", + "border-color": "#94a3b8", + "border-width": 2, + "label": parent_label, + "font-size": "14px", + "font-weight": "bold", + "text-valign": "top", + "text-halign": "center", + "text-margin-y": -6, + }, + }, + { + "selector": "node:parent:selected", + "style": { + "background-opacity": 0.5, + "background-color": "#bae6fd", + "border-color": "#0284c7", + "border-width": 3, + }, + }, + { + "selector": "node:childless[!vop]", + "style": { + "display": "none", + }, + }, + ] + + return label_style + COLOR_STYLES + selected_style + custom_styles @callback( @@ -480,35 +553,34 @@ def toggle_modal(n1, n2, is_open): return not is_open +TAB_KEYS = ["rep", "log", "ref", "reset"] + + @callback( Output("modal-tab-store", "data"), Output("modal-tab-rep", "active"), Output("modal-tab-log", "active"), Output("modal-tab-ref", "active"), Output("modal-tab-reset", "active"), - Output("modal-tab-plot", "active"), Input("modal-tab-rep", "n_clicks"), Input("modal-tab-log", "n_clicks"), Input("modal-tab-ref", "n_clicks"), Input("modal-tab-reset", "n_clicks"), - Input("modal-tab-plot", "n_clicks"), prevent_initial_call=True, ) -def switch_modal_tab(n_rep, n_log, n_ref, n_reset, n_plot): +def switch_modal_tab(*_clicks): ctx = callback_context if not ctx.triggered: - return "rep", True, False, False, False, False + return "rep", True, False, False, False trigger_id = ctx.triggered[0]["prop_id"].split(".")[0] - if trigger_id == "modal-tab-log": - return "log", False, True, False, False, False - elif trigger_id == "modal-tab-ref": - return "ref", False, False, True, False, False - elif trigger_id == "modal-tab-reset": - return "reset", False, False, False, True, False - elif trigger_id == "modal-tab-plot": - return "plot", False, False, False, False, True - return "rep", True, False, False, False, False + tab = ( + trigger_id.replace("modal-tab-", "") + if trigger_id.startswith("modal-tab-") + else "rep" + ) + actives = [tab == key for key in TAB_KEYS] + return (tab, *actives) @callback( @@ -516,19 +588,9 @@ def switch_modal_tab(n_rep, n_log, n_ref, n_reset, n_plot): Output("modal-content-log", "style"), Output("modal-content-ref", "style"), Output("modal-content-reset", "style"), - Output("modal-content-plot", "style"), Input("modal-tab-store", "data"), ) def toggle_modal_content_visibility(active_tab): - styles = [{"display": "none"} for _ in range(5)] - if active_tab == "rep": - styles[0] = {"display": "block"} - elif active_tab == "log": - styles[1] = {"display": "block"} - elif active_tab == "ref": - styles[2] = {"display": "block"} - elif active_tab == "reset": - styles[3] = {"display": "block"} - elif active_tab == "plot": - styles[4] = {"display": "block"} - return styles[0], styles[1], styles[2], styles[3], styles[4] + return tuple( + {"display": "block" if active_tab == key else "none"} for key in TAB_KEYS + ) diff --git a/src/components/components_2d/move_log_2d.py b/src/components/components_2d/move_log_2d.py index 4574e26..6a972af 100644 --- a/src/components/components_2d/move_log_2d.py +++ b/src/components/components_2d/move_log_2d.py @@ -1,7 +1,6 @@ from textwrap import dedent as d -from dash import dcc, html, Input, Output, callback, no_update, State +from dash import dcc, html, Input, Output, callback, no_update import dash_bootstrap_components as dbc -import json move_log = dbc.Card( dbc.CardBody( @@ -44,7 +43,7 @@ Input("btn-get-svg", "n_clicks"), prevent_initial_call=True, ) -def export_svg(n_clicks, ftype="svg"): +def export_svg(n_clicks): if n_clicks > 0: return {"type": "svg", "action": "download", "filename": "cytoscape"} return no_update diff --git a/tests/test_app.py b/tests/test_app.py index 63da41d..235a74b 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -44,7 +44,7 @@ def test_apply_operation_wrapper_errors(): history_data={"undo_stack": [], "redo_stack": []}, ) assert len(res_cz) == 5 - assert res_cz[0] == "Odd number of gates!" + assert res_cz[0] == "Error: Odd number of gates!" assert res_cz[1:] == (no_update, no_update, no_update, no_update) res_fusion = apply_operation_wrapper( @@ -56,7 +56,7 @@ def test_apply_operation_wrapper_errors(): history_data={"undo_stack": [], "redo_stack": []}, ) assert len(res_fusion) == 5 - assert res_fusion[0] == "Odd number of qubits!" + assert res_fusion[0] == "Error: Odd number of qubits!" assert res_fusion[1:] == (no_update, no_update, no_update, no_update) @@ -84,7 +84,7 @@ def test_apply_operation_wrapper_add_node_offset(): new_node_pos = next( item["position"] for item in cyto_data_new - if item.get("data") and item["data"].get("value") == 2 + if item.get("data") and str(item["data"].get("id")) == "2" ) assert 0 <= new_node_pos["x"] <= 100 assert 0 <= new_node_pos["y"] <= 100 @@ -109,11 +109,114 @@ def test_apply_operation_wrapper_add_node_offset(): new_node_pos2 = next( item["position"] for item in cyto_data_new2 - if item.get("data") and item["data"].get("value") == 2 + if item.get("data") and str(item["data"].get("id")) == "2" ) assert new_node_pos2 == {"x": 70, "y": 80} +def test_apply_operation_wrapper_duplicate(): + from components.components_2d.action_panel import apply_operation_wrapper + from clustersim.simulator import FrontendClusterState + + # 1. Setup a FrontendClusterState with 3 nodes, two of which are in a group + state = FrontendClusterState(3) + state.add_group("group_alpha", label="Alpha Group") + state.set_group(0, "group_alpha") + state.set_group(1, "group_alpha") + + state.set_position(0, {"x": 10.0, "y": 20.0}) + state.set_position(1, {"x": 30.0, "y": 40.0}) + state.set_position(2, {"x": 50.0, "y": 60.0}) + + state.CZ(0, 1) + + cyto_data = state.to_cytoscape_elements() + + # 2. Duplicate node 0 (which is in group_alpha) + res = apply_operation_wrapper( + method_name="duplicate", + selected_nodes=["0"], + cyto_data=cyto_data, + log="", + method_args={}, + history_data={"undo_stack": [], "redo_stack": []}, + ) + + ui, cyto_data_new, repr_g, log_out, history_data_out = res + assert ui == "Duplicated nodes [0]" + + # 3. Reconstruct to check metadata and counts + reconstructed = FrontendClusterState.from_cytoscape_elements(cyto_data_new) + assert len(reconstructed) == 4 # Original 3 + 1 duplicated + + assert reconstructed.get_position(3) == {"x": 60.0, "y": 70.0} + new_group_id = reconstructed.get_group(3) + assert new_group_id is not None + assert new_group_id != "group_alpha" + assert new_group_id in reconstructed.groups + assert reconstructed.groups[new_group_id]["label"] == "Alpha Group" + + # 4. Duplicate the whole group "group_alpha" + res_group = apply_operation_wrapper( + method_name="duplicate", + selected_nodes=["group_alpha"], + cyto_data=cyto_data_new, + log="", + method_args={}, + history_data={"undo_stack": [], "redo_stack": []}, + ) + ui_g, cyto_data_group, _, _, _ = res_group + + reconstructed_g = FrontendClusterState.from_cytoscape_elements(cyto_data_group) + # The group contains 0 and 1. So duplication adds 2 nodes. + # Total nodes should be 4 (from cyto_data_new) + 2 = 6. + assert len(reconstructed_g) == 6 + + parent_4 = reconstructed_g.get_group(4) + parent_5 = reconstructed_g.get_group(5) + + assert parent_4 is not None + assert parent_4 == parent_5 + assert parent_4 != "group_alpha" + assert parent_4 in reconstructed_g.groups + assert reconstructed_g.groups[parent_4]["label"] == "Alpha Group" + + +def test_copy_nodes_with_group(): + from components.components_2d.action_panel import copy_nodes + from clustersim.simulator import FrontendClusterState + + # 1. Setup a FrontendClusterState with 2 nodes inside a group + state = FrontendClusterState(2) + state.add_group("group_alpha", label="Alpha Group") + state.set_group(0, "group_alpha") + state.set_group(1, "group_alpha") + + cyto_data = state.to_cytoscape_elements() + + # 2. Case A: Only the group is selected + selected_node_data_group = [ + {"id": "group_alpha", "value": state.groups["group_alpha"]["value"]} + ] + + clipboard = copy_nodes(1, selected_node_data_group, cyto_data) + assert len(clipboard) == 2 + assert state.get_value(0) in clipboard + assert state.get_value(1) in clipboard + + # 3. Case B: The group AND its children are selected (should only copy/duplicate once) + selected_node_data_both = [ + {"id": "group_alpha", "value": state.groups["group_alpha"]["value"]}, + {"id": "0", "value": state.get_value(0)}, + {"id": "1", "value": state.get_value(1)}, + ] + + clipboard_both = copy_nodes(1, selected_node_data_both, cyto_data) + assert len(clipboard_both) == 2 + assert state.get_value(0) in clipboard_both + assert state.get_value(1) in clipboard_both + + def test_action_panel_tooltips(): from components.components_2d.action_panel import qubit_panel import dash_bootstrap_components as dbc @@ -166,3 +269,260 @@ def test_fusion_buttons_mapping(): op_name, op_args = button_operations[key] assert op_name == "fusion_gate" assert op_args["fusion_type"] == expected_type + + +def test_load_graph_callback_json(): + from components.components_2d.action_panel import load_graph + from clustersim.simulator import FrontendClusterState + from unittest.mock import patch + import json + import base64 + + # Create dummy initial state data + initial_state = FrontendClusterState(2) + initial_elements = initial_state.to_cytoscape_elements() + initial_log = "original log content" + initial_history = {"undo_stack": [], "redo_stack": []} + + # Target state we want to load + target_state = FrontendClusterState(3) + target_state.node_metadata[0]["shape"] = "star" + target_state.set_position(1, {"x": 100.0, "y": 200.0}) + target_json_str = target_state.to_json() + + # 1. Test loading via JSON text input box + with patch("components.components_2d.action_panel.callback_context") as mock_ctx: + mock_ctx.triggered_id = "load-button" + res = load_graph( + _undo=None, + _ctrl_z=None, + _redo=None, + _ctrl_y=None, + _load=1, + _reset=None, + upload_contents=None, + upload_filename=None, + load_graph_input=target_json_str, + move_log=initial_log, + cyto_data=initial_elements, + history_data=initial_history.copy(), + ) + assert len(res) == 9 + cyto_data_new, repr_g, new_move_log, ui_msg, color, history_data_new, valid, invalid, feedback = res + assert valid is True + assert invalid is False + assert ui_msg == "Action completed!" + # Check that history_data has pushed the prior state onto the undo stack! + assert len(history_data_new["undo_stack"]) == 1 + assert history_data_new["undo_stack"][0]["move_log"] == initial_log + assert history_data_new["undo_stack"][0]["elements"] == initial_elements + + # 2. Test loading via file upload (dcc.Upload) + upload_contents_b64 = "data:application/json;base64," + base64.b64encode(target_json_str.encode("utf-8")).decode("utf-8") + with patch("components.components_2d.action_panel.callback_context") as mock_ctx: + mock_ctx.triggered_id = "upload-graph-json" + res = load_graph( + _undo=None, + _ctrl_z=None, + _redo=None, + _ctrl_y=None, + _load=None, + _reset=None, + upload_contents=upload_contents_b64, + upload_filename="test_graph.json", + load_graph_input="", + move_log=initial_log, + cyto_data=initial_elements, + history_data=initial_history.copy(), + ) + assert len(res) == 9 + cyto_data_new, repr_g, new_move_log, ui_msg, color, history_data_new, valid, invalid, feedback = res + assert valid is True + assert invalid is False + assert "Successfully loaded test_graph.json" in ui_msg + assert len(history_data_new["undo_stack"]) == 1 + assert history_data_new["undo_stack"][0]["move_log"] == initial_log + assert history_data_new["undo_stack"][0]["elements"] == initial_elements + + +def test_load_graph_exception_handling(): + from components.components_2d.action_panel import load_graph + from clustersim.simulator import FrontendClusterState + from unittest.mock import patch + import base64 + + initial_state = FrontendClusterState(2) + initial_elements = initial_state.to_cytoscape_elements() + initial_log = "original log content" + initial_history = {"undo_stack": [], "redo_stack": []} + + # 1. Test empty load input (should succeed and result in a valid empty graph) + with patch("components.components_2d.action_panel.callback_context") as mock_ctx: + mock_ctx.triggered_id = "load-button" + res = load_graph( + _undo=None, + _ctrl_z=None, + _redo=None, + _ctrl_y=None, + _load=1, + _reset=None, + upload_contents=None, + upload_filename=None, + load_graph_input="", + move_log=initial_log, + cyto_data=initial_elements, + history_data=initial_history.copy(), + ) + cyto_data_new, repr_g, new_move_log, ui_msg, color, history_data_new, valid, invalid, feedback = res + assert invalid is False + assert valid is True + assert cyto_data_new == [] + + # 2. Test invalid/malformed JSON upload (triggers IndexError or parse error) + # The JSON state has non-contiguous node IDs: 0, 2, 3, which induces dynamic expansion and IndexError in node_metadata. + non_contiguous_json = ( + '{"cluster_state_json": "{\\"directed\\":false,\\"multigraph\\":false,\\"attrs\\":null,' + '\\"nodes\\":[{\\"id\\":0,\\"data\\":{\\"id\\":\\"0\\",\\"vop\\":\\"IA\\"}},' + '{\\"id\\":2,\\"data\\":{\\"id\\":\\"1\\",\\"vop\\":\\"IA\\"}},' + '{\\"id\\":3,\\"data\\":{\\"id\\":\\"2\\",\\"vop\\":\\"IA\\"}}],\\"links\\":[]}",' + '"node_metadata": [{}, {}, {}], "groups": {}}' + ) + upload_contents_b64 = "data:application/json;base64," + base64.b64encode(non_contiguous_json.encode("utf-8")).decode("utf-8") + with patch("components.components_2d.action_panel.callback_context") as mock_ctx: + mock_ctx.triggered_id = "upload-graph-json" + res = load_graph( + _undo=None, + _ctrl_z=None, + _redo=None, + _ctrl_y=None, + _load=None, + _reset=None, + upload_contents=upload_contents_b64, + upload_filename="test_graph.json", + load_graph_input="", + move_log=initial_log, + cyto_data=initial_elements, + history_data=initial_history.copy(), + ) + cyto_data_new, repr_g, new_move_log, ui_msg, color, history_data_new, valid, invalid, feedback = res + assert invalid is True + assert valid is False + assert "Failed to load file" in ui_msg + assert "list index out of range" in feedback + + +def test_handle_buttons_edge_grouped_nodes(): + from components.components_2d.action_panel import handle_buttons, button_operations, KEY_MAPPINGS + from clustersim.simulator import FrontendClusterState + from unittest.mock import patch + + state = FrontendClusterState(3) + state.add_group("group_alpha", label="Alpha Group") + state.set_group(0, "group_alpha") + state.set_group(1, "group_alpha") + + cyto_data = state.to_cytoscape_elements() + + # Edge between grouped nodes: '0_group_alpha' and '1_group_alpha' + selected_edge_data = [ + {"source": "0_group_alpha", "target": "1_group_alpha"} + ] + + with patch("components.components_2d.action_panel.callback_context") as mock_ctx: + mock_ctx.triggered_id = "toggle-edge" + num_inputs = len(button_operations) + len(KEY_MAPPINGS) + 1 + args = [0] * num_inputs + [ + [], # clipboard_data + {"undo_stack": [], "redo_stack": []}, # history_data + "success", # fusion_mode_val + 0, # fusion_force_val + 0, # force_meas_val + "", # log + [], # selected_node_data (empty, so it falls back to edge) + selected_edge_data, + cyto_data, + ] + + res = handle_buttons(*args) + ui, color, cyto_data_new, repr_g, log, history_data = res + assert "Applied toggle_edge" in ui + + +def test_load_graph_reset_button(): + from components.components_2d.action_panel import load_graph + from clustersim.simulator import FrontendClusterState + from unittest.mock import patch + + initial_state = FrontendClusterState(2) + initial_elements = initial_state.to_cytoscape_elements() + initial_log = "original log content" + initial_history = {"undo_stack": [], "redo_stack": []} + + # Test loading via reset-button triggers load of empty graph + with patch("components.components_2d.action_panel.callback_context") as mock_ctx: + mock_ctx.triggered_id = "reset-button" + res = load_graph( + _undo=None, + _ctrl_z=None, + _redo=None, + _ctrl_y=None, + _load=None, + _reset=1, + upload_contents=None, + upload_filename=None, + load_graph_input="some previous text input", # Should be ignored and treated as "" + move_log=initial_log, + cyto_data=initial_elements, + history_data=initial_history.copy(), + ) + cyto_data_new, repr_g, new_move_log, ui_msg, color, history_data_new, valid, invalid, feedback = res + assert invalid is False + assert valid is True + assert cyto_data_new == [] + + +def test_move_log_uses_ids_not_values(): + from components.components_2d.action_panel import handle_buttons, button_operations, KEY_MAPPINGS + from clustersim.simulator import FrontendClusterState + from unittest.mock import patch + + state = FrontendClusterState(3) + state.set_position(0, {"x": 10, "y": 20}) + state.set_position(1, {"x": 30, "y": 40}) + state.set_position(2, {"x": 50, "y": 60}) + + v0 = state.node_metadata[0]["value"] + v1 = state.node_metadata[1]["value"] + + cyto_data = state.to_cytoscape_elements() + + selected_node_data = [ + {"id": "0", "value": v0}, + {"id": "1", "value": v1}, + ] + + with patch("components.components_2d.action_panel.callback_context") as mock_ctx: + mock_ctx.triggered_id = "CZ" + num_inputs = len(button_operations) + len(KEY_MAPPINGS) + 1 + args = [0] * num_inputs + [ + [], # clipboard_data + {"undo_stack": [], "redo_stack": []}, # history_data + "success", # fusion_mode_val + 0, # fusion_force_val + 0, # force_meas_val + "", # log + selected_node_data, + [], # selected_edge_data + cyto_data, + ] + + res = handle_buttons(*args) + ui, color, cyto_data_new, repr_g, log, history_data = res + assert "CZ 0 1\n" in log + assert v0 not in log + assert v1 not in log + + + + + diff --git a/tests/test_frontend_cluster_state.py b/tests/test_frontend_cluster_state.py new file mode 100644 index 0000000..c1a5931 --- /dev/null +++ b/tests/test_frontend_cluster_state.py @@ -0,0 +1,284 @@ +import pytest +from clustersim.simulator import ClusterState, FrontendClusterState + + +def test_init_and_gate_delegation(): + state = FrontendClusterState(3) + assert len(state) == 3 + assert state.num_nodes == 3 + + state.H(0) + state.H(1) + state.CZ(0, 1) + + assert state.stabilizers == ["+XZI", "+ZXI", "+IIZ"] + + state.MX(2, force=1) + assert len(state.stabilizers) == 3 + + +def test_node_removal_metadata_transfer(): + state = FrontendClusterState(3) + + pos0 = {"x": 10.0, "y": 20.0} + pos1 = {"x": 30.0, "y": 40.0} + pos2 = {"x": 50.0, "y": 60.0} + + state.set_position(0, pos0) + state.set_position(1, pos1) + state.set_position(2, pos2) + + state.set_group(2, "group_alpha") + state.set_value(2, "custom_node_2") + + # Remove node 1 + state.remove_node(1) + + assert len(state) == 2 + + # Node 0 retains pos0 + assert state.get_position(0) == pos0 + + # Node 1 (former node 2) MUST retain pos2, group_alpha, and custom_node_2 + assert state.get_position(1) == pos2 + assert state.get_group(1) == "group_alpha" + assert state.get_value(1) == "custom_node_2" + + +def test_add_node_with_metadata(): + state = FrontendClusterState(2) + + state.add_group("group_beta", label="Beta Group") + new_idx = state.add_node( + vop="IA", + position={"x": 100.0, "y": 200.0}, + group="group_beta", + value="node_2_val", + ) + + assert new_idx == 2 + assert len(state) == 3 + assert state.get_position(2) == {"x": 100.0, "y": 200.0} + assert state.get_group(2) == "group_beta" + assert state.get_value(2) == "node_2_val" + + +def test_undo_redo_snapshots(): + state = FrontendClusterState(2) + state.set_position(0, {"x": 0.0, "y": 0.0}) + state.set_position(1, {"x": 10.0, "y": 10.0}) + + # Save checkpoint 1 + state.push_state() + + # Perform action: add node 2 + state.add_node(position={"x": 50.0, "y": 50.0}) + assert len(state) == 3 + + # Save checkpoint 2 + state.push_state() + + # Perform action: remove node 0 + state.remove_node(0) + assert len(state) == 2 + + # Undo removal -> back to 3 nodes + assert state.undo() is True + assert len(state) == 3 + assert state.get_position(2) == {"x": 50.0, "y": 50.0} + + # Undo add node -> back to 2 nodes + assert state.undo() is True + assert len(state) == 2 + + # Redo -> back to 3 nodes + assert state.redo() is True + assert len(state) == 3 + + +def test_cytoscape_elements_roundtrip(): + state = FrontendClusterState(3) + state.add_group("group_main", label="Main Container") + state.set_group(0, "group_main") + state.set_group(1, "group_main") + state.set_position(0, {"x": 10.0, "y": 15.0}) + state.set_position(1, {"x": 20.0, "y": 25.0}) + state.set_position(2, {"x": 30.0, "y": 35.0}) + + state.CZ(0, 1) + state.CZ(1, 2) + + elements = state.to_cytoscape_elements() + + # Verify group element exists with value = None + group_elems = [e for e in elements if e.get("data", {}).get("id") == "group_main"] + assert len(group_elems) == 1 + assert isinstance(group_elems[0]["data"]["value"], str) + + # Reconstruct from elements + reconstructed = FrontendClusterState.from_cytoscape_elements(elements) + + assert len(reconstructed) == 3 + assert reconstructed.get_position(0) == {"x": 10.0, "y": 15.0} + assert reconstructed.get_group(0) == "group_main" + assert reconstructed.get_group(1) == "group_main" + assert reconstructed.get_group(2) is None + assert reconstructed.cluster_state == state.cluster_state + + +def test_json_serialization(): + state = FrontendClusterState(3) + state.add_group("g1", label="Group 1") + state.set_group(0, "g1") + state.set_position(0, {"x": 5.0, "y": 5.0}) + + json_str = state.to_json() + reconstructed = FrontendClusterState.from_json(json_str) + + assert reconstructed == state + + +def test_remove_node_by_position_id(): + state = FrontendClusterState(3) + state.set_position(0, {"x": 0.0, "y": 0.0}) + state.set_position(1, {"x": 100.0, "y": 100.0}) + state.set_position(2, {"x": 200.0, "y": 200.0}) + + # User selects node 1 on screen (position 100, 100) and deletes it + removed = state.remove_node("1") + assert removed == [1] + assert len(state) == 2 + + # Verify exported elements: + # Cytoscape ID "0" has pos (0, 0) + # Cytoscape ID "1" has pos (200, 200) and value=2 + elements = state.to_cytoscape_elements() + n0 = next(e for e in elements if e.get("data", {}).get("id") in (0, "0")) + n1 = next(e for e in elements if e.get("data", {}).get("id") in (1, "1")) + + assert n0["position"] == {"x": 0.0, "y": 0.0} + assert n1["position"] == {"x": 200.0, "y": 200.0} + assert isinstance(n1["data"]["value"], str) + + # User highlights node at position (200, 200) which has Cytoscape ID "1" and deletes it + removed2 = state.remove_node("1") + assert removed2 == [1] + assert len(state) == 1 + + # Remaining node is at (0, 0) + elements2 = state.to_cytoscape_elements() + assert len(elements2) == 1 + assert elements2[0]["position"] == {"x": 0.0, "y": 0.0} + + +def test_group_size_update_on_node_removal(): + state = FrontendClusterState(4) + state.add_group("group_A", label="Group A") + state.set_group(1, "group_A") + state.set_group(2, "group_A") + + # Initial check: group_A has 2 children + elems = state.to_cytoscape_elements() + grp_children = [e for e in elems if e.get("data", {}).get("parent") == "group_A"] + assert len(grp_children) == 2 + + # 1. Remove Node 0 (outside group_A) -> group size remains 2 + state.remove_node("0") + elems1 = state.to_cytoscape_elements() + grp_children1 = [e for e in elems1 if e.get("data", {}).get("parent") == "group_A"] + assert len(grp_children1) == 2 + + # 2. Reconstruct from elems1 and remove Node 0 (inside group_A) -> group size decreases by 1 + state2 = FrontendClusterState.from_cytoscape_elements(elems1) + state2.remove_node("0") + elems2 = state2.to_cytoscape_elements() + grp_children2 = [e for e in elems2 if e.get("data", {}).get("parent") == "group_A"] + assert len(grp_children2) == 1 + + +def test_quantum_operations_with_string_identifiers(): + state = FrontendClusterState(3) + state.H("0") + state.H("1") + state.CZ("0", "1") + state.toggle_edge("0", "1") + state.add_edge("1", "2") + state.remove_edge("1", "2") + res = state.MZ("2", force=1) + assert res == 0 + + +def test_explicit_parent_none_in_cytoscape_elements(): + state = FrontendClusterState(2) + state.add_group("g1") + state.set_group(1, "g1") + + elems = state.to_cytoscape_elements() + n0 = next(e for e in elems if str(e.get("data", {}).get("id")).startswith("0")) + n1 = next(e for e in elems if str(e.get("data", {}).get("id")).startswith("1")) + + assert n0["data"].get("parent") is None + assert n1["data"]["parent"] == "g1" + + +def test_undo_group_creation(): + state = FrontendClusterState(2) + state.set_position(0, {"x": 0.0, "y": 0.0}) + state.set_position(1, {"x": 100.0, "y": 100.0}) + + # Save state before creating group + state.push_state() + + # Action: Create group for node 1 + state.add_group("group_123", label="My Group") + state.set_group(1, "group_123") + + # Verify group exists + assert state.get_group(1) == "group_123" + assert "group_123" in state.groups + + # Undo group creation + assert state.undo() is True + + # Group should be undone! + assert state.get_group(1) is None + assert "group_123" not in state.groups + assert len(state) == 2 + + +def test_custom_node_shape_export_and_reconstruction(): + state = FrontendClusterState(2) + state.node_metadata[0]["shape"] = "star" + state.node_metadata[1]["shape"] = "diamond" + + elems = state.to_cytoscape_elements() + n0 = next(e for e in elems if e.get("data", {}).get("id") in (0, "0")) + n1 = next(e for e in elems if e.get("data", {}).get("id") in (1, "1")) + + assert n0["data"]["shape"] == "star" + assert n1["data"]["shape"] == "diamond" + + reconstructed = FrontendClusterState.from_cytoscape_elements(elems) + assert reconstructed.node_metadata[0]["shape"] == "star" + assert reconstructed.node_metadata[1]["shape"] == "diamond" + + +def test_json_serialization_deserialization(): + state = FrontendClusterState(3) + state.node_metadata[0]["shape"] = "star" + state.set_position(1, {"x": 12.3, "y": 45.6}) + state.add_group("group_test", label="Test Group") + state.set_group(2, "group_test") + + # Serialize to JSON + json_str = state.to_json() + + # Deserialize from JSON + reconstructed = FrontendClusterState.from_json(json_str) + + assert len(reconstructed) == 3 + assert reconstructed.node_metadata[0]["shape"] == "star" + assert reconstructed.get_position(1) == {"x": 12.3, "y": 45.6} + assert reconstructed.get_group(2) == "group_test" + assert "group_test" in reconstructed.groups + assert reconstructed.groups["group_test"]["label"] == "Test Group" diff --git a/tests/test_simulator.py b/tests/test_simulator.py index 5e6d223..a90bc19 100644 --- a/tests/test_simulator.py +++ b/tests/test_simulator.py @@ -173,6 +173,10 @@ def random_graph_program(draw): return "\n".join(instructions) + "\n" +from hypothesis import settings + + +@settings(deadline=None) @given(program=random_graph_program()) def test_load_undo(program): """