From c8bc27ad9fac2183dce49fcada7ba0060b1757f0 Mon Sep 17 00:00:00 2001 From: Zhihua Han Date: Sat, 4 Jul 2026 13:13:39 +0200 Subject: [PATCH 01/16] wip: graph customization --- graph2d_app.py | 3 +- src/clustersim/simulator/__init__.py | 3 +- .../simulator/frontend_cluster_state.py | 572 ++++++++++++++++++ src/components/components_2d/__init__.py | 3 + src/components/components_2d/action_panel.py | 198 +++--- .../components_2d/cytoscape_panel.py | 207 +++++++ src/components/components_2d/figure_2d.py | 43 +- tests/test_frontend_cluster_state.py | 208 +++++++ tests/test_simulator.py | 3 + 9 files changed, 1146 insertions(+), 94 deletions(-) create mode 100644 src/clustersim/simulator/frontend_cluster_state.py create mode 100644 src/components/components_2d/cytoscape_panel.py create mode 100644 tests/test_frontend_cluster_state.py diff --git a/graph2d_app.py b/graph2d_app.py index b950fcd..21be60b 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 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/frontend_cluster_state.py b/src/clustersim/simulator/frontend_cluster_state.py new file mode 100644 index 0000000..f5c7e24 --- /dev/null +++ b/src/clustersim/simulator/frontend_cluster_state.py @@ -0,0 +1,572 @@ +from __future__ import annotations +import random +import copy +import json +from typing import Any, Dict, List, Optional, Tuple, Set, Union +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]] = [] + + for i in range(n): + pos = {"x": 0.0, "y": 0.0} + if positions is not None: + if isinstance(positions, list) and i < len(positions): + pos = dict(positions[i]) + elif isinstance(positions, dict) and i in positions: + pos = dict(positions[i]) + else: + pos = {"x": float(random.randint(0, 300)), "y": float(random.randint(0, 300))} + else: + pos = {"x": float(random.randint(0, 300)), "y": float(random.randint(0, 300))} + + self.node_metadata.append( + { + "position": pos, + "parent": None, + "value": i, + "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 + ) + + # ------------------------------------------------------------------ + # 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: + q = self.resolve_qubit(qubit) + return self.cluster_state.measure(q if q is not None else int(qubit), force=force, basis=basis) + + def MX(self, qubit: Union[int, str], force: int = -1) -> int: + q = self.resolve_qubit(qubit) + return self.cluster_state.MX(q if q is not None else int(qubit), force=force) + + def MY(self, qubit: Union[int, str], force: int = -1) -> int: + q = self.resolve_qubit(qubit) + return self.cluster_state.MY(q if q is not None else int(qubit), force=force) + + def MZ(self, qubit: Union[int, str], force: int = -1) -> int: + q = self.resolve_qubit(qubit) + return self.cluster_state.MZ(q if q is not None else int(qubit), force=force) + + def M(self, qubit: Union[int, str], force: int = -1) -> int: + q = self.resolve_qubit(qubit) + return self.cluster_state.M(q if q is not None else int(qubit), force=force) + + def I(self, qubit: Union[int, str]) -> None: + q = self.resolve_qubit(qubit) + self.cluster_state.I(q if q is not None else int(qubit)) + + def X(self, qubit: Union[int, str]) -> None: + q = self.resolve_qubit(qubit) + self.cluster_state.X(q if q is not None else int(qubit)) + + def Y(self, qubit: Union[int, str]) -> None: + q = self.resolve_qubit(qubit) + self.cluster_state.Y(q if q is not None else int(qubit)) + + def Z(self, qubit: Union[int, str]) -> None: + q = self.resolve_qubit(qubit) + self.cluster_state.Z(q if q is not None else int(qubit)) + + def H(self, qubit: Union[int, str]) -> None: + q = self.resolve_qubit(qubit) + self.cluster_state.H(q if q is not None else int(qubit)) + + def S(self, qubit: Union[int, str]) -> None: + q = self.resolve_qubit(qubit) + self.cluster_state.S(q if q is not None else int(qubit)) + + def SH(self, qubit: Union[int, str]) -> None: + q = self.resolve_qubit(qubit) + self.cluster_state.SH(q if q is not None else int(qubit)) + + def S_DAG(self, qubit: Union[int, str]) -> None: + q = self.resolve_qubit(qubit) + self.cluster_state.S_DAG(q if q is not None else int(qubit)) + + def CX(self, control: Union[int, str], qubit: Union[int, str]) -> None: + c = self.resolve_qubit(control) + q = self.resolve_qubit(qubit) + self.cluster_state.CX(c if c is not None else int(control), q if q is not None else int(qubit)) + + def CZ(self, control: Union[int, str], qubit: Union[int, str]) -> None: + c = self.resolve_qubit(control) + q = self.resolve_qubit(qubit) + self.cluster_state.CZ(c if c is not None else int(control), q if q is not None else int(qubit)) + + def fusion_gate( + self, + qubit1: Union[int, str], + qubit2: Union[int, str], + fusion_type: str = "XXZZ", + mode: str = "success", + force: int = 0, + ) -> None: + q1 = self.resolve_qubit(qubit1) + q2 = self.resolve_qubit(qubit2) + self.cluster_state.fusion_gate( + q1 if q1 is not None else int(qubit1), + q2 if q2 is not None else int(qubit2), + fusion_type=fusion_type, + mode=mode, + force=force, + ) + + def local_complementation(self, qubit: Union[int, str]) -> None: + q = self.resolve_qubit(qubit) + self.cluster_state.local_complementation(q if q is not None else int(qubit)) + + def local_complementation_rewrite(self, qubit: Union[int, str]) -> None: + q = self.resolve_qubit(qubit) + self.cluster_state.local_complementation_rewrite(q if q is not None else int(qubit)) + + def apply_VOP(self, qubit: Union[int, str], vop: Union[Tuple[int, int], int, str]) -> None: + q = self.resolve_qubit(qubit) + self.cluster_state.apply_VOP(q if q is not None else int(qubit), vop) + + def add_edge(self, qubit1: Union[int, str], qubit2: Union[int, str]) -> None: + q1 = self.resolve_qubit(qubit1) + q2 = self.resolve_qubit(qubit2) + self.cluster_state.add_edge(q1 if q1 is not None else int(qubit1), q2 if q2 is not None else int(qubit2)) + + def remove_edge(self, qubit1: Union[int, str], qubit2: Union[int, str]) -> None: + q1 = self.resolve_qubit(qubit1) + q2 = self.resolve_qubit(qubit2) + self.cluster_state.remove_edge(q1 if q1 is not None else int(qubit1), q2 if q2 is not None else int(qubit2)) + + def toggle_edge(self, qubit1: Union[int, str], qubit2: Union[int, str]) -> None: + q1 = self.resolve_qubit(qubit1) + q2 = self.resolve_qubit(qubit2) + self.cluster_state.toggle_edge(q1 if q1 is not None else int(qubit1), q2 if q2 is not None else int(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 = idx + + 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). + """ + # 1. If identifier is string digit matching Cytoscape element id str(i) + if isinstance(identifier, str) and identifier.isdigit(): + q = int(identifier) + if 0 <= q < len(self): + return q + + # 2. Check if integer identifier matches persistent value attribute + for i, meta in enumerate(self.node_metadata): + if meta.get("value") == identifier or str(meta.get("value")) == str(identifier): + return i + + # 3. 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 + new_metadata = [] + for i, meta in enumerate(self.node_metadata): + if i not in qubits_to_remove: + new_metadata.append(meta) + + self.node_metadata = new_metadata + return resolved_qubits + + # ------------------------------------------------------------------ + # Frontend Metadata & Group Management + # ------------------------------------------------------------------ + + def get_position(self, qubit: int) -> Dict[str, float]: + return self.node_metadata[qubit]["position"] + + def set_position(self, qubit: int, position: Dict[str, float]) -> None: + self.node_metadata[qubit]["position"] = dict(position) + + def get_group(self, qubit: int) -> Optional[str]: + return self.node_metadata[qubit]["parent"] + + def set_group(self, qubit: int, group_id: Optional[str]) -> None: + if group_id is not None and group_id not in self.groups: + self.add_group(group_id) + self.node_metadata[qubit]["parent"] = group_id + + def get_value(self, qubit: int) -> Any: + return self.node_metadata[qubit]["value"] + + def set_value(self, qubit: int, value: Any) -> None: + self.node_metadata[qubit]["value"] = value + + def add_group( + self, + group_id: str, + label: Optional[str] = None, + style: Optional[Dict[str, Any]] = None, + ) -> None: + """Add or update a compound parent group container.""" + if label is None: + label = group_id + if style is None: + style = {} + self.groups[group_id] = {"label": label, "style": style} + + 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. Compound Parent Group Container Nodes (value = None) + for group_id, group_info in self.groups.items(): + elements.append( + { + "data": { + "id": str(group_id), + "label": group_info.get("label", str(group_id)), + "value": None, + }, + "classes": "group container", + } + ) + + # 2. Qubit Nodes + for i in range(len(self)): + meta = self.node_metadata[i] + node_data = { + "id": str(i), + "label": meta.get("label", str(i)), + "value": meta.get("value", i), + "vop": self.vertex_operators[i], + } + if meta.get("parent"): + node_data["parent"] = str(meta["parent"]) + + 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) + + # 3. Edges between Qubit Nodes + for u, neighbors in enumerate(self.adjacency_list): + for v in neighbors: + if u < v: + elements.append( + { + "data": { + "source": str(u), + "target": str(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 str(data.get("id", "")).isdigit()): + node_elements.append(item) + elif "id" in data: + # Group container node + group_id = str(data["id"]) + groups[group_id] = { + "label": data.get("label", group_id), + "style": item.get("style", {}), + } + + # Sort qubit nodes by integer ID + def get_node_id(elem): + try: + return int(elem["data"]["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: + u = int(edge["data"]["source"]) + v = int(edge["data"]["target"]) + 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}) + frontend_state.node_metadata[i] = { + "position": dict(pos), + "parent": data.get("parent"), + "value": data.get("value", i), + "label": data.get("label", str(i)), + "classes": elem.get("classes", ""), + "style": elem.get("style", {}), + } + + 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..5bc8028 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,6 @@ "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..4cbe176 100644 --- a/src/components/components_2d/action_panel.py +++ b/src/components/components_2d/action_panel.py @@ -1,5 +1,5 @@ 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 @@ -309,12 +309,19 @@ ], width=3, ), - # 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.2rem", "padding": "2px 8px", "marginBottom": "4px"}, + title="Plot Customization Options", + ), dbc.Button( "⚙", id="open-modal-btn", @@ -324,7 +331,7 @@ 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, @@ -427,9 +434,8 @@ def handle_buttons(*args): "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}) + # Record the current elements state in history for Undo/Redo + history_data["undo_stack"].append({"move_log": log, "elements": cyto_data}) history_data["redo_stack"] = [] # Determine which button triggered the callback @@ -473,7 +479,12 @@ def handle_buttons(*args): elif method_name == "measure": method_args["force"] = int(args[-5]) - selected_nodes = [i["value"] for i in selected_node_data] + selected_nodes = [] + for i in selected_node_data: + if i.get("id") is not None and str(i["id"]).isdigit(): + selected_nodes.append(str(i["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) @@ -512,7 +523,7 @@ def apply_operation_wrapper( 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,13 +533,11 @@ 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) 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) + getattr(state, method_name)(*pair, **method_args) ui = f"Applied {method_name} to {selected_nodes}" elif method_name in ["CX", "CZ"]: for pair in itertools.batched(selected_nodes, n=2): @@ -540,7 +549,7 @@ def apply_operation_wrapper( 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}" elif method_name in [ @@ -554,13 +563,13 @@ def apply_operation_wrapper( "S_DAG", ]: for i in selected_nodes: - getattr(g, method_name)(i, **method_args) + getattr(state, method_name)(i, **method_args) ui = f"Applied {method_name} to {selected_nodes}" elif method_name in ["measure"]: outcomes = [] for i in selected_nodes: - outcomes.append(getattr(g, method_name)(i, **method_args)) + outcomes.append(getattr(state, method_name)(i, **method_args)) ui = f"Measured selected nodes {selected_nodes} with outcomes {outcomes}" elif method_name in ["fusion_gate"]: @@ -573,80 +582,53 @@ def apply_operation_wrapper( 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}" 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) + for q in selected_nodes: + if q < len(state): + selected_positions.append(state.get_position(q)) 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 neighbour < new_node_id: + state.add_edge(new_node_id, neighbour) if selected_nodes: ui = f"Added node {new_node_id} with edges to {selected_nodes}!" else: ui = f"Added node {new_node_id}!" elif method_name in ["remove_node"]: - getattr(g, method_name)(selected_nodes, **method_args) + state.remove_node(selected_nodes) 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) - - cyto_data_new = g.to_cytoscape(export_elements=True) - cyto_data_new = postprocess_cyto_data_elements(cyto_data_new, new_positions) - log += f"REMOVE_NODE {' '.join(map(str, selected_nodes))}\n" - - return ui, cyto_data_new, repr(g), log, history_data + cyto_data_new = state.to_cytoscape_elements() + return ui, cyto_data_new, repr(state.cluster_state), log, history_data 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}) + for q in selected_nodes: + if q < len(state): + pos = state.get_position(q) + state.add_node(position={"x": pos["x"] + 50, "y": pos["y"] + 50}) - g = getattr(g, method_name)(selected_nodes, **method_args) + state.cluster_state = state.cluster_state.duplicate(selected_nodes) ui = f"Duplicated nodes {selected_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() if method_name == "measure": log += f"M{method_args['basis']}" @@ -661,14 +643,14 @@ def apply_operation_wrapper( elif method_name == "local_complementation_rewrite": log += f"LCR {' '.join(map(str, selected_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: log += f"ADD_EDGE {new_node_id} {neighbour}\n" else: log += f"{method_name.upper()} {' '.join(map(str, selected_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( @@ -680,12 +662,15 @@ def preprocess_cyto_data_elements( 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"]) + nodes.append(item) # This is a quantum qubit node + positions.append(item.get("position", {"x": 0.0, "y": 0.0})) elif item["data"].get("source"): edges.append(item) # This is an edge + else: + # Compound group container node (no vop, no source) + pass else: - raise NotImplementedError("Unknown or no data") + pass return { "data": [], "multigraph": False, @@ -700,8 +685,6 @@ 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 @@ -770,6 +753,19 @@ def load_graph( "Cannot load empty input!", ) + 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, + "", + ) + if triggered_id == "undo-button": if not history_data["undo_stack"]: return ( @@ -787,14 +783,23 @@ def load_graph( 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} + {"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) + cyto_data_new = prev_state["elements"] + state = FrontendClusterState.from_cytoscape_elements(cyto_data_new) + return ( + cyto_data_new, + repr(state.cluster_state), + move_log, + "Action completed!", + history_data, + valid, + invalid, + feedback, + ) elif triggered_id == "redo-button": if not history_data["redo_stack"]: @@ -813,14 +818,23 @@ def load_graph( 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} + {"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) + cyto_data_new = next_state["elements"] + state = FrontendClusterState.from_cytoscape_elements(cyto_data_new) + return ( + cyto_data_new, + repr(state.cluster_state), + move_log, + "Action completed!", + history_data, + valid, + invalid, + feedback, + ) elif triggered_id == "load-button": try: @@ -840,23 +854,26 @@ def load_graph( 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)] + state = FrontendClusterState(num_nodes=len(g), cluster_state=g) move_log = parsed_log - # Clear history on new graph load history_data = {"undo_stack": [], "redo_stack": []} + cyto_data_new = state.to_cytoscape_elements() - cyto_data_new = g.to_cytoscape(export_elements=True) - cyto_data_new = postprocess_cyto_data_elements(cyto_data_new, positions) + return ( + cyto_data_new, + repr(g), + move_log, + "Action completed!", + history_data, + valid, + invalid, + feedback, + ) return ( - cyto_data_new, - repr(g), - move_log, + no_update, + no_update, + no_update, "Action completed!", history_data, valid, @@ -872,11 +889,10 @@ 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( diff --git a/src/components/components_2d/cytoscape_panel.py b/src/components/components_2d/cytoscape_panel.py new file mode 100644 index 0000000..d71c21c --- /dev/null +++ b/src/components/components_2d/cytoscape_panel.py @@ -0,0 +1,207 @@ +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"}, +] + +# Offcanvas UI component definition +cytoscape_offcanvas = dbc.Offcanvas( + [ + html.Div( + [ + html.Label("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-3", + ), + 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"), + ], + className="mb-4", + ), + 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.Button("Delete Selected Group", id="remove-group-btn", color="primary", size="sm", className="w-100 mb-4"), + ], + className="d-flex flex-column", + ), + ], + id="cytoscape-offcanvas", + title="Plot 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"), + Input("open-offcanvas-btn-tab5", "n_clicks"), + State("cytoscape-offcanvas", "is_open"), + prevent_initial_call=True, +) +def toggle_offcanvas(n1, n2, is_open): + if n1 or n2: + return not is_open + return is_open + + +@callback( + Output("custom-label-input", "value"), + Output("node-shape-select", "value"), + 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 = "ellipse" + + 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", "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 + + +@callback( + Output("figure-app", "elements", allow_duplicate=True), + Input("apply-label-btn", "n_clicks"), + Input("apply-shape-btn", "n_clicks"), + Input("create-group-btn", "n_clicks"), + Input("remove-group-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"), + prevent_initial_call=True, +) +def handle_cytoscape_options( + btn_label, + btn_shape, + btn_create_group, + btn_remove_group, + elements, + selected_nodes, + selected_edges, + custom_label, + node_shape, +): + ctx = callback_context + if not ctx.triggered or not elements: + return no_update + + trigger_id = ctx.triggered[0]["prop_id"].split(".")[0] + lbl_text = custom_label.strip() if custom_label else "" + + state = FrontendClusterState.from_cytoscape_elements(elements) + + if trigger_id == "apply-label-btn": + if selected_nodes: + selected_ids = {str(n["id"]) for n in selected_nodes} + for i in range(len(state)): + if str(i) in selected_ids: + state.node_metadata[i]["label"] = lbl_text if lbl_text else str(state.get_value(i)) + for g_id in state.groups: + if g_id in selected_ids and lbl_text: + state.groups[g_id]["label"] = lbl_text + elif selected_edges: + # edge label update in elements + pass + + elif trigger_id == "apply-shape-btn": + if selected_nodes: + selected_ids = {str(n["id"]) for n in selected_nodes} + for i in range(len(state)): + if str(i) in selected_ids: + if "style" not in state.node_metadata[i]: + state.node_metadata[i]["style"] = {} + state.node_metadata[i]["style"]["shape"] = node_shape + + elif trigger_id == "create-group-btn": + if not selected_nodes: + return no_update + selected_qubits = [] + for n in selected_nodes: + try: + selected_qubits.append(int(n["id"])) + except (ValueError, KeyError): + pass + + if not selected_qubits: + return no_update + + g_id = f"group_{uuid.uuid4().hex[:8]}" + g_label = lbl_text if lbl_text else f"Group {g_id[-8:]}" + state.add_group(g_id, label=g_label) + for q in selected_qubits: + if q < len(state): + state.set_group(q, g_id) + + elif trigger_id == "remove-group-btn": + if not selected_nodes: + return no_update + groups_to_remove = set() + for n in selected_nodes: + nid = str(n["id"]) + if nid in state.groups: + groups_to_remove.add(nid) + else: + try: + q = int(nid) + grp = state.get_group(q) + if grp: + groups_to_remove.add(grp) + except ValueError: + pass + + for g_id in groups_to_remove: + state.remove_group(g_id, unassign_children=True) + + return state.to_cytoscape_elements() diff --git a/src/components/components_2d/figure_2d.py b/src/components/components_2d/figure_2d.py index c8f29b6..e735050 100644 --- a/src/components/components_2d/figure_2d.py +++ b/src/components/components_2d/figure_2d.py @@ -122,6 +122,7 @@ def _init_state(): dbc.Col( [ dcc.Markdown("**Plot Options**", style={"margin": "0 0 4px 0"}), + dbc.Button("Customize Plot", id="open-offcanvas-btn-tab5", color="secondary", outline=True, style={"width": "100%", "marginBottom": "4px"}), 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"), @@ -448,7 +449,47 @@ def update_stylesheet(_, node_labels: str): } ] - return label_style + color_styles + selected_style + 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": "data(label)", + "font-size": "14px", + "font-weight": "bold", + "text-valign": "top", + "text-halign": "center", + "text-margin-y": -6, + }, + }, + { + "selector": "node:childless[!vop]", + "style": { + "display": "none", + }, + }, + ] + + return label_style + color_styles + selected_style + custom_styles @callback( diff --git a/tests/test_frontend_cluster_state.py b/tests/test_frontend_cluster_state.py new file mode 100644 index 0000000..1682909 --- /dev/null +++ b/tests/test_frontend_cluster_state.py @@ -0,0 +1,208 @@ +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 group_elems[0]["data"]["value"] is None + + # 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") == "0") + n1 = next(e for e in elements if e.get("data", {}).get("id") == "1") + + assert n0["position"] == {"x": 0.0, "y": 0.0} + assert n1["position"] == {"x": 200.0, "y": 200.0} + assert n1["data"]["value"] == 2 + + # 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 diff --git a/tests/test_simulator.py b/tests/test_simulator.py index 5e6d223..88f122c 100644 --- a/tests/test_simulator.py +++ b/tests/test_simulator.py @@ -173,6 +173,9 @@ 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): """ From 4aacf154c35d490e0af49bfe7e27eaf1674a3c37 Mon Sep 17 00:00:00 2001 From: Zhihua Han Date: Sat, 4 Jul 2026 14:06:42 +0200 Subject: [PATCH 02/16] fix: groups now working --- .../simulator/frontend_cluster_state.py | 66 ++++++--- src/components/components_2d/action_panel.py | 137 ++++++++++++------ .../components_2d/cytoscape_panel.py | 27 +++- src/components/components_2d/figure_2d.py | 24 ++- tests/test_app.py | 4 +- tests/test_frontend_cluster_state.py | 47 +++++- 6 files changed, 227 insertions(+), 78 deletions(-) diff --git a/src/clustersim/simulator/frontend_cluster_state.py b/src/clustersim/simulator/frontend_cluster_state.py index f5c7e24..4418893 100644 --- a/src/clustersim/simulator/frontend_cluster_state.py +++ b/src/clustersim/simulator/frontend_cluster_state.py @@ -3,6 +3,7 @@ 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 @@ -57,7 +58,7 @@ def __init__( { "position": pos, "parent": None, - "value": i, + "value": uuid.uuid4().hex[:8], "label": str(i), "classes": "", "style": {}, @@ -294,7 +295,7 @@ def add_node( position = {"x": float(random.randint(0, 300)), "y": float(random.randint(0, 300))} if value is None: - value = idx + value = uuid.uuid4().hex[:8] if label is None: label = str(idx) @@ -372,38 +373,59 @@ def remove_node(self, qubits: Union[int, str, List[Union[int, str]]]) -> List[in # Frontend Metadata & Group Management # ------------------------------------------------------------------ - def get_position(self, qubit: int) -> Dict[str, float]: - return self.node_metadata[qubit]["position"] + def get_position(self, qubit: Union[int, str]) -> Dict[str, float]: + q = self.resolve_qubit(qubit) + idx = q if q is not None else int(qubit) + return self.node_metadata[idx]["position"] - def set_position(self, qubit: int, position: Dict[str, float]) -> None: - self.node_metadata[qubit]["position"] = dict(position) + def set_position(self, qubit: Union[int, str], position: Dict[str, float]) -> None: + q = self.resolve_qubit(qubit) + idx = q if q is not None else int(qubit) + self.node_metadata[idx]["position"] = dict(position) - def get_group(self, qubit: int) -> Optional[str]: - return self.node_metadata[qubit]["parent"] + def get_group(self, qubit: Union[int, str]) -> Optional[str]: + q = self.resolve_qubit(qubit) + idx = q if q is not None else int(qubit) + return self.node_metadata[idx]["parent"] - def set_group(self, qubit: int, group_id: Optional[str]) -> None: + def set_group(self, qubit: Union[int, str], group_id: Optional[str]) -> None: + q = self.resolve_qubit(qubit) + idx = q if q is not None else int(qubit) if group_id is not None and group_id not in self.groups: self.add_group(group_id) - self.node_metadata[qubit]["parent"] = group_id + self.node_metadata[idx]["parent"] = group_id - def get_value(self, qubit: int) -> Any: - return self.node_metadata[qubit]["value"] + def get_value(self, qubit: Union[int, str]) -> Any: + q = self.resolve_qubit(qubit) + idx = q if q is not None else int(qubit) + return self.node_metadata[idx]["value"] - def set_value(self, qubit: int, value: Any) -> None: - self.node_metadata[qubit]["value"] = value + def set_value(self, qubit: Union[int, str], value: Any) -> None: + q = self.resolve_qubit(qubit) + idx = q if q is not None else int(qubit) + self.node_metadata[idx]["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 = group_id if style is None: style = {} - self.groups[group_id] = {"label": label, "style": 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 remove_group(self, group_id: str, unassign_children: bool = True) -> None: """Remove a compound group container.""" @@ -424,14 +446,18 @@ def to_cytoscape_elements(self) -> List[Dict[str, Any]]: """ elements: List[Dict[str, Any]] = [] - # 1. Compound Parent Group Container Nodes (value = None) + # 1. 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", str(group_id)), - "value": None, + "value": val, }, "classes": "group container", } @@ -448,6 +474,8 @@ def to_cytoscape_elements(self) -> List[Dict[str, Any]]: } if meta.get("parent"): node_data["parent"] = str(meta["parent"]) + else: + node_data["parent"] = None node_elem = { "data": node_data, @@ -494,9 +522,13 @@ def from_cytoscape_elements(cls, elements: List[Dict[str, Any]]) -> FrontendClus 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 diff --git a/src/components/components_2d/action_panel.py b/src/components/components_2d/action_panel.py index 4cbe176..714c1cf 100644 --- a/src/components/components_2d/action_panel.py +++ b/src/components/components_2d/action_panel.py @@ -425,6 +425,8 @@ def handle_buttons(*args): selected_edge_data = args[-2] cyto_data = args[-1] + state = FrontendClusterState.from_cytoscape_elements(cyto_data) + history_data = args[-8] if not history_data or not isinstance(history_data, dict): history_data = {"undo_stack": [], "redo_stack": []} @@ -435,7 +437,9 @@ def handle_buttons(*args): } # Record the current elements state in history for Undo/Redo - history_data["undo_stack"].append({"move_log": log, "elements": cyto_data}) + 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 @@ -481,8 +485,11 @@ def handle_buttons(*args): selected_nodes = [] for i in selected_node_data: - if i.get("id") is not None and str(i["id"]).isdigit(): - selected_nodes.append(str(i["id"])) + 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"]) @@ -534,11 +541,15 @@ def apply_operation_wrapper( _type_: processed cyto_data for use in figure-app.elements """ state = FrontendClusterState.from_cytoscape_elements(cyto_data) + display_nodes = [ + int(q) if (isinstance(q, int) or (isinstance(q, str) and q.isdigit())) else q + for q in selected_nodes + ] if method_name in ["add_edge", "remove_edge", "toggle_edge"]: for pair in itertools.combinations(selected_nodes, 2): 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 ["CX", "CZ"]: for pair in itertools.batched(selected_nodes, n=2): if len(pair) < 2: @@ -551,7 +562,7 @@ def apply_operation_wrapper( ) 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", @@ -565,13 +576,13 @@ def apply_operation_wrapper( for i in selected_nodes: 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(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): if len(pair) < 2: @@ -584,14 +595,17 @@ def apply_operation_wrapper( ) 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"]: selected_positions = [] + resolved_selected = [] if selected_nodes: for q in selected_nodes: - if q < len(state): - selected_positions.append(state.get_position(q)) + 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) @@ -603,27 +617,36 @@ def apply_operation_wrapper( new_node_id = state.add_node(position=new_pos, **method_args) # Connect the new node to every selected node - for neighbour in selected_nodes: + for neighbour in resolved_selected: if neighbour < new_node_id: state.add_edge(new_node_id, neighbour) if selected_nodes: - ui = f"Added node {new_node_id} with edges to {selected_nodes}!" + 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"]: - state.remove_node(selected_nodes) - ui = f"Removed nodes {selected_nodes}" + groups_to_remove = [q for q in selected_nodes if str(q) in state.groups] + qubits_to_remove = [q for q in selected_nodes if str(q) not in state.groups] + + for g_id in groups_to_remove: + state.remove_group(str(g_id), unassign_children=True) + + if qubits_to_remove: + state.remove_node(qubits_to_remove) + + ui = f"Removed nodes {display_nodes}" log += f"REMOVE_NODE {' '.join(map(str, selected_nodes))}\n" cyto_data_new = state.to_cytoscape_elements() return ui, cyto_data_new, repr(state.cluster_state), log, history_data elif method_name == "duplicate": - selected_nodes.sort() - for q in selected_nodes: + resolved_selected = [state.resolve_qubit(q) for q in selected_nodes if state.resolve_qubit(q) is not None] + resolved_selected.sort() + for q in resolved_selected: if q < len(state): pos = state.get_position(q) state.add_node(position={"x": pos["x"] + 50, "y": pos["y"] + 50}) - state.cluster_state = state.cluster_state.duplicate(selected_nodes) + state.cluster_state = state.cluster_state.duplicate(resolved_selected) ui = f"Duplicated nodes {selected_nodes}" else: raise NotImplementedError(f"Do not know {method_name}") @@ -753,19 +776,6 @@ def load_graph( "Cannot load empty input!", ) - 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, - "", - ) - if triggered_id == "undo-button": if not history_data["undo_stack"]: return ( @@ -784,22 +794,53 @@ def load_graph( # Save the current state to the redo stack history_data["redo_stack"].append( - {"move_log": move_log, "elements": cyto_data} + { + "action_type": prev_state.get("action_type", "quantum_operation"), + "move_log": move_log, + "elements": cyto_data, + } ) - move_log = prev_state["move_log"] + action_type = prev_state.get("action_type", "quantum_operation") + move_log = prev_state.get("move_log", move_log) cyto_data_new = prev_state["elements"] - state = FrontendClusterState.from_cytoscape_elements(cyto_data_new) - return ( - cyto_data_new, - repr(state.cluster_state), - move_log, - "Action completed!", - history_data, - valid, - invalid, - feedback, - ) + + 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 ( + cyto_data_new, + repr(state.cluster_state), + move_log, + "Undid plot customization!", + history_data, + valid, + invalid, + feedback, + ) + 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 ( + cyto_data_new, + repr(state.cluster_state), + move_log, + "Undid operation!", + history_data, + valid, + invalid, + feedback, + ) elif triggered_id == "redo-button": if not history_data["redo_stack"]: @@ -819,11 +860,17 @@ def load_graph( # Save the current state to the undo stack history_data["undo_stack"].append( - {"move_log": move_log, "elements": cyto_data} + { + "action_type": next_state.get("action_type", "quantum_operation"), + "move_log": move_log, + "elements": cyto_data, + } ) - move_log = next_state["move_log"] + 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 ( cyto_data_new, diff --git a/src/components/components_2d/cytoscape_panel.py b/src/components/components_2d/cytoscape_panel.py index d71c21c..04858bd 100644 --- a/src/components/components_2d/cytoscape_panel.py +++ b/src/components/components_2d/cytoscape_panel.py @@ -111,6 +111,7 @@ def update_offcanvas_inputs(selected_nodes, selected_edges, elements): @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"), @@ -120,6 +121,8 @@ def update_offcanvas_inputs(selected_nodes, selected_edges, elements): 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( @@ -132,10 +135,24 @@ def handle_cytoscape_options( selected_edges, custom_label, node_shape, + history_data, + move_log, ): ctx = callback_context if not ctx.triggered or not elements: - return no_update + return no_update, no_update + + 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 offcanvas customization + history_data["undo_stack"].append({"move_log": move_log or "", "elements": elements}) + history_data["redo_stack"] = [] trigger_id = ctx.triggered[0]["prop_id"].split(".")[0] lbl_text = custom_label.strip() if custom_label else "" @@ -166,7 +183,7 @@ def handle_cytoscape_options( elif trigger_id == "create-group-btn": if not selected_nodes: - return no_update + return no_update, no_update selected_qubits = [] for n in selected_nodes: try: @@ -175,7 +192,7 @@ def handle_cytoscape_options( pass if not selected_qubits: - return no_update + return no_update, no_update g_id = f"group_{uuid.uuid4().hex[:8]}" g_label = lbl_text if lbl_text else f"Group {g_id[-8:]}" @@ -186,7 +203,7 @@ def handle_cytoscape_options( elif trigger_id == "remove-group-btn": if not selected_nodes: - return no_update + return no_update, no_update groups_to_remove = set() for n in selected_nodes: nid = str(n["id"]) @@ -204,4 +221,4 @@ def handle_cytoscape_options( for g_id in groups_to_remove: state.remove_group(g_id, unassign_children=True) - return state.to_cytoscape_elements() + return state.to_cytoscape_elements(), history_data diff --git a/src/components/components_2d/figure_2d.py b/src/components/components_2d/figure_2d.py index e735050..4559b60 100644 --- a/src/components/components_2d/figure_2d.py +++ b/src/components/components_2d/figure_2d.py @@ -54,8 +54,8 @@ def _init_state(): {"label": "Label", "value": "data(label)"}, {"label": "VOP", "value": "data(vop)"}, ], - placeholder="Value", - value="data(value)", + placeholder="ID", + value="data(id)", ) @@ -377,8 +377,20 @@ 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]}" + 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}" @callback( @@ -449,6 +461,8 @@ def update_stylesheet(_, node_labels: str): } ] + parent_label = "data(value)" if node_labels == "data(value)" else "data(label)" + custom_styles = [ { "selector": "node", @@ -473,7 +487,7 @@ def update_stylesheet(_, node_labels: str): "background-color": "#cbd5e1", "border-color": "#94a3b8", "border-width": 2, - "label": "data(label)", + "label": parent_label, "font-size": "14px", "font-weight": "bold", "text-valign": "top", diff --git a/tests/test_app.py b/tests/test_app.py index 63da41d..9af8e19 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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,7 +109,7 @@ 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} diff --git a/tests/test_frontend_cluster_state.py b/tests/test_frontend_cluster_state.py index 1682909..1a669d4 100644 --- a/tests/test_frontend_cluster_state.py +++ b/tests/test_frontend_cluster_state.py @@ -113,7 +113,7 @@ def test_cytoscape_elements_roundtrip(): # 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 group_elems[0]["data"]["value"] is None + assert isinstance(group_elems[0]["data"]["value"], str) # Reconstruct from elements reconstructed = FrontendClusterState.from_cytoscape_elements(elements) @@ -153,12 +153,12 @@ def test_remove_node_by_position_id(): # 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") == "0") - n1 = next(e for e in elements if e.get("data", {}).get("id") == "1") + 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 n1["data"]["value"] == 2 + 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") @@ -206,3 +206,42 @@ def test_quantum_operations_with_string_identifiers(): 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 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 "parent" in n0["data"] + assert n0["data"]["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 From 007ba9d3f9d4ce109c4dcda225ded473dc5030c1 Mon Sep 17 00:00:00 2001 From: Zhihua Han Date: Sat, 4 Jul 2026 14:16:07 +0200 Subject: [PATCH 03/16] wip: label logic --- .../components_2d/cytoscape_panel.py | 69 ++++++++++++++----- src/components/components_2d/figure_2d.py | 4 +- 2 files changed, 55 insertions(+), 18 deletions(-) diff --git a/src/components/components_2d/cytoscape_panel.py b/src/components/components_2d/cytoscape_panel.py index 04858bd..eaa9b8e 100644 --- a/src/components/components_2d/cytoscape_panel.py +++ b/src/components/components_2d/cytoscape_panel.py @@ -26,6 +26,8 @@ [ html.Label("Label", className="form-label small fw-bold mb-1"), dbc.Input(id="custom-label-input", type="text", placeholder="Enter label text...", size="sm"), + dbc.FormFeedback("Label applied successfully!", type="valid", id="custom-label-feedback-valid"), + dbc.FormFeedback("Empty label!", type="invalid", id="custom-label-feedback-invalid"), ], className="mb-3", ), @@ -72,6 +74,8 @@ def toggle_offcanvas(n1, n2, is_open): @callback( Output("custom-label-input", "value"), Output("node-shape-select", "value"), + Output("custom-label-input", "valid"), + Output("custom-label-input", "invalid"), Input("figure-app", "selectedNodeData"), Input("figure-app", "selectedEdgeData"), State("figure-app", "elements"), @@ -106,12 +110,15 @@ def update_offcanvas_inputs(selected_nodes, selected_edges, elements): data = actual.get("data", {}) if actual else edge label_val = data.get("label", "") - return label_val, shape_val + return label_val, shape_val, False, False @callback( Output("figure-app", "elements", allow_duplicate=True), Output("history-store", "data", allow_duplicate=True), + Output("custom-label-input", "valid", allow_duplicate=True), + Output("custom-label-input", "invalid", allow_duplicate=True), + Output("custom-label-feedback-invalid", "children", allow_duplicate=True), Input("apply-label-btn", "n_clicks"), Input("apply-shape-btn", "n_clicks"), Input("create-group-btn", "n_clicks"), @@ -140,7 +147,14 @@ def handle_cytoscape_options( ): ctx = callback_context if not ctx.triggered or not elements: - return no_update, no_update + return no_update, no_update, False, False, "" + + trigger_id = ctx.triggered[0]["prop_id"].split(".")[0] + lbl_text = custom_label.strip() if custom_label else "" + + if trigger_id == "apply-label-btn": + if not lbl_text: + return no_update, no_update, False, True, "Label cannot be empty!" if not history_data or not isinstance(history_data, dict): history_data = {"undo_stack": [], "redo_stack": []} @@ -150,13 +164,6 @@ def handle_cytoscape_options( "redo_stack": list(history_data.get("redo_stack", [])), } - # Record state before applying offcanvas customization - history_data["undo_stack"].append({"move_log": move_log or "", "elements": elements}) - history_data["redo_stack"] = [] - - trigger_id = ctx.triggered[0]["prop_id"].split(".")[0] - lbl_text = custom_label.strip() if custom_label else "" - state = FrontendClusterState.from_cytoscape_elements(elements) if trigger_id == "apply-label-btn": @@ -164,15 +171,26 @@ def handle_cytoscape_options( selected_ids = {str(n["id"]) for n in selected_nodes} for i in range(len(state)): if str(i) in selected_ids: - state.node_metadata[i]["label"] = lbl_text if lbl_text else str(state.get_value(i)) + state.node_metadata[i]["label"] = lbl_text for g_id in state.groups: - if g_id in selected_ids and lbl_text: + if g_id in selected_ids: state.groups[g_id]["label"] = lbl_text elif selected_edges: - # edge label update in elements pass + # Record state before applying offcanvas customization + history_data["undo_stack"].append( + {"action_type": "frontend_customization", "move_log": move_log or "", "elements": elements} + ) + history_data["redo_stack"] = [] + return state.to_cytoscape_elements(), history_data, True, False, "" + elif trigger_id == "apply-shape-btn": + # Record state before applying offcanvas customization + history_data["undo_stack"].append( + {"action_type": "frontend_customization", "move_log": move_log or "", "elements": elements} + ) + history_data["redo_stack"] = [] if selected_nodes: selected_ids = {str(n["id"]) for n in selected_nodes} for i in range(len(state)): @@ -180,10 +198,11 @@ def handle_cytoscape_options( if "style" not in state.node_metadata[i]: state.node_metadata[i]["style"] = {} state.node_metadata[i]["style"]["shape"] = node_shape + return state.to_cytoscape_elements(), history_data, False, False, "" elif trigger_id == "create-group-btn": if not selected_nodes: - return no_update, no_update + return no_update, no_update, False, False, "" selected_qubits = [] for n in selected_nodes: try: @@ -192,7 +211,13 @@ def handle_cytoscape_options( pass if not selected_qubits: - return no_update, no_update + return no_update, no_update, False, False, "" + + # Record state before applying offcanvas customization + history_data["undo_stack"].append( + {"action_type": "frontend_customization", "move_log": move_log or "", "elements": elements} + ) + history_data["redo_stack"] = [] g_id = f"group_{uuid.uuid4().hex[:8]}" g_label = lbl_text if lbl_text else f"Group {g_id[-8:]}" @@ -200,10 +225,11 @@ def handle_cytoscape_options( for q in selected_qubits: if q < len(state): state.set_group(q, g_id) + return state.to_cytoscape_elements(), history_data, False, False, "" elif trigger_id == "remove-group-btn": if not selected_nodes: - return no_update, no_update + return no_update, no_update, False, False, "" groups_to_remove = set() for n in selected_nodes: nid = str(n["id"]) @@ -218,7 +244,18 @@ def handle_cytoscape_options( except ValueError: pass + if not groups_to_remove: + return no_update, no_update, False, False, "" + + # Record state before applying offcanvas customization + history_data["undo_stack"].append( + {"action_type": "frontend_customization", "move_log": move_log or "", "elements": elements} + ) + history_data["redo_stack"] = [] + for g_id in groups_to_remove: state.remove_group(g_id, unassign_children=True) - return state.to_cytoscape_elements(), history_data + return state.to_cytoscape_elements(), history_data, False, False, "" + + return no_update, no_update, False, False, "" diff --git a/src/components/components_2d/figure_2d.py b/src/components/components_2d/figure_2d.py index 4559b60..c7743f0 100644 --- a/src/components/components_2d/figure_2d.py +++ b/src/components/components_2d/figure_2d.py @@ -54,8 +54,8 @@ def _init_state(): {"label": "Label", "value": "data(label)"}, {"label": "VOP", "value": "data(vop)"}, ], - placeholder="ID", - value="data(id)", + placeholder="Label", + value="data(label)", ) From 7fca97ce28ac9c11083aab5f40bf8eeaca4c7f40 Mon Sep 17 00:00:00 2001 From: Zhihua Han Date: Sat, 4 Jul 2026 23:54:39 +0200 Subject: [PATCH 04/16] feat: group behaviour --- .../simulator/frontend_cluster_state.py | 16 +++- .../components_2d/cytoscape_panel.py | 74 ++++--------------- tests/test_frontend_cluster_state.py | 17 +++++ 3 files changed, 45 insertions(+), 62 deletions(-) diff --git a/src/clustersim/simulator/frontend_cluster_state.py b/src/clustersim/simulator/frontend_cluster_state.py index 4418893..fff2a9e 100644 --- a/src/clustersim/simulator/frontend_cluster_state.py +++ b/src/clustersim/simulator/frontend_cluster_state.py @@ -414,7 +414,7 @@ def add_group( ) -> None: """Add or update a compound parent group container.""" if label is None: - label = group_id + label = "" if style is None: style = {} if value is None: @@ -456,7 +456,7 @@ def to_cytoscape_elements(self) -> List[Dict[str, Any]]: { "data": { "id": str(group_id), - "label": group_info.get("label", str(group_id)), + "label": group_info.get("label", ""), "value": val, }, "classes": "group container", @@ -472,6 +472,11 @@ def to_cytoscape_elements(self) -> List[Dict[str, Any]]: "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 meta.get("parent"): node_data["parent"] = str(meta["parent"]) else: @@ -561,13 +566,18 @@ def get_node_id(elem): 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": elem.get("style", {}), + "style": style_dict, } return frontend_state diff --git a/src/components/components_2d/cytoscape_panel.py b/src/components/components_2d/cytoscape_panel.py index eaa9b8e..6089aa1 100644 --- a/src/components/components_2d/cytoscape_panel.py +++ b/src/components/components_2d/cytoscape_panel.py @@ -26,8 +26,6 @@ [ html.Label("Label", className="form-label small fw-bold mb-1"), dbc.Input(id="custom-label-input", type="text", placeholder="Enter label text...", size="sm"), - dbc.FormFeedback("Label applied successfully!", type="valid", id="custom-label-feedback-valid"), - dbc.FormFeedback("Empty label!", type="invalid", id="custom-label-feedback-invalid"), ], className="mb-3", ), @@ -42,8 +40,7 @@ [ 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.Button("Delete Selected Group", id="remove-group-btn", color="primary", size="sm", className="w-100 mb-4"), + dbc.Button("Create Group from Selection", id="create-group-btn", color="primary", size="sm", className="w-100 mb-4"), ], className="d-flex flex-column", ), @@ -74,15 +71,13 @@ def toggle_offcanvas(n1, n2, is_open): @callback( Output("custom-label-input", "value"), Output("node-shape-select", "value"), - Output("custom-label-input", "valid"), - Output("custom-label-input", "invalid"), 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 = "ellipse" + shape_val = no_update if selected_nodes and len(selected_nodes) == 1: node = selected_nodes[0] @@ -93,7 +88,7 @@ def update_offcanvas_inputs(selected_nodes, selected_edges, elements): 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", "ellipse") + 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] @@ -110,19 +105,15 @@ def update_offcanvas_inputs(selected_nodes, selected_edges, elements): data = actual.get("data", {}) if actual else edge label_val = data.get("label", "") - return label_val, shape_val, False, False + return label_val, shape_val @callback( Output("figure-app", "elements", allow_duplicate=True), Output("history-store", "data", allow_duplicate=True), - Output("custom-label-input", "valid", allow_duplicate=True), - Output("custom-label-input", "invalid", allow_duplicate=True), - Output("custom-label-feedback-invalid", "children", allow_duplicate=True), Input("apply-label-btn", "n_clicks"), Input("apply-shape-btn", "n_clicks"), Input("create-group-btn", "n_clicks"), - Input("remove-group-btn", "n_clicks"), State("figure-app", "elements"), State("figure-app", "selectedNodeData"), State("figure-app", "selectedEdgeData"), @@ -136,7 +127,6 @@ def handle_cytoscape_options( btn_label, btn_shape, btn_create_group, - btn_remove_group, elements, selected_nodes, selected_edges, @@ -147,15 +137,11 @@ def handle_cytoscape_options( ): ctx = callback_context if not ctx.triggered or not elements: - return no_update, no_update, False, False, "" + return no_update, no_update trigger_id = ctx.triggered[0]["prop_id"].split(".")[0] lbl_text = custom_label.strip() if custom_label else "" - if trigger_id == "apply-label-btn": - if not lbl_text: - return no_update, no_update, False, True, "Label cannot be empty!" - if not history_data or not isinstance(history_data, dict): history_data = {"undo_stack": [], "redo_stack": []} else: @@ -171,10 +157,10 @@ def handle_cytoscape_options( selected_ids = {str(n["id"]) for n in selected_nodes} for i in range(len(state)): if str(i) in selected_ids: - state.node_metadata[i]["label"] = lbl_text + state.node_metadata[i]["label"] = lbl_text if lbl_text else str(i) for g_id in state.groups: if g_id in selected_ids: - state.groups[g_id]["label"] = lbl_text + state.groups[g_id]["label"] = lbl_text if lbl_text else "" elif selected_edges: pass @@ -183,7 +169,7 @@ def handle_cytoscape_options( {"action_type": "frontend_customization", "move_log": move_log or "", "elements": elements} ) history_data["redo_stack"] = [] - return state.to_cytoscape_elements(), history_data, True, False, "" + return state.to_cytoscape_elements(), history_data elif trigger_id == "apply-shape-btn": # Record state before applying offcanvas customization @@ -195,14 +181,15 @@ def handle_cytoscape_options( selected_ids = {str(n["id"]) for n in selected_nodes} for i in range(len(state)): if str(i) in selected_ids: + state.node_metadata[i]["shape"] = node_shape if "style" not in state.node_metadata[i]: state.node_metadata[i]["style"] = {} state.node_metadata[i]["style"]["shape"] = node_shape - return state.to_cytoscape_elements(), history_data, False, False, "" + return state.to_cytoscape_elements(), history_data elif trigger_id == "create-group-btn": if not selected_nodes: - return no_update, no_update, False, False, "" + return no_update, no_update selected_qubits = [] for n in selected_nodes: try: @@ -211,7 +198,7 @@ def handle_cytoscape_options( pass if not selected_qubits: - return no_update, no_update, False, False, "" + return no_update, no_update # Record state before applying offcanvas customization history_data["undo_stack"].append( @@ -220,42 +207,11 @@ def handle_cytoscape_options( history_data["redo_stack"] = [] g_id = f"group_{uuid.uuid4().hex[:8]}" - g_label = lbl_text if lbl_text else f"Group {g_id[-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, False, False, "" - - elif trigger_id == "remove-group-btn": - if not selected_nodes: - return no_update, no_update, False, False, "" - groups_to_remove = set() - for n in selected_nodes: - nid = str(n["id"]) - if nid in state.groups: - groups_to_remove.add(nid) - else: - try: - q = int(nid) - grp = state.get_group(q) - if grp: - groups_to_remove.add(grp) - except ValueError: - pass - - if not groups_to_remove: - return no_update, no_update, False, False, "" - - # Record state before applying offcanvas customization - history_data["undo_stack"].append( - {"action_type": "frontend_customization", "move_log": move_log or "", "elements": elements} - ) - history_data["redo_stack"] = [] - - for g_id in groups_to_remove: - state.remove_group(g_id, unassign_children=True) - - return state.to_cytoscape_elements(), history_data, False, False, "" + return state.to_cytoscape_elements(), history_data - return no_update, no_update, False, False, "" + return no_update, no_update diff --git a/tests/test_frontend_cluster_state.py b/tests/test_frontend_cluster_state.py index 1a669d4..84582e1 100644 --- a/tests/test_frontend_cluster_state.py +++ b/tests/test_frontend_cluster_state.py @@ -245,3 +245,20 @@ def test_undo_group_creation(): 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" From 9cfbad485fa3789a4cd1301c02b3844ec5da1b24 Mon Sep 17 00:00:00 2001 From: Zhihua Han Date: Sun, 5 Jul 2026 00:05:36 +0200 Subject: [PATCH 05/16] feat: move plot options to sidebar --- .../simulator/frontend_cluster_state.py | 89 +++++++-- src/components/components_2d/__init__.py | 1 - src/components/components_2d/action_panel.py | 115 +++++++++-- .../components_2d/cytoscape_panel.py | 179 +++++++++++++++--- src/components/components_2d/figure_2d.py | 121 ++++-------- tests/test_simulator.py | 1 + 6 files changed, 360 insertions(+), 146 deletions(-) diff --git a/src/clustersim/simulator/frontend_cluster_state.py b/src/clustersim/simulator/frontend_cluster_state.py index fff2a9e..bb418a4 100644 --- a/src/clustersim/simulator/frontend_cluster_state.py +++ b/src/clustersim/simulator/frontend_cluster_state.py @@ -20,7 +20,9 @@ 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, + positions: Optional[ + Union[List[Dict[str, float]], Dict[int, Dict[str, float]]] + ] = None, groups: Optional[Dict[str, Dict[str, Any]]] = None, ): """ @@ -50,9 +52,15 @@ def __init__( elif isinstance(positions, dict) and i in positions: pos = dict(positions[i]) else: - pos = {"x": float(random.randint(0, 300)), "y": float(random.randint(0, 300))} + pos = { + "x": float(random.randint(0, 300)), + "y": float(random.randint(0, 300)), + } else: - pos = {"x": float(random.randint(0, 300)), "y": float(random.randint(0, 300))} + pos = { + "x": float(random.randint(0, 300)), + "y": float(random.randint(0, 300)), + } self.node_metadata.append( { @@ -157,7 +165,9 @@ def redo(self) -> bool: 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"]) + 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"]) @@ -168,7 +178,9 @@ def _restore_from_snapshot(self, state_dict: Dict[str, Any]) -> None: def measure(self, qubit: Union[int, str], force: int = -1, basis: str = "Z") -> int: q = self.resolve_qubit(qubit) - return self.cluster_state.measure(q if q is not None else int(qubit), force=force, basis=basis) + return self.cluster_state.measure( + q if q is not None else int(qubit), force=force, basis=basis + ) def MX(self, qubit: Union[int, str], force: int = -1) -> int: q = self.resolve_qubit(qubit) @@ -221,12 +233,16 @@ def S_DAG(self, qubit: Union[int, str]) -> None: def CX(self, control: Union[int, str], qubit: Union[int, str]) -> None: c = self.resolve_qubit(control) q = self.resolve_qubit(qubit) - self.cluster_state.CX(c if c is not None else int(control), q if q is not None else int(qubit)) + self.cluster_state.CX( + c if c is not None else int(control), q if q is not None else int(qubit) + ) def CZ(self, control: Union[int, str], qubit: Union[int, str]) -> None: c = self.resolve_qubit(control) q = self.resolve_qubit(qubit) - self.cluster_state.CZ(c if c is not None else int(control), q if q is not None else int(qubit)) + self.cluster_state.CZ( + c if c is not None else int(control), q if q is not None else int(qubit) + ) def fusion_gate( self, @@ -252,26 +268,36 @@ def local_complementation(self, qubit: Union[int, str]) -> None: def local_complementation_rewrite(self, qubit: Union[int, str]) -> None: q = self.resolve_qubit(qubit) - self.cluster_state.local_complementation_rewrite(q if q is not None else int(qubit)) + self.cluster_state.local_complementation_rewrite( + q if q is not None else int(qubit) + ) - def apply_VOP(self, qubit: Union[int, str], vop: Union[Tuple[int, int], int, str]) -> None: + def apply_VOP( + self, qubit: Union[int, str], vop: Union[Tuple[int, int], int, str] + ) -> None: q = self.resolve_qubit(qubit) self.cluster_state.apply_VOP(q if q is not None else int(qubit), vop) def add_edge(self, qubit1: Union[int, str], qubit2: Union[int, str]) -> None: q1 = self.resolve_qubit(qubit1) q2 = self.resolve_qubit(qubit2) - self.cluster_state.add_edge(q1 if q1 is not None else int(qubit1), q2 if q2 is not None else int(qubit2)) + self.cluster_state.add_edge( + q1 if q1 is not None else int(qubit1), q2 if q2 is not None else int(qubit2) + ) def remove_edge(self, qubit1: Union[int, str], qubit2: Union[int, str]) -> None: q1 = self.resolve_qubit(qubit1) q2 = self.resolve_qubit(qubit2) - self.cluster_state.remove_edge(q1 if q1 is not None else int(qubit1), q2 if q2 is not None else int(qubit2)) + self.cluster_state.remove_edge( + q1 if q1 is not None else int(qubit1), q2 if q2 is not None else int(qubit2) + ) def toggle_edge(self, qubit1: Union[int, str], qubit2: Union[int, str]) -> None: q1 = self.resolve_qubit(qubit1) q2 = self.resolve_qubit(qubit2) - self.cluster_state.toggle_edge(q1 if q1 is not None else int(qubit1), q2 if q2 is not None else int(qubit2)) + self.cluster_state.toggle_edge( + q1 if q1 is not None else int(qubit1), q2 if q2 is not None else int(qubit2) + ) # ------------------------------------------------------------------ # Node Addition & Removal (with Metadata & Index Re-mapping) @@ -292,7 +318,10 @@ def add_node( self.cluster_state.add_node(vop=vop) if position is None: - position = {"x": float(random.randint(0, 300)), "y": float(random.randint(0, 300))} + position = { + "x": float(random.randint(0, 300)), + "y": float(random.randint(0, 300)), + } if value is None: value = uuid.uuid4().hex[:8] @@ -324,7 +353,9 @@ def resolve_qubit(self, identifier: Union[int, str]) -> Optional[int]: # 2. Check if integer identifier matches persistent value attribute for i, meta in enumerate(self.node_metadata): - if meta.get("value") == identifier or str(meta.get("value")) == str(identifier): + if meta.get("value") == identifier or str(meta.get("value")) == str( + identifier + ): return i # 3. Fallback to raw integer index within bounds @@ -450,7 +481,11 @@ def to_cytoscape_elements(self) -> List[Dict[str, Any]]: 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] + 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( { @@ -509,7 +544,9 @@ def to_cytoscape_elements(self) -> List[Dict[str, Any]]: return elements @classmethod - def from_cytoscape_elements(cls, elements: List[Dict[str, Any]]) -> FrontendClusterState: + def from_cytoscape_elements( + cls, elements: List[Dict[str, Any]] + ) -> FrontendClusterState: """ Reconstruct FrontendClusterState from a list of Cytoscape elements. """ @@ -522,14 +559,20 @@ def from_cytoscape_elements(cls, elements: List[Dict[str, Any]]) -> FrontendClus # 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 str(data.get("id", "")).isdigit()): + elif "vop" in data or ( + data.get("value") is not None and str(data.get("id", "")).isdigit() + ): 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] + 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", {}), @@ -559,7 +602,9 @@ def get_node_id(elem): 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) + frontend_state = cls( + num_nodes=num_nodes, cluster_state=base_cluster_state, groups=groups + ) # Restore node metadata for i in range(num_nodes): @@ -598,7 +643,11 @@ def to_dict(self) -> Dict[str, Any]: 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", {})) + 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 diff --git a/src/components/components_2d/__init__.py b/src/components/components_2d/__init__.py index 5bc8028..2236544 100644 --- a/src/components/components_2d/__init__.py +++ b/src/components/components_2d/__init__.py @@ -17,4 +17,3 @@ "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 714c1cf..99fa624 100644 --- a/src/components/components_2d/action_panel.py +++ b/src/components/components_2d/action_panel.py @@ -77,7 +77,15 @@ [ dbc.Col( [ - html.Span("Force Meas.", style={"fontSize": "0.85rem", "fontWeight": "600", "display": "block", "marginBottom": "2px"}), + html.Span( + "Force Meas.", + style={ + "fontSize": "0.85rem", + "fontWeight": "600", + "display": "block", + "marginBottom": "2px", + }, + ), dbc.Select( id="fusion-force-measurement", options=[ @@ -89,15 +97,27 @@ ], placeholder="00", value=0, - style={"fontSize": "0.85rem", "padding": "2px 4px", "height": "auto"}, + style={ + "fontSize": "0.85rem", + "padding": "2px 4px", + "height": "auto", + }, ), ], width=6, - className="pe-1" + className="pe-1", ), dbc.Col( [ - html.Span("Fusion Mode", style={"fontSize": "0.85rem", "fontWeight": "600", "display": "block", "marginBottom": "2px"}), + html.Span( + "Fusion Mode", + style={ + "fontSize": "0.85rem", + "fontWeight": "600", + "display": "block", + "marginBottom": "2px", + }, + ), dbc.Select( id="fusion-mode", options=[ @@ -112,14 +132,18 @@ {"label": "Random", "value": "random"}, ], value="success", - style={"fontSize": "0.85rem", "padding": "2px 4px", "height": "auto"}, + style={ + "fontSize": "0.85rem", + "padding": "2px 4px", + "height": "auto", + }, ), ], width=6, - className="ps-1" + className="ps-1", ), ], - className="mb-2 g-0" + className="mb-2 g-0", ), dbc.Row( [ @@ -127,14 +151,41 @@ [ 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"}), + 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" + className="w-100", ), ], - width=12 + width=12, ), ], className="g-0", @@ -319,7 +370,11 @@ id="open-offcanvas-btn", outline=True, color="secondary", - style={"fontSize": "1.2rem", "padding": "2px 8px", "marginBottom": "4px"}, + style={ + "fontSize": "1.2rem", + "padding": "2px 8px", + "marginBottom": "4px", + }, title="Plot Customization Options", ), dbc.Button( @@ -336,7 +391,9 @@ ], width=1, ), - dbc.Tooltip("Copy (Ctrl+C) / Paste (Ctrl+V)", target="duplicate", placement="top"), + 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"), @@ -367,9 +424,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", {}), @@ -612,7 +678,10 @@ def apply_operation_wrapper( 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": float(random.randint(0, 100)), "y": float(random.randint(0, 100))} + new_pos = { + "x": float(random.randint(0, 100)), + "y": float(random.randint(0, 100)), + } new_node_id = state.add_node(position=new_pos, **method_args) @@ -639,7 +708,11 @@ def apply_operation_wrapper( cyto_data_new = state.to_cytoscape_elements() return ui, cyto_data_new, repr(state.cluster_state), log, history_data elif method_name == "duplicate": - resolved_selected = [state.resolve_qubit(q) for q in selected_nodes if state.resolve_qubit(q) is not None] + resolved_selected = [ + state.resolve_qubit(q) + for q in selected_nodes + if state.resolve_qubit(q) is not None + ] resolved_selected.sort() for q in resolved_selected: if q < len(state): @@ -827,7 +900,9 @@ def load_graph( # 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")) + state.add_group( + g_id, label=g_info.get("label"), style=g_info.get("style") + ) cyto_data_new = state.to_cytoscape_elements() diff --git a/src/components/components_2d/cytoscape_panel.py b/src/components/components_2d/cytoscape_panel.py index 6089aa1..79c365c 100644 --- a/src/components/components_2d/cytoscape_panel.py +++ b/src/components/components_2d/cytoscape_panel.py @@ -19,34 +19,149 @@ {"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.Label("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-3", + 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.Label("Node Shape", className="form-label small fw-bold mb-1"), - dbc.Select(id="node-shape-select", options=SHAPE_OPTIONS, value="ellipse", size="sm"), - ], - className="mb-4", + 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", + ), + ], + className="d-flex flex-column mb-3", + ), + html.Hr(className="my-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-4"), - ], - className="d-flex flex-column", + 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 Customization", + title="Plot Options & Customization", is_open=False, scrollable=True, backdrop=False, @@ -58,12 +173,11 @@ @callback( Output("cytoscape-offcanvas", "is_open"), Input("open-offcanvas-btn", "n_clicks"), - Input("open-offcanvas-btn-tab5", "n_clicks"), State("cytoscape-offcanvas", "is_open"), prevent_initial_call=True, ) -def toggle_offcanvas(n1, n2, is_open): - if n1 or n2: +def toggle_offcanvas(n_clicks, is_open): + if n_clicks: return not is_open return is_open @@ -82,13 +196,22 @@ def update_offcanvas_inputs(selected_nodes, selected_edges, elements): 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) + 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") + 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] @@ -166,7 +289,11 @@ def handle_cytoscape_options( # Record state before applying offcanvas customization history_data["undo_stack"].append( - {"action_type": "frontend_customization", "move_log": move_log or "", "elements": elements} + { + "action_type": "frontend_customization", + "move_log": move_log or "", + "elements": elements, + } ) history_data["redo_stack"] = [] return state.to_cytoscape_elements(), history_data @@ -174,7 +301,11 @@ def handle_cytoscape_options( elif trigger_id == "apply-shape-btn": # Record state before applying offcanvas customization history_data["undo_stack"].append( - {"action_type": "frontend_customization", "move_log": move_log or "", "elements": elements} + { + "action_type": "frontend_customization", + "move_log": move_log or "", + "elements": elements, + } ) history_data["redo_stack"] = [] if selected_nodes: @@ -202,7 +333,11 @@ def handle_cytoscape_options( # Record state before applying offcanvas customization history_data["undo_stack"].append( - {"action_type": "frontend_customization", "move_log": move_log or "", "elements": elements} + { + "action_type": "frontend_customization", + "move_log": move_log or "", + "elements": elements, + } ) history_data["redo_stack"] = [] diff --git a/src/components/components_2d/figure_2d.py b/src/components/components_2d/figure_2d.py index c7743f0..374f926 100644 --- a/src/components/components_2d/figure_2d.py +++ b/src/components/components_2d/figure_2d.py @@ -40,25 +40,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="Label", - value="data(label)", -) - - tab_1 = dbc.Row([dbc.Col(qubit_panel, width=12)]) tab_3 = dbc.Row( @@ -89,11 +70,30 @@ def _init_state(): ], width=6, ), - 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"), + 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", ), @@ -103,37 +103,6 @@ def _init_state(): ] ) -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("Customize Plot", id="open-offcanvas-btn-tab5", color="secondary", outline=True, style={"width": "100%", "marginBottom": "4px"}), - 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 @@ -187,11 +156,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, @@ -302,12 +266,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"}, ) @@ -381,10 +339,14 @@ def displaySelectedNodeData(data_list: List[Dict[str, Any]]): 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", ""))): + 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()): + 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) @@ -541,29 +503,25 @@ def toggle_modal(n1, n2, is_open): 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(n_rep, n_log, n_ref, n_reset): 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 + return "log", False, True, False, False elif trigger_id == "modal-tab-ref": - return "ref", False, False, True, False, False + return "ref", False, False, True, 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 + return "reset", False, False, False, True + return "rep", True, False, False, False @callback( @@ -571,11 +529,10 @@ 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)] + styles = [{"display": "none"} for _ in range(4)] if active_tab == "rep": styles[0] = {"display": "block"} elif active_tab == "log": @@ -584,6 +541,4 @@ def toggle_modal_content_visibility(active_tab): 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 styles[0], styles[1], styles[2], styles[3] diff --git a/tests/test_simulator.py b/tests/test_simulator.py index 88f122c..a90bc19 100644 --- a/tests/test_simulator.py +++ b/tests/test_simulator.py @@ -175,6 +175,7 @@ def random_graph_program(draw): from hypothesis import settings + @settings(deadline=None) @given(program=random_graph_program()) def test_load_undo(program): From b09c56a149a0eeffff7bb6bc5181b2a616dcefb9 Mon Sep 17 00:00:00 2001 From: Zhihua Han Date: Sun, 5 Jul 2026 00:12:27 +0200 Subject: [PATCH 06/16] feat: group/ungroup. --- .../simulator/frontend_cluster_state.py | 108 +++++++++++------ .../components_2d/cytoscape_panel.py | 109 +++++++++++++----- 2 files changed, 150 insertions(+), 67 deletions(-) diff --git a/src/clustersim/simulator/frontend_cluster_state.py b/src/clustersim/simulator/frontend_cluster_state.py index bb418a4..824eb10 100644 --- a/src/clustersim/simulator/frontend_cluster_state.py +++ b/src/clustersim/simulator/frontend_cluster_state.py @@ -345,17 +345,28 @@ 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) + + # Handle compound node ID format like "0_group_1" + if "_" in s_id and not s_id.startswith("group_"): + prefix = s_id.split("_")[0] + if prefix.isdigit(): + q = int(prefix) + if 0 <= q < len(self): + return q + # 1. If identifier is string digit matching Cytoscape element id str(i) - if isinstance(identifier, str) and identifier.isdigit(): - q = int(identifier) + if s_id.isdigit(): + q = int(s_id) if 0 <= q < len(self): return q # 2. Check if integer identifier matches persistent value attribute for i, meta in enumerate(self.node_metadata): - if meta.get("value") == identifier or str(meta.get("value")) == str( - identifier - ): + if meta.get("value") == identifier or str(meta.get("value")) == s_id: return i # 3. Fallback to raw integer index within bounds @@ -477,32 +488,14 @@ def to_cytoscape_elements(self) -> List[Dict[str, Any]]: """ elements: List[Dict[str, Any]] = [] - # 1. 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", - } - ) - - # 2. Qubit Nodes + # 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] + parent_id = str(meta["parent"]) if meta.get("parent") else None + cyto_id = f"{i}_{parent_id}" if parent_id else str(i) + node_data = { - "id": str(i), + "id": cyto_id, "label": meta.get("label", str(i)), "value": meta.get("value", i), "vop": self.vertex_operators[i], @@ -512,10 +505,8 @@ def to_cytoscape_elements(self) -> List[Dict[str, Any]]: elif meta.get("style", {}).get("shape"): node_data["shape"] = meta["style"]["shape"] - if meta.get("parent"): - node_data["parent"] = str(meta["parent"]) - else: - node_data["parent"] = None + if parent_id: + node_data["parent"] = parent_id node_elem = { "data": node_data, @@ -528,15 +519,50 @@ def to_cytoscape_elements(self) -> List[Dict[str, Any]]: 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: + u_parent = ( + str(self.node_metadata[u]["parent"]) + if self.node_metadata[u].get("parent") + else None + ) + u_id = f"{u}_{u_parent}" if u_parent else str(u) + + v_parent = ( + str(self.node_metadata[v]["parent"]) + if self.node_metadata[v].get("parent") + else None + ) + v_id = f"{v}_{v_parent}" if v_parent else str(v) + elements.append( { "data": { - "source": str(u), - "target": str(v), + "source": u_id, + "target": v_id, } } ) @@ -560,7 +586,8 @@ def from_cytoscape_elements( if "source" in data and "target" in data: edge_elements.append(item) elif "vop" in data or ( - data.get("value") is not None and str(data.get("id", "")).isdigit() + data.get("value") is not None + and not str(data.get("id", "")).startswith("group_") ): node_elements.append(item) elif "id" in data: @@ -581,8 +608,11 @@ def from_cytoscape_elements( # 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(elem["data"]["id"]) + return int(raw_id) except (ValueError, KeyError): return 0 @@ -597,8 +627,10 @@ def get_node_id(elem): rx_graph.add_node({"vop": vop_val}) for edge in edge_elements: - u = int(edge["data"]["source"]) - v = int(edge["data"]["target"]) + 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) diff --git a/src/components/components_2d/cytoscape_panel.py b/src/components/components_2d/cytoscape_panel.py index 79c365c..4176bf7 100644 --- a/src/components/components_2d/cytoscape_panel.py +++ b/src/components/components_2d/cytoscape_panel.py @@ -185,6 +185,7 @@ def toggle_offcanvas(n_clicks, 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"), @@ -192,6 +193,22 @@ def toggle_offcanvas(n_clicks, is_open): 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) + is_grouped = False + for node in selected_nodes: + n_id = str(node.get("id")) + if n_id in state.groups: + is_grouped = True + break + q = state.resolve_qubit(n_id) + if q is not None and state.get_group(q) is not None: + is_grouped = True + break + if is_grouped: + btn_text = "Ungroup Selection" if selected_nodes and len(selected_nodes) == 1: node = selected_nodes[0] @@ -228,7 +245,7 @@ def update_offcanvas_inputs(selected_nodes, selected_edges, elements): data = actual.get("data", {}) if actual else edge label_val = data.get("label", "") - return label_val, shape_val + return label_val, shape_val, btn_text @callback( @@ -277,15 +294,16 @@ def handle_cytoscape_options( if trigger_id == "apply-label-btn": if selected_nodes: - selected_ids = {str(n["id"]) for n in selected_nodes} - for i in range(len(state)): - if str(i) in selected_ids: - state.node_metadata[i]["label"] = lbl_text if lbl_text else str(i) - for g_id in state.groups: - if g_id in selected_ids: - state.groups[g_id]["label"] = lbl_text if lbl_text else "" - elif selected_edges: - pass + for n in selected_nodes: + n_id = str(n.get("id")) + if n_id in state.groups: + state.groups[n_id]["label"] = lbl_text if lbl_text else "" + 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) + ) # Record state before applying offcanvas customization history_data["undo_stack"].append( @@ -309,27 +327,35 @@ def handle_cytoscape_options( ) history_data["redo_stack"] = [] if selected_nodes: - selected_ids = {str(n["id"]) for n in selected_nodes} - for i in range(len(state)): - if str(i) in selected_ids: - state.node_metadata[i]["shape"] = node_shape - if "style" not in state.node_metadata[i]: - state.node_metadata[i]["style"] = {} - state.node_metadata[i]["style"]["shape"] = node_shape + for n in selected_nodes: + n_id = str(n.get("id")) + q = state.resolve_qubit(n_id) + if q is not None and 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: - try: - selected_qubits.append(int(n["id"])) - except (ValueError, KeyError): - pass + 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) - if not selected_qubits: - return no_update, no_update + 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 # Record state before applying offcanvas customization history_data["undo_stack"].append( @@ -341,12 +367,37 @@ def handle_cytoscape_options( ) history_data["redo_stack"] = [] - 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) + 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 From 12f20770f8846fe2f69a7c2e1d95b53c0ca88ba5 Mon Sep 17 00:00:00 2001 From: Zhihua Han Date: Sun, 5 Jul 2026 00:21:35 +0200 Subject: [PATCH 07/16] feat: group expand feature --- assets/clientside.js | 8 ++++ graph2d_app.py | 1 + .../simulator/frontend_cluster_state.py | 36 +++++++++++++++++ src/components/components_2d/action_panel.py | 39 ++++++++----------- .../components_2d/cytoscape_panel.py | 16 ++++++-- 5 files changed, 73 insertions(+), 27 deletions(-) diff --git a/assets/clientside.js b/assets/clientside.js index 6f7c460..2ba74c9 100644 --- a/assets/clientside.js +++ b/assets/clientside.js @@ -87,6 +87,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/graph2d_app.py b/graph2d_app.py index 21be60b..f520a03 100644 --- a/graph2d_app.py +++ b/graph2d_app.py @@ -43,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/frontend_cluster_state.py b/src/clustersim/simulator/frontend_cluster_state.py index 824eb10..7295a46 100644 --- a/src/clustersim/simulator/frontend_cluster_state.py +++ b/src/clustersim/simulator/frontend_cluster_state.py @@ -469,6 +469,42 @@ def add_group( 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: + if isinstance(item, dict): + s_item = str(item.get("id", "")) + else: + s_item = 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: diff --git a/src/components/components_2d/action_panel.py b/src/components/components_2d/action_panel.py index 99fa624..d160c3c 100644 --- a/src/components/components_2d/action_panel.py +++ b/src/components/components_2d/action_panel.py @@ -589,7 +589,7 @@ def handle_buttons(*args): def apply_operation_wrapper( method_name: str, - selected_nodes: list[int], + selected_nodes: list, cyto_data, log, method_args, @@ -607,17 +607,15 @@ def apply_operation_wrapper( _type_: processed cyto_data for use in figure-app.elements """ state = FrontendClusterState.from_cytoscape_elements(cyto_data) - display_nodes = [ - int(q) if (isinstance(q, int) or (isinstance(q, str) and q.isdigit())) else q - for q in selected_nodes - ] + 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): + 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!", @@ -639,18 +637,18 @@ def apply_operation_wrapper( "S", "S_DAG", ]: - for i in selected_nodes: + for i in expanded_qubits: getattr(state, method_name)(i, **method_args) ui = f"Applied {method_name} to {display_nodes}" elif method_name in ["measure"]: outcomes = [] - for i in selected_nodes: + for i in expanded_qubits: outcomes.append(getattr(state, method_name)(i, **method_args)) 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!", @@ -666,8 +664,8 @@ def apply_operation_wrapper( elif method_name in ["add_node"]: selected_positions = [] resolved_selected = [] - if selected_nodes: - for q in selected_nodes: + 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) @@ -689,30 +687,25 @@ def apply_operation_wrapper( for neighbour in resolved_selected: if neighbour < new_node_id: state.add_edge(new_node_id, neighbour) - if selected_nodes: + 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"]: groups_to_remove = [q for q in selected_nodes if str(q) in state.groups] - qubits_to_remove = [q for q in selected_nodes if str(q) not in state.groups] for g_id in groups_to_remove: state.remove_group(str(g_id), unassign_children=True) - if qubits_to_remove: - state.remove_node(qubits_to_remove) + if expanded_qubits: + state.remove_node(expanded_qubits) ui = f"Removed nodes {display_nodes}" - log += f"REMOVE_NODE {' '.join(map(str, selected_nodes))}\n" + log += f"REMOVE_NODE {' '.join(map(str, expanded_qubits))}\n" cyto_data_new = state.to_cytoscape_elements() return ui, cyto_data_new, repr(state.cluster_state), log, history_data elif method_name == "duplicate": - resolved_selected = [ - state.resolve_qubit(q) - for q in selected_nodes - if state.resolve_qubit(q) is not None - ] + resolved_selected = list(expanded_qubits) resolved_selected.sort() for q in resolved_selected: if q < len(state): @@ -720,7 +713,7 @@ def apply_operation_wrapper( state.add_node(position={"x": pos["x"] + 50, "y": pos["y"] + 50}) state.cluster_state = state.cluster_state.duplicate(resolved_selected) - ui = f"Duplicated nodes {selected_nodes}" + ui = f"Duplicated nodes {display_nodes}" else: raise NotImplementedError(f"Do not know {method_name}") diff --git a/src/components/components_2d/cytoscape_panel.py b/src/components/components_2d/cytoscape_panel.py index 4176bf7..1e4a1f6 100644 --- a/src/components/components_2d/cytoscape_panel.py +++ b/src/components/components_2d/cytoscape_panel.py @@ -119,6 +119,11 @@ 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", ), @@ -254,6 +259,7 @@ def update_offcanvas_inputs(selected_nodes, selected_edges, elements): 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"), @@ -267,6 +273,7 @@ def handle_cytoscape_options( btn_label, btn_shape, btn_create_group, + key_g, elements, selected_nodes, selected_edges, @@ -280,6 +287,8 @@ def handle_cytoscape_options( 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): @@ -327,10 +336,9 @@ def handle_cytoscape_options( ) history_data["redo_stack"] = [] if selected_nodes: - for n in selected_nodes: - n_id = str(n.get("id")) - q = state.resolve_qubit(n_id) - if q is not None and 0 <= q < len(state): + 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"] = {} From 3d84390f91951a027c3498747161d5de4f4f1e26 Mon Sep 17 00:00:00 2001 From: Zhihua Han Date: Sun, 5 Jul 2026 00:29:00 +0200 Subject: [PATCH 08/16] chore: code cleanup and simplification --- .../simulator/frontend_cluster_state.py | 209 ++++++------------ src/components/components_2d/action_panel.py | 67 +++--- .../components_2d/cytoscape_panel.py | 57 ++--- src/components/components_2d/figure_2d.py | 40 ++-- src/components/components_2d/move_log_2d.py | 5 +- tests/test_frontend_cluster_state.py | 7 +- 6 files changed, 141 insertions(+), 244 deletions(-) diff --git a/src/clustersim/simulator/frontend_cluster_state.py b/src/clustersim/simulator/frontend_cluster_state.py index 7295a46..056cbe3 100644 --- a/src/clustersim/simulator/frontend_cluster_state.py +++ b/src/clustersim/simulator/frontend_cluster_state.py @@ -44,27 +44,26 @@ def __init__( # Initialize node metadata list (1-to-1 with qubits 0..n-1) self.node_metadata: List[Dict[str, Any]] = [] - for i in range(n): - pos = {"x": 0.0, "y": 0.0} - if positions is not None: + 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): - pos = dict(positions[i]) - elif isinstance(positions, dict) and i in positions: - pos = dict(positions[i]) - else: - pos = { - "x": float(random.randint(0, 300)), - "y": float(random.randint(0, 300)), - } - else: - pos = { - "x": float(random.randint(0, 300)), - "y": float(random.randint(0, 300)), - } + 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": pos, + "position": _get_init_pos(i), "parent": None, "value": uuid.uuid4().hex[:8], "label": str(i), @@ -110,6 +109,16 @@ def __eq__(self, other: Any) -> bool: 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 # ------------------------------------------------------------------ @@ -177,72 +186,49 @@ def _restore_from_snapshot(self, state_dict: Dict[str, Any]) -> None: # ------------------------------------------------------------------ def measure(self, qubit: Union[int, str], force: int = -1, basis: str = "Z") -> int: - q = self.resolve_qubit(qubit) - return self.cluster_state.measure( - q if q is not None else int(qubit), force=force, basis=basis - ) + return self.cluster_state.measure(self._q(qubit), force=force, basis=basis) def MX(self, qubit: Union[int, str], force: int = -1) -> int: - q = self.resolve_qubit(qubit) - return self.cluster_state.MX(q if q is not None else int(qubit), force=force) + return self.cluster_state.MX(self._q(qubit), force=force) def MY(self, qubit: Union[int, str], force: int = -1) -> int: - q = self.resolve_qubit(qubit) - return self.cluster_state.MY(q if q is not None else int(qubit), force=force) + return self.cluster_state.MY(self._q(qubit), force=force) def MZ(self, qubit: Union[int, str], force: int = -1) -> int: - q = self.resolve_qubit(qubit) - return self.cluster_state.MZ(q if q is not None else int(qubit), force=force) + return self.cluster_state.MZ(self._q(qubit), force=force) def M(self, qubit: Union[int, str], force: int = -1) -> int: - q = self.resolve_qubit(qubit) - return self.cluster_state.M(q if q is not None else int(qubit), force=force) + return self.cluster_state.M(self._q(qubit), force=force) def I(self, qubit: Union[int, str]) -> None: - q = self.resolve_qubit(qubit) - self.cluster_state.I(q if q is not None else int(qubit)) + self.cluster_state.I(self._q(qubit)) def X(self, qubit: Union[int, str]) -> None: - q = self.resolve_qubit(qubit) - self.cluster_state.X(q if q is not None else int(qubit)) + self.cluster_state.X(self._q(qubit)) def Y(self, qubit: Union[int, str]) -> None: - q = self.resolve_qubit(qubit) - self.cluster_state.Y(q if q is not None else int(qubit)) + self.cluster_state.Y(self._q(qubit)) def Z(self, qubit: Union[int, str]) -> None: - q = self.resolve_qubit(qubit) - self.cluster_state.Z(q if q is not None else int(qubit)) + self.cluster_state.Z(self._q(qubit)) def H(self, qubit: Union[int, str]) -> None: - q = self.resolve_qubit(qubit) - self.cluster_state.H(q if q is not None else int(qubit)) + self.cluster_state.H(self._q(qubit)) def S(self, qubit: Union[int, str]) -> None: - q = self.resolve_qubit(qubit) - self.cluster_state.S(q if q is not None else int(qubit)) + self.cluster_state.S(self._q(qubit)) def SH(self, qubit: Union[int, str]) -> None: - q = self.resolve_qubit(qubit) - self.cluster_state.SH(q if q is not None else int(qubit)) + self.cluster_state.SH(self._q(qubit)) def S_DAG(self, qubit: Union[int, str]) -> None: - q = self.resolve_qubit(qubit) - self.cluster_state.S_DAG(q if q is not None else int(qubit)) + self.cluster_state.S_DAG(self._q(qubit)) def CX(self, control: Union[int, str], qubit: Union[int, str]) -> None: - c = self.resolve_qubit(control) - q = self.resolve_qubit(qubit) - self.cluster_state.CX( - c if c is not None else int(control), q if q is not None else int(qubit) - ) + self.cluster_state.CX(self._q(control), self._q(qubit)) def CZ(self, control: Union[int, str], qubit: Union[int, str]) -> None: - c = self.resolve_qubit(control) - q = self.resolve_qubit(qubit) - self.cluster_state.CZ( - c if c is not None else int(control), q if q is not None else int(qubit) - ) + self.cluster_state.CZ(self._q(control), self._q(qubit)) def fusion_gate( self, @@ -252,52 +238,33 @@ def fusion_gate( mode: str = "success", force: int = 0, ) -> None: - q1 = self.resolve_qubit(qubit1) - q2 = self.resolve_qubit(qubit2) self.cluster_state.fusion_gate( - q1 if q1 is not None else int(qubit1), - q2 if q2 is not None else int(qubit2), + self._q(qubit1), + self._q(qubit2), fusion_type=fusion_type, mode=mode, force=force, ) def local_complementation(self, qubit: Union[int, str]) -> None: - q = self.resolve_qubit(qubit) - self.cluster_state.local_complementation(q if q is not None else int(qubit)) + self.cluster_state.local_complementation(self._q(qubit)) def local_complementation_rewrite(self, qubit: Union[int, str]) -> None: - q = self.resolve_qubit(qubit) - self.cluster_state.local_complementation_rewrite( - q if q is not None else int(qubit) - ) + 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: - q = self.resolve_qubit(qubit) - self.cluster_state.apply_VOP(q if q is not None else int(qubit), vop) + self.cluster_state.apply_VOP(self._q(qubit), vop) def add_edge(self, qubit1: Union[int, str], qubit2: Union[int, str]) -> None: - q1 = self.resolve_qubit(qubit1) - q2 = self.resolve_qubit(qubit2) - self.cluster_state.add_edge( - q1 if q1 is not None else int(qubit1), q2 if q2 is not None else int(qubit2) - ) + self.cluster_state.add_edge(self._q(qubit1), self._q(qubit2)) def remove_edge(self, qubit1: Union[int, str], qubit2: Union[int, str]) -> None: - q1 = self.resolve_qubit(qubit1) - q2 = self.resolve_qubit(qubit2) - self.cluster_state.remove_edge( - q1 if q1 is not None else int(qubit1), q2 if q2 is not None else int(qubit2) - ) + self.cluster_state.remove_edge(self._q(qubit1), self._q(qubit2)) def toggle_edge(self, qubit1: Union[int, str], qubit2: Union[int, str]) -> None: - q1 = self.resolve_qubit(qubit1) - q2 = self.resolve_qubit(qubit2) - self.cluster_state.toggle_edge( - q1 if q1 is not None else int(qubit1), q2 if q2 is not None else int(qubit2) - ) + self.cluster_state.toggle_edge(self._q(qubit1), self._q(qubit2)) # ------------------------------------------------------------------ # Node Addition & Removal (with Metadata & Index Re-mapping) @@ -350,26 +317,19 @@ def resolve_qubit(self, identifier: Union[int, str]) -> Optional[int]: s_id = str(identifier) - # Handle compound node ID format like "0_group_1" - if "_" in s_id and not s_id.startswith("group_"): - prefix = s_id.split("_")[0] - if prefix.isdigit(): - q = int(prefix) - if 0 <= q < len(self): - return q - - # 1. If identifier is string digit matching Cytoscape element id str(i) - if s_id.isdigit(): - q = int(s_id) + # 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 - # 2. Check if integer identifier matches persistent value attribute + # 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 - # 3. Fallback to raw integer index within bounds + # Fallback to raw integer index within bounds if isinstance(identifier, int) and 0 <= identifier < len(self): return identifier @@ -403,12 +363,11 @@ def remove_node(self, qubits: Union[int, str, List[Union[int, str]]]) -> List[in self.cluster_state.remove_node(list(qubits_to_remove)) # Re-map node metadata list: keep only non-removed nodes in original relative order - new_metadata = [] - for i, meta in enumerate(self.node_metadata): - if i not in qubits_to_remove: - new_metadata.append(meta) - - self.node_metadata = new_metadata + self.node_metadata = [ + meta + for i, meta in enumerate(self.node_metadata) + if i not in qubits_to_remove + ] return resolved_qubits # ------------------------------------------------------------------ @@ -416,36 +375,25 @@ def remove_node(self, qubits: Union[int, str, List[Union[int, str]]]) -> List[in # ------------------------------------------------------------------ def get_position(self, qubit: Union[int, str]) -> Dict[str, float]: - q = self.resolve_qubit(qubit) - idx = q if q is not None else int(qubit) - return self.node_metadata[idx]["position"] + return self.node_metadata[self._q(qubit)]["position"] def set_position(self, qubit: Union[int, str], position: Dict[str, float]) -> None: - q = self.resolve_qubit(qubit) - idx = q if q is not None else int(qubit) - self.node_metadata[idx]["position"] = dict(position) + self.node_metadata[self._q(qubit)]["position"] = dict(position) def get_group(self, qubit: Union[int, str]) -> Optional[str]: - q = self.resolve_qubit(qubit) - idx = q if q is not None else int(qubit) - return self.node_metadata[idx]["parent"] + return self.node_metadata[self._q(qubit)]["parent"] def set_group(self, qubit: Union[int, str], group_id: Optional[str]) -> None: - q = self.resolve_qubit(qubit) - idx = q if q is not None else int(qubit) + 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: - q = self.resolve_qubit(qubit) - idx = q if q is not None else int(qubit) - return self.node_metadata[idx]["value"] + return self.node_metadata[self._q(qubit)]["value"] def set_value(self, qubit: Union[int, str], value: Any) -> None: - q = self.resolve_qubit(qubit) - idx = q if q is not None else int(qubit) - self.node_metadata[idx]["value"] = value + self.node_metadata[self._q(qubit)]["value"] = value def add_group( self, @@ -487,10 +435,7 @@ def expand_selection( """ result: List[int] = [] for item in selected_items: - if isinstance(item, dict): - s_item = str(item.get("id", "")) - else: - s_item = str(item) + 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) @@ -527,8 +472,8 @@ def to_cytoscape_elements(self) -> 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 - cyto_id = f"{i}_{parent_id}" if parent_id else str(i) node_data = { "id": cyto_id, @@ -580,25 +525,11 @@ def to_cytoscape_elements(self) -> List[Dict[str, Any]]: for u, neighbors in enumerate(self.adjacency_list): for v in neighbors: if u < v: - u_parent = ( - str(self.node_metadata[u]["parent"]) - if self.node_metadata[u].get("parent") - else None - ) - u_id = f"{u}_{u_parent}" if u_parent else str(u) - - v_parent = ( - str(self.node_metadata[v]["parent"]) - if self.node_metadata[v].get("parent") - else None - ) - v_id = f"{v}_{v_parent}" if v_parent else str(v) - elements.append( { "data": { - "source": u_id, - "target": v_id, + "source": self._cyto_node_id(u), + "target": self._cyto_node_id(v), } } ) diff --git a/src/components/components_2d/action_panel.py b/src/components/components_2d/action_panel.py index d160c3c..259f9d4 100644 --- a/src/components/components_2d/action_panel.py +++ b/src/components/components_2d/action_panel.py @@ -456,6 +456,26 @@ } + +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("figure-app", "elements", allow_duplicate=True), @@ -493,14 +513,7 @@ def handle_buttons(*args): state = FrontendClusterState.from_cytoscape_elements(cyto_data) - 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", [])), - } + history_data = _normalize_history(args[-8]) # Record the current elements state in history for Undo/Redo history_data["undo_stack"].append( @@ -510,18 +523,7 @@ def handle_buttons(*args): # 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] @@ -749,17 +751,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 quantum qubit node - positions.append(item.get("position", {"x": 0.0, "y": 0.0})) - elif item["data"].get("source"): - edges.append(item) # This is an edge - else: - # Compound group container node (no vop, no source) - pass - else: - pass + 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, @@ -816,13 +813,7 @@ def load_graph( 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", [])), - } + history_data = _normalize_history(history_data) _, positions = preprocess_cyto_data_elements(cyto_data) diff --git a/src/components/components_2d/cytoscape_panel.py b/src/components/components_2d/cytoscape_panel.py index 1e4a1f6..52218b9 100644 --- a/src/components/components_2d/cytoscape_panel.py +++ b/src/components/components_2d/cytoscape_panel.py @@ -202,17 +202,15 @@ def update_offcanvas_inputs(selected_nodes, selected_edges, elements): if selected_nodes and elements: state = FrontendClusterState.from_cytoscape_elements(elements) - is_grouped = False - for node in selected_nodes: - n_id = str(node.get("id")) + + def _is_node_grouped(n): + n_id = str(n.get("id")) if n_id in state.groups: - is_grouped = True - break + return True q = state.resolve_qubit(n_id) - if q is not None and state.get_group(q) is not None: - is_grouped = True - break - if is_grouped: + 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: @@ -299,6 +297,16 @@ def handle_cytoscape_options( "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": @@ -306,35 +314,16 @@ def handle_cytoscape_options( for n in selected_nodes: n_id = str(n.get("id")) if n_id in state.groups: - state.groups[n_id]["label"] = lbl_text if lbl_text else "" + 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) ) - - # Record state before applying offcanvas customization - history_data["undo_stack"].append( - { - "action_type": "frontend_customization", - "move_log": move_log or "", - "elements": elements, - } - ) - history_data["redo_stack"] = [] return state.to_cytoscape_elements(), history_data elif trigger_id == "apply-shape-btn": - # Record state before applying offcanvas customization - history_data["undo_stack"].append( - { - "action_type": "frontend_customization", - "move_log": move_log or "", - "elements": elements, - } - ) - history_data["redo_stack"] = [] if selected_nodes: expanded_qubits = state.expand_selection(selected_nodes) for q in expanded_qubits: @@ -365,16 +354,6 @@ def handle_cytoscape_options( 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 - # Record state before applying offcanvas customization - history_data["undo_stack"].append( - { - "action_type": "frontend_customization", - "move_log": move_log or "", - "elements": elements, - } - ) - history_data["redo_stack"] = [] - if is_ungroup_action: # Ungroup action: remove selected group containers and unassign grouped qubits for g_id in selected_group_ids: diff --git a/src/components/components_2d/figure_2d.py b/src/components/components_2d/figure_2d.py index 374f926..24c28bb 100644 --- a/src/components/components_2d/figure_2d.py +++ b/src/components/components_2d/figure_2d.py @@ -18,7 +18,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 +26,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, @@ -195,7 +197,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", @@ -497,6 +499,9 @@ 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"), @@ -509,19 +514,19 @@ def toggle_modal(n1, n2, is_open): Input("modal-tab-reset", "n_clicks"), prevent_initial_call=True, ) -def switch_modal_tab(n_rep, n_log, n_ref, n_reset): +def switch_modal_tab(*_clicks): ctx = callback_context if not ctx.triggered: 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 - elif trigger_id == "modal-tab-ref": - return "ref", False, False, True, False - elif trigger_id == "modal-tab-reset": - return "reset", False, False, False, True - return "rep", True, 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( @@ -532,13 +537,6 @@ def switch_modal_tab(n_rep, n_log, n_ref, n_reset): Input("modal-tab-store", "data"), ) def toggle_modal_content_visibility(active_tab): - styles = [{"display": "none"} for _ in range(4)] - 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"} - return styles[0], styles[1], styles[2], styles[3] + 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_frontend_cluster_state.py b/tests/test_frontend_cluster_state.py index 84582e1..22447eb 100644 --- a/tests/test_frontend_cluster_state.py +++ b/tests/test_frontend_cluster_state.py @@ -214,11 +214,10 @@ def test_explicit_parent_none_in_cytoscape_elements(): state.set_group(1, "g1") 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")) + 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 "parent" in n0["data"] - assert n0["data"]["parent"] is None + assert n0["data"].get("parent") is None assert n1["data"]["parent"] == "g1" From 403cf70bbbfa328e328d5e09b24633fd384ee527 Mon Sep 17 00:00:00 2001 From: Zhihua Han Date: Sun, 5 Jul 2026 00:42:59 +0200 Subject: [PATCH 09/16] chore: simplify codebase --- assets/clientside.js | 5 + .../simulator/frontend_cluster_state.py | 5 - src/components/components_2d/action_panel.py | 192 ++++++++---------- src/components/components_2d/figure_2d.py | 103 +++++----- 4 files changed, 144 insertions(+), 161 deletions(-) diff --git a/assets/clientside.js b/assets/clientside.js index 2ba74c9..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; diff --git a/src/clustersim/simulator/frontend_cluster_state.py b/src/clustersim/simulator/frontend_cluster_state.py index 056cbe3..9a5d6a3 100644 --- a/src/clustersim/simulator/frontend_cluster_state.py +++ b/src/clustersim/simulator/frontend_cluster_state.py @@ -44,11 +44,6 @@ def __init__( # Initialize node metadata list (1-to-1 with qubits 0..n-1) self.node_metadata: List[Dict[str, Any]] = [] - 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): diff --git a/src/components/components_2d/action_panel.py b/src/components/components_2d/action_panel.py index 259f9d4..44fd1cf 100644 --- a/src/components/components_2d/action_panel.py +++ b/src/components/components_2d/action_panel.py @@ -6,6 +6,30 @@ 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 @@ -391,29 +415,10 @@ ], width=1, ), - 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", ) @@ -456,7 +461,6 @@ } - 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": []} @@ -504,16 +508,22 @@ def _normalize_history(history_data: Any) -> dict[str, list[Any]]: 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] + 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(args[-8]) + history_data = _normalize_history(raw_history_data) # Record the current elements state in history for Undo/Redo history_data["undo_stack"].append( @@ -526,7 +536,6 @@ def handle_buttons(*args): 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!", @@ -545,11 +554,11 @@ 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]) + method_args["force"] = int(force_meas_val) selected_nodes = [] for i in selected_node_data: @@ -703,9 +712,6 @@ def apply_operation_wrapper( state.remove_node(expanded_qubits) ui = f"Removed nodes {display_nodes}" - log += f"REMOVE_NODE {' '.join(map(str, expanded_qubits))}\n" - cyto_data_new = state.to_cytoscape_elements() - return ui, cyto_data_new, repr(state.cluster_state), log, history_data elif method_name == "duplicate": resolved_selected = list(expanded_qubits) resolved_selected.sort() @@ -738,6 +744,8 @@ def apply_operation_wrapper( log += f"ADD_NODE {new_node_id}\n" for neighbour in selected_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" @@ -774,6 +782,28 @@ def postprocess_cyto_data_elements( 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="", +): + return ( + cyto_data, + sim_rep, + move_log, + ui_msg, + history_data, + valid, + invalid, + feedback, + ) + + @callback( Output("figure-app", "elements", allow_duplicate=True), Output("simulator-representation", "children", allow_duplicate=True), @@ -815,36 +845,16 @@ def load_graph( history_data = _normalize_history(history_data) - _, 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!", + return _make_load_response( + ui_msg="Cannot load empty input!", + invalid=True, + feedback="Cannot load empty input!", ) 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="Nothing to undo!") # Pop the state to restore prev_state = history_data["undo_stack"].pop() @@ -866,15 +876,12 @@ def load_graph( # 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 ( + return _make_load_response( cyto_data_new, repr(state.cluster_state), move_log, "Undid plot customization!", history_data, - valid, - invalid, - feedback, ) else: # Undoing a quantum operation: restore quantum state while preserving active groups and plot customizations @@ -890,29 +897,17 @@ def load_graph( cyto_data_new = state.to_cytoscape_elements() - return ( + return _make_load_response( cyto_data_new, repr(state.cluster_state), move_log, "Undid operation!", history_data, - valid, - invalid, - feedback, ) 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="Nothing to redo!") # Pop the state to restore next_state = history_data["redo_stack"].pop() @@ -931,33 +926,21 @@ def load_graph( cyto_data_new = next_state["elements"] state = FrontendClusterState.from_cytoscape_elements(cyto_data_new) - return ( + return _make_load_response( cyto_data_new, repr(state.cluster_state), move_log, "Action completed!", history_data, - valid, - invalid, - feedback, ) elif triggered_id == "load-button": try: g, parsed_log = ClusterState.from_string(load_graph_input, return_log=True) - valid = True - invalid = False 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"Failed to load: {err_msg}", invalid=True, feedback=err_msg ) state = FrontendClusterState(num_nodes=len(g), cluster_state=g) @@ -965,27 +948,16 @@ def load_graph( history_data = {"undo_stack": [], "redo_stack": []} cyto_data_new = state.to_cytoscape_elements() - return ( + return _make_load_response( cyto_data_new, repr(g), move_log, "Action completed!", history_data, - valid, - invalid, - feedback, + valid=True, ) - return ( - no_update, - no_update, - no_update, - "Action completed!", - history_data, - valid, - invalid, - feedback, - ) + return _make_load_response(history_data=history_data) @callback( diff --git a/src/components/components_2d/figure_2d.py b/src/components/components_2d/figure_2d.py index 24c28bb..b0fa815 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 @@ -357,13 +406,16 @@ def displaySelectedNodeData(data_list: List[Dict[str, Any]]): return f"Selected {selected_ids}" -@callback( +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( @@ -372,49 +424,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", @@ -467,7 +478,7 @@ def update_stylesheet(_, node_labels: str): }, ] - return label_style + color_styles + selected_style + custom_styles + return label_style + COLOR_STYLES + selected_style + custom_styles @callback( From d9e40332a02b887f1dfce32f3db57c7e1a8eecc8 Mon Sep 17 00:00:00 2001 From: Zhihua Han Date: Sun, 5 Jul 2026 00:46:30 +0200 Subject: [PATCH 10/16] chore: update README --- README.md | 131 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 115 insertions(+), 16 deletions(-) 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). From 3df6ee4091b1c615305e0a807c1860553209aafa Mon Sep 17 00:00:00 2001 From: Zhihua Han Date: Mon, 6 Jul 2026 20:23:08 +0200 Subject: [PATCH 11/16] feat: undo persists through loads --- src/components/components_2d/action_panel.py | 110 ++++++++++++++++--- src/components/components_2d/figure_2d.py | 26 +++++ tests/test_app.py | 73 ++++++++++++ tests/test_frontend_cluster_state.py | 21 ++++ 4 files changed, 213 insertions(+), 17 deletions(-) diff --git a/src/components/components_2d/action_panel.py b/src/components/components_2d/action_panel.py index 44fd1cf..4d18f09 100644 --- a/src/components/components_2d/action_panel.py +++ b/src/components/components_2d/action_panel.py @@ -318,7 +318,7 @@ style={"fontSize": "0.8rem", "padding": "4px 6px"}, ), dbc.Button( - "Del Node", + "Delete Node", outline=True, color="primary", id="remove-node", @@ -330,14 +330,14 @@ dbc.ButtonGroup( [ dbc.Button( - "Toggle", + "Toggle Edge", outline=True, color="primary", id="toggle-edge", style={"fontSize": "0.8rem", "padding": "4px 6px"}, ), dbc.Button( - "Del Edge", + "Delete Edge", outline=True, color="primary", id="remove-edge", @@ -818,6 +818,8 @@ def _make_load_response( Input("redo-button", "n_clicks"), Input("ctrl-y-btn", "n_clicks"), Input("load-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"), @@ -830,6 +832,8 @@ def load_graph( _redo, _ctrl_y, _load, + upload_contents, + upload_filename, load_graph_input, move_log, cyto_data, @@ -845,13 +849,6 @@ def load_graph( history_data = _normalize_history(history_data) - if not load_graph_input and triggered_id == "load-button": - return _make_load_response( - ui_msg="Cannot load empty input!", - invalid=True, - feedback="Cannot load empty input!", - ) - if triggered_id == "undo-button": if not history_data["undo_stack"]: return _make_load_response(ui_msg="Nothing to undo!") @@ -935,24 +932,103 @@ def load_graph( ) elif triggered_id == "load-button": + load_graph_input_str = load_graph_input.strip() + if load_graph_input_str.startswith("{") or load_graph_input_str.startswith("["): + try: + 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" + except Exception as e: + err_msg = str(e) + return _make_load_response( + ui_msg=f"Failed to load JSON: {err_msg}", invalid=True, feedback=err_msg + ) + else: + try: + 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 + except Exception as e: + err_msg = str(e) + return _make_load_response( + ui_msg=f"Failed to load: {err_msg}", invalid=True, feedback=err_msg + ) + + # 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() + + return _make_load_response( + cyto_data_new, + repr(g), + new_move_log, + "Action completed!", + history_data, + valid=True, + ) + + elif triggered_id == "upload-graph-json": + if not upload_contents: + return no_update try: - g, parsed_log = ClusterState.from_string(load_graph_input, return_log=True) + 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 except Exception as e: err_msg = str(e) return _make_load_response( - ui_msg=f"Failed to load: {err_msg}", invalid=True, feedback=err_msg + ui_msg=f"Failed to load file: {err_msg}", invalid=True, feedback=err_msg ) - state = FrontendClusterState(num_nodes=len(g), cluster_state=g) - move_log = parsed_log - history_data = {"undo_stack": [], "redo_stack": []} + # 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() return _make_load_response( cyto_data_new, repr(g), - move_log, - "Action completed!", + new_move_log, + f"Successfully loaded {upload_filename}!", history_data, valid=True, ) diff --git a/src/components/components_2d/figure_2d.py b/src/components/components_2d/figure_2d.py index b0fa815..6d8af05 100644 --- a/src/components/components_2d/figure_2d.py +++ b/src/components/components_2d/figure_2d.py @@ -148,6 +148,32 @@ def _init_state(): ], 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, ) diff --git a/tests/test_app.py b/tests/test_app.py index 9af8e19..012218e 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -166,3 +166,76 @@ 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, + 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) == 8 + cyto_data_new, repr_g, new_move_log, ui_msg, 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, + 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) == 8 + cyto_data_new, repr_g, new_move_log, ui_msg, 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 + diff --git a/tests/test_frontend_cluster_state.py b/tests/test_frontend_cluster_state.py index 22447eb..c1a5931 100644 --- a/tests/test_frontend_cluster_state.py +++ b/tests/test_frontend_cluster_state.py @@ -261,3 +261,24 @@ def test_custom_node_shape_export_and_reconstruction(): 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" From 520d7929c2b05100d40178b11b4eaf6921f81d31 Mon Sep 17 00:00:00 2001 From: Zhihua Han Date: Mon, 6 Jul 2026 20:33:05 +0200 Subject: [PATCH 12/16] fix: duplication group error --- src/components/components_2d/action_panel.py | 53 ++++++++-- src/components/components_2d/figure_2d.py | 9 ++ tests/test_app.py | 103 +++++++++++++++++++ 3 files changed, 158 insertions(+), 7 deletions(-) diff --git a/src/components/components_2d/action_panel.py b/src/components/components_2d/action_panel.py index 4d18f09..83ebf2d 100644 --- a/src/components/components_2d/action_panel.py +++ b/src/components/components_2d/action_panel.py @@ -713,14 +713,50 @@ def apply_operation_wrapper( ui = f"Removed nodes {display_nodes}" elif method_name == "duplicate": + 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: - if q < len(state): - pos = state.get_position(q) - state.add_node(position={"x": pos["x"] + 50, "y": pos["y"] + 50}) + 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) - state.cluster_state = state.cluster_state.duplicate(resolved_selected) ui = f"Duplicated nodes {display_nodes}" else: raise NotImplementedError(f"Do not know {method_name}") @@ -1053,9 +1089,12 @@ def export_graph_to_json(n_clicks, cyto_data): 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/figure_2d.py b/src/components/components_2d/figure_2d.py index 6d8af05..0d6e803 100644 --- a/src/components/components_2d/figure_2d.py +++ b/src/components/components_2d/figure_2d.py @@ -496,6 +496,15 @@ def update_stylesheet(_, node_labels: str): "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": { diff --git a/tests/test_app.py b/tests/test_app.py index 012218e..ff0eb23 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -114,6 +114,109 @@ def test_apply_operation_wrapper_add_node_offset(): 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 From 2c36541ef26cb26eed95facf9390900e67a26355 Mon Sep 17 00:00:00 2001 From: Zhihua Han Date: Mon, 6 Jul 2026 20:53:04 +0200 Subject: [PATCH 13/16] feat: consistent UI --- assets/custom_style.css | 64 +++- src/components/components_2d/action_panel.py | 336 +++++++++---------- src/components/components_2d/figure_2d.py | 18 +- 3 files changed, 223 insertions(+), 195 deletions(-) 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/src/components/components_2d/action_panel.py b/src/components/components_2d/action_panel.py index 83ebf2d..4875681 100644 --- a/src/components/components_2d/action_panel.py +++ b/src/components/components_2d/action_panel.py @@ -35,12 +35,34 @@ # 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( @@ -48,183 +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( [ @@ -235,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( [ @@ -268,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", @@ -297,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( [ @@ -315,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( - "Delete 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 Edge", + "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( - "Delete 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", @@ -382,7 +350,8 @@ ] ), ], - width=3, + width="auto", + className="flex-shrink-0", ), # Column 5: Advanced Options (Gear and Hamburger icon) dbc.Col( @@ -395,9 +364,9 @@ outline=True, color="secondary", style={ - "fontSize": "1.2rem", - "padding": "2px 8px", - "marginBottom": "4px", + "fontSize": "1.1rem", + "padding": "1px 6px", + "marginBottom": "2px", }, title="Plot Customization Options", ), @@ -406,21 +375,22 @@ 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 flex-column align-items-center mt-1", ), ], - width=1, + width="auto", + className="flex-shrink-0", ), *[ dbc.Tooltip(text, target=target, placement="top") for target, text in TOOLTIPS.items() ], ], - className="flex-nowrap", + className="flex-nowrap justify-content-between w-100", ) diff --git a/src/components/components_2d/figure_2d.py b/src/components/components_2d/figure_2d.py index 0d6e803..7e7ff27 100644 --- a/src/components/components_2d/figure_2d.py +++ b/src/components/components_2d/figure_2d.py @@ -367,8 +367,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", }, ), @@ -378,9 +378,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, @@ -388,16 +388,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", ), ] ) From d3b0deb18157c51c42ae4b2f0b5a33b4c0b400fe Mon Sep 17 00:00:00 2001 From: Zhihua Han Date: Mon, 6 Jul 2026 21:11:56 +0200 Subject: [PATCH 14/16] feat: add RESET button, load UI --- src/clustersim/simulator/cluster_state.py | 3 - src/components/components_2d/action_panel.py | 82 ++++++----- src/components/components_2d/figure_2d.py | 8 +- tests/test_app.py | 141 +++++++++++++++++++ 4 files changed, 192 insertions(+), 42 deletions(-) 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/components/components_2d/action_panel.py b/src/components/components_2d/action_panel.py index 4875681..fef44fc 100644 --- a/src/components/components_2d/action_panel.py +++ b/src/components/components_2d/action_panel.py @@ -553,9 +553,15 @@ 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 @@ -824,6 +830,7 @@ def _make_load_response( 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"), @@ -838,6 +845,7 @@ def load_graph( _redo, _ctrl_y, _load, + _reset, upload_contents, upload_filename, load_graph_input, @@ -848,6 +856,10 @@ 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": @@ -938,9 +950,9 @@ def load_graph( ) elif triggered_id == "load-button": - load_graph_input_str = load_graph_input.strip() - if load_graph_input_str.startswith("{") or load_graph_input_str.startswith("["): - try: + try: + 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: @@ -951,32 +963,26 @@ def load_graph( g = ClusterState.from_json(data) state = FrontendClusterState(num_nodes=len(g), cluster_state=g) new_move_log = "# Reconstructed from Graph JSON string\n" - except Exception as e: - err_msg = str(e) - return _make_load_response( - ui_msg=f"Failed to load JSON: {err_msg}", invalid=True, feedback=err_msg - ) - else: - try: + 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 - except Exception as e: - err_msg = str(e) - return _make_load_response( - ui_msg=f"Failed to load: {err_msg}", invalid=True, feedback=err_msg - ) - # 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() + # 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"Failed to load: {err_msg}", invalid=True, feedback=err_msg + ) return _make_load_response( cyto_data_new, @@ -1013,23 +1019,23 @@ def load_graph( 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"Failed to load file: {err_msg}", invalid=True, feedback=err_msg ) - # 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() - return _make_load_response( cyto_data_new, repr(g), diff --git a/src/components/components_2d/figure_2d.py b/src/components/components_2d/figure_2d.py index 7e7ff27..3b501a2 100644 --- a/src/components/components_2d/figure_2d.py +++ b/src/components/components_2d/figure_2d.py @@ -119,7 +119,7 @@ def _init_state(): "", type="invalid", id="load-feedback-invalid" ), ], - width=6, + width=4, ), dbc.Col( dbc.Button( @@ -127,6 +127,12 @@ def _init_state(): ), 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%"} diff --git a/tests/test_app.py b/tests/test_app.py index ff0eb23..044eec3 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -299,6 +299,7 @@ def test_load_graph_callback_json(): _redo=None, _ctrl_y=None, _load=1, + _reset=None, upload_contents=None, upload_filename=None, load_graph_input=target_json_str, @@ -326,6 +327,7 @@ def test_load_graph_callback_json(): _redo=None, _ctrl_y=None, _load=None, + _reset=None, upload_contents=upload_contents_b64, upload_filename="test_graph.json", load_graph_input="", @@ -342,3 +344,142 @@ def test_load_graph_callback_json(): 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, 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, 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, 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, history_data_new, valid, invalid, feedback = res + assert invalid is False + assert valid is True + assert cyto_data_new == [] + + + + From 5dab10f62c1188caedd11f412c9095ddcdd2455f Mon Sep 17 00:00:00 2001 From: Zhihua Han Date: Mon, 6 Jul 2026 21:16:21 +0200 Subject: [PATCH 15/16] fix: IDs working --- src/components/components_2d/action_panel.py | 24 ++++++++--- tests/test_app.py | 43 ++++++++++++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/components/components_2d/action_panel.py b/src/components/components_2d/action_panel.py index fef44fc..d8eb505 100644 --- a/src/components/components_2d/action_panel.py +++ b/src/components/components_2d/action_panel.py @@ -739,27 +739,39 @@ def apply_operation_wrapper( 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(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(state.cluster_state), log, history_data diff --git a/tests/test_app.py b/tests/test_app.py index 044eec3..a07c0fb 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -481,5 +481,48 @@ def test_load_graph_reset_button(): 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, 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 + + + From e9ae546f0313834e43d4e2e33fda16aa12b551aa Mon Sep 17 00:00:00 2001 From: Zhihua Han Date: Mon, 6 Jul 2026 21:25:49 +0200 Subject: [PATCH 16/16] feat: add color ui --- src/components/components_2d/action_panel.py | 36 +++++++++++++++----- tests/test_app.py | 22 ++++++------ 2 files changed, 38 insertions(+), 20 deletions(-) diff --git a/src/components/components_2d/action_panel.py b/src/components/components_2d/action_panel.py index d8eb505..6af570e 100644 --- a/src/components/components_2d/action_panel.py +++ b/src/components/components_2d/action_panel.py @@ -452,6 +452,7 @@ def _normalize_history(history_data: Any) -> dict[str, list[Any]]: @callback( Output("ui", "children"), + Output("ui", "color"), Output("figure-app", "elements", allow_duplicate=True), Output("simulator-representation", "children"), Output("move-log", "children"), @@ -508,7 +509,8 @@ def handle_buttons(*args): if triggered_id == "ctrl-v-btn": if not clipboard_data: return ( - "Clipboard is empty!", + "Error: Clipboard is empty!", + "danger", no_update, no_update, no_update, @@ -567,11 +569,16 @@ def handle_buttons(*args): ) # 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( @@ -605,7 +612,7 @@ def apply_operation_wrapper( 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, @@ -638,7 +645,7 @@ def apply_operation_wrapper( 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, @@ -815,12 +822,22 @@ def _make_load_response( 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, @@ -833,6 +850,7 @@ def _make_load_response( 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), @@ -881,7 +899,7 @@ def load_graph( if triggered_id == "undo-button": if not history_data["undo_stack"]: - return _make_load_response(ui_msg="Nothing to undo!") + return _make_load_response(ui_msg="Error: Nothing to undo!") # Pop the state to restore prev_state = history_data["undo_stack"].pop() @@ -934,7 +952,7 @@ def load_graph( elif triggered_id == "redo-button": if not history_data["redo_stack"]: - return _make_load_response(ui_msg="Nothing to redo!") + return _make_load_response(ui_msg="Error: Nothing to redo!") # Pop the state to restore next_state = history_data["redo_stack"].pop() @@ -993,7 +1011,7 @@ def load_graph( except Exception as e: err_msg = str(e) return _make_load_response( - ui_msg=f"Failed to load: {err_msg}", invalid=True, feedback=err_msg + ui_msg=f"Error: Failed to load: {err_msg}", invalid=True, feedback=err_msg ) return _make_load_response( @@ -1045,7 +1063,7 @@ def load_graph( except Exception as e: err_msg = str(e) return _make_load_response( - ui_msg=f"Failed to load file: {err_msg}", invalid=True, feedback=err_msg + ui_msg=f"Error: Failed to load file: {err_msg}", invalid=True, feedback=err_msg ) return _make_load_response( diff --git a/tests/test_app.py b/tests/test_app.py index a07c0fb..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) @@ -307,8 +307,8 @@ def test_load_graph_callback_json(): cyto_data=initial_elements, history_data=initial_history.copy(), ) - assert len(res) == 8 - cyto_data_new, repr_g, new_move_log, ui_msg, history_data_new, valid, invalid, feedback = res + 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!" @@ -335,8 +335,8 @@ def test_load_graph_callback_json(): cyto_data=initial_elements, history_data=initial_history.copy(), ) - assert len(res) == 8 - cyto_data_new, repr_g, new_move_log, ui_msg, history_data_new, valid, invalid, feedback = res + 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 @@ -373,7 +373,7 @@ def test_load_graph_exception_handling(): cyto_data=initial_elements, history_data=initial_history.copy(), ) - cyto_data_new, repr_g, new_move_log, ui_msg, history_data_new, valid, invalid, feedback = res + 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 == [] @@ -404,7 +404,7 @@ def test_load_graph_exception_handling(): cyto_data=initial_elements, history_data=initial_history.copy(), ) - cyto_data_new, repr_g, new_move_log, ui_msg, history_data_new, valid, invalid, feedback = res + 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 @@ -444,7 +444,7 @@ def test_handle_buttons_edge_grouped_nodes(): ] res = handle_buttons(*args) - ui, cyto_data_new, repr_g, log, history_data = res + ui, color, cyto_data_new, repr_g, log, history_data = res assert "Applied toggle_edge" in ui @@ -475,7 +475,7 @@ def test_load_graph_reset_button(): cyto_data=initial_elements, history_data=initial_history.copy(), ) - cyto_data_new, repr_g, new_move_log, ui_msg, history_data_new, valid, invalid, feedback = res + 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 == [] @@ -517,7 +517,7 @@ def test_move_log_uses_ids_not_values(): ] res = handle_buttons(*args) - ui, cyto_data_new, repr_g, log, history_data = res + 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