updated css. Added orientation. Saving orientation, position and scale in state
This commit is contained in:
@@ -6,6 +6,8 @@ from typing import Optional
|
||||
from fasthtml.components import Div
|
||||
from fasthtml.xtend import Script
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.dbmanager import DbObject
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
@@ -30,20 +32,39 @@ class HierarchicalCanvasGraphConf:
|
||||
class HierarchicalCanvasGraphState(DbObject):
|
||||
"""Persistent state for HierarchicalCanvasGraph.
|
||||
|
||||
Only the collapsed state is persisted. Zoom, pan, and selection are ephemeral.
|
||||
Persists collapsed nodes, view transform (zoom/pan), and layout orientation.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, owner, save_state=True):
|
||||
super().__init__(owner, save_state=save_state)
|
||||
with self.initializing():
|
||||
# Persisted: set of collapsed node IDs (stored as list for JSON serialization)
|
||||
self.collapsed: list = []
|
||||
|
||||
|
||||
# Persisted: zoom/pan transform
|
||||
self.transform: dict = {"x": 0, "y": 0, "scale": 1}
|
||||
|
||||
# Persisted: layout orientation ('horizontal' or 'vertical')
|
||||
self.layout_mode: str = 'horizontal'
|
||||
|
||||
# Not persisted: current selection (ephemeral)
|
||||
self.ns_selected_id: Optional[str] = None
|
||||
|
||||
# Not persisted: zoom/pan transform (ephemeral)
|
||||
self.ns_transform: dict = {"x": 0, "y": 0, "scale": 1}
|
||||
|
||||
class Commands(BaseCommands):
|
||||
"""Commands for HierarchicalCanvasGraph internal state management."""
|
||||
|
||||
def update_view_state(self):
|
||||
"""Update view transform and layout mode.
|
||||
|
||||
This command is called internally by the JS to persist view state changes.
|
||||
"""
|
||||
return Command(
|
||||
"UpdateViewState",
|
||||
"Update view transform and layout mode",
|
||||
self._owner,
|
||||
self._owner._handle_update_view_state
|
||||
).htmx(target=f"#{self._id}", swap='none')
|
||||
|
||||
|
||||
class HierarchicalCanvasGraph(MultipleInstance):
|
||||
@@ -65,7 +86,7 @@ class HierarchicalCanvasGraph(MultipleInstance):
|
||||
- select_node: Fired when a node is clicked (not on toggle button)
|
||||
- toggle_node: Fired when a node's expand/collapse button is clicked
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, parent, conf: HierarchicalCanvasGraphConf, _id=None):
|
||||
"""Initialize the HierarchicalCanvasGraph control.
|
||||
|
||||
@@ -77,10 +98,11 @@ class HierarchicalCanvasGraph(MultipleInstance):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.conf = conf
|
||||
self._state = HierarchicalCanvasGraphState(self)
|
||||
|
||||
self.commands = Commands(self)
|
||||
|
||||
logger.debug(f"HierarchicalCanvasGraph created with id={self._id}, "
|
||||
f"nodes={len(conf.nodes)}, edges={len(conf.edges)}")
|
||||
|
||||
|
||||
def get_state(self):
|
||||
"""Get the control's persistent state.
|
||||
|
||||
@@ -88,7 +110,7 @@ class HierarchicalCanvasGraph(MultipleInstance):
|
||||
HierarchicalCanvasGraphState: The state object
|
||||
"""
|
||||
return self._state
|
||||
|
||||
|
||||
def get_selected_id(self) -> Optional[str]:
|
||||
"""Get the currently selected node ID.
|
||||
|
||||
@@ -96,7 +118,7 @@ class HierarchicalCanvasGraph(MultipleInstance):
|
||||
str or None: The selected node ID, or None if no selection
|
||||
"""
|
||||
return self._state.ns_selected_id
|
||||
|
||||
|
||||
def set_collapsed(self, node_ids: set):
|
||||
"""Set the collapsed state of nodes.
|
||||
|
||||
@@ -105,7 +127,7 @@ class HierarchicalCanvasGraph(MultipleInstance):
|
||||
"""
|
||||
self._state.collapsed = list(node_ids)
|
||||
logger.debug(f"set_collapsed: {len(node_ids)} nodes collapsed")
|
||||
|
||||
|
||||
def toggle_node(self, node_id: str):
|
||||
"""Toggle the collapsed state of a node.
|
||||
|
||||
@@ -122,10 +144,29 @@ class HierarchicalCanvasGraph(MultipleInstance):
|
||||
else:
|
||||
collapsed_set.add(node_id)
|
||||
logger.debug(f"toggle_node: collapsed {node_id}")
|
||||
|
||||
|
||||
self._state.collapsed = list(collapsed_set)
|
||||
return self
|
||||
|
||||
def _handle_update_view_state(self, event_data: dict):
|
||||
"""Internal handler to update view state from client.
|
||||
|
||||
Args:
|
||||
event_data: Dictionary with 'transform' and/or 'layout_mode' keys
|
||||
|
||||
Returns:
|
||||
str: Empty string (no UI update needed)
|
||||
"""
|
||||
if 'transform' in event_data:
|
||||
self._state.transform = event_data['transform']
|
||||
logger.debug(f"Transform updated: {self._state.transform}")
|
||||
|
||||
if 'layout_mode' in event_data:
|
||||
self._state.layout_mode = event_data['layout_mode']
|
||||
logger.debug(f"Layout mode updated: {self._state.layout_mode}")
|
||||
|
||||
return ""
|
||||
|
||||
def _prepare_options(self) -> dict:
|
||||
"""Prepare JavaScript options object.
|
||||
|
||||
@@ -134,17 +175,24 @@ class HierarchicalCanvasGraph(MultipleInstance):
|
||||
"""
|
||||
# Convert event handlers to HTMX options
|
||||
events = {}
|
||||
|
||||
# Add internal handler for view state persistence
|
||||
events['_internal_update_state'] = self.commands.update_view_state().ajax_htmx_options()
|
||||
|
||||
# Add user-provided event handlers
|
||||
if self.conf.events_handlers:
|
||||
for event_name, command in self.conf.events_handlers.items():
|
||||
events[event_name] = command.ajax_htmx_options()
|
||||
|
||||
|
||||
return {
|
||||
"nodes": self.conf.nodes,
|
||||
"edges": self.conf.edges,
|
||||
"collapsed": self._state.collapsed,
|
||||
"events": events
|
||||
"nodes": self.conf.nodes,
|
||||
"edges": self.conf.edges,
|
||||
"collapsed": self._state.collapsed,
|
||||
"transform": self._state.transform,
|
||||
"layout_mode": self._state.layout_mode,
|
||||
"events": events
|
||||
}
|
||||
|
||||
|
||||
def render(self):
|
||||
"""Render the HierarchicalCanvasGraph control.
|
||||
|
||||
@@ -153,14 +201,14 @@ class HierarchicalCanvasGraph(MultipleInstance):
|
||||
"""
|
||||
options = self._prepare_options()
|
||||
options_json = json.dumps(options, indent=2)
|
||||
|
||||
|
||||
return Div(
|
||||
# Canvas element (sized by JS to fill container)
|
||||
Div(
|
||||
id=f"{self._id}_container",
|
||||
cls="mf-hcg-container"
|
||||
),
|
||||
|
||||
|
||||
# Initialization script
|
||||
Script(f"""
|
||||
(function() {{
|
||||
@@ -171,11 +219,11 @@ class HierarchicalCanvasGraph(MultipleInstance):
|
||||
}}
|
||||
}})();
|
||||
"""),
|
||||
|
||||
|
||||
id=self._id,
|
||||
cls="mf-hierarchical-canvas-graph"
|
||||
)
|
||||
|
||||
|
||||
def __ft__(self):
|
||||
"""FastHTML magic method for rendering.
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from myfasthtml.controls.HierarchicalCanvasGraph import HierarchicalCanvasGraph, HierarchicalCanvasGraphConf
|
||||
from myfasthtml.controls.Panel import Panel
|
||||
from myfasthtml.controls.Properties import Properties
|
||||
@@ -5,9 +7,22 @@ from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import SingleInstance, UniqueInstance, MultipleInstance, InstancesManager
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstancesDebuggerConf:
|
||||
"""Configuration for InstancesDebugger control.
|
||||
|
||||
Attributes:
|
||||
group_siblings_by_type: If True, sibling nodes (same parent) are grouped
|
||||
by their type for easier visual identification.
|
||||
Useful for detecting memory leaks. Default: True.
|
||||
"""
|
||||
group_siblings_by_type: bool = True
|
||||
|
||||
|
||||
class InstancesDebugger(SingleInstance):
|
||||
def __init__(self, parent, _id=None):
|
||||
def __init__(self, parent, conf: InstancesDebuggerConf = None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.conf = conf if conf is not None else InstancesDebuggerConf()
|
||||
self._panel = Panel(self, _id="-panel")
|
||||
self._select_command = Command("ShowInstance",
|
||||
"Display selected Instance",
|
||||
@@ -30,7 +45,7 @@ class InstancesDebugger(SingleInstance):
|
||||
"""Handle node selection event from canvas graph.
|
||||
|
||||
Args:
|
||||
event_data: dict with keys: node_id, label, type, kind
|
||||
event_data: dict with keys: node_id, label, kind, type
|
||||
"""
|
||||
node_id = event_data.get("node_id")
|
||||
if not node_id:
|
||||
@@ -52,8 +67,8 @@ class InstancesDebugger(SingleInstance):
|
||||
properties_def,
|
||||
_id="-properties"))
|
||||
|
||||
def _get_instance_type(self, instance) -> str:
|
||||
"""Determine the instance type for visualization.
|
||||
def _get_instance_kind(self, instance) -> str:
|
||||
"""Determine the instance kind for visualization.
|
||||
|
||||
Args:
|
||||
instance: The instance object
|
||||
@@ -77,7 +92,7 @@ class InstancesDebugger(SingleInstance):
|
||||
"""Build nodes and edges from current instances.
|
||||
|
||||
Returns:
|
||||
tuple: (nodes, edges) where nodes include id, label, type, kind
|
||||
tuple: (nodes, edges) where nodes include id, label, kind, type
|
||||
"""
|
||||
instances = self._get_instances()
|
||||
|
||||
@@ -85,7 +100,7 @@ class InstancesDebugger(SingleInstance):
|
||||
edges = []
|
||||
existing_ids = set()
|
||||
|
||||
# Create nodes with type and kind information
|
||||
# Create nodes with kind (instance kind) and type (class name)
|
||||
for instance in instances:
|
||||
node_id = instance.get_full_id()
|
||||
existing_ids.add(node_id)
|
||||
@@ -93,8 +108,8 @@ class InstancesDebugger(SingleInstance):
|
||||
nodes.append({
|
||||
"id": node_id,
|
||||
"label": instance.get_id(),
|
||||
"type": self._get_instance_type(instance),
|
||||
"kind": instance.__class__.__name__
|
||||
"kind": self._get_instance_kind(instance),
|
||||
"type": instance.__class__.__name__
|
||||
})
|
||||
|
||||
# Track nodes with parents
|
||||
@@ -120,13 +135,48 @@ class InstancesDebugger(SingleInstance):
|
||||
nodes.append({
|
||||
"id": parent_id,
|
||||
"label": f"Ghost: {parent_id}",
|
||||
"type": "multiple", # Default type for ghost nodes
|
||||
"kind": "Ghost"
|
||||
"kind": "multiple", # Default kind for ghost nodes
|
||||
"type": "Ghost"
|
||||
})
|
||||
existing_ids.add(parent_id)
|
||||
|
||||
# Group siblings by type if configured
|
||||
if self.conf.group_siblings_by_type:
|
||||
edges = self._sort_edges_by_sibling_type(nodes, edges)
|
||||
|
||||
return nodes, edges
|
||||
|
||||
def _sort_edges_by_sibling_type(self, nodes, edges):
|
||||
"""Sort edges so that siblings (same parent) are grouped by type.
|
||||
|
||||
Args:
|
||||
nodes: List of node dictionaries
|
||||
edges: List of edge dictionaries
|
||||
|
||||
Returns:
|
||||
list: Sorted edges with siblings grouped by type
|
||||
"""
|
||||
from collections import defaultdict
|
||||
|
||||
# Create mapping node_id -> type for quick lookup
|
||||
node_types = {node["id"]: node["type"] for node in nodes}
|
||||
|
||||
# Group edges by parent
|
||||
edges_by_parent = defaultdict(list)
|
||||
for edge in edges:
|
||||
edges_by_parent[edge["from"]].append(edge)
|
||||
|
||||
# Sort each parent's children by type and rebuild edges list
|
||||
sorted_edges = []
|
||||
for parent_id in edges_by_parent:
|
||||
parent_edges = sorted(
|
||||
edges_by_parent[parent_id],
|
||||
key=lambda e: node_types.get(e["to"], "")
|
||||
)
|
||||
sorted_edges.extend(parent_edges)
|
||||
|
||||
return sorted_edges
|
||||
|
||||
def _get_instances(self):
|
||||
return list(InstancesManager.instances.values())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user