New hierarchical component, used for InstancesDebugger.py
This commit is contained in:
185
src/myfasthtml/controls/HierarchicalCanvasGraph.py
Normal file
185
src/myfasthtml/controls/HierarchicalCanvasGraph.py
Normal file
@@ -0,0 +1,185 @@
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from fasthtml.components import Div
|
||||
from fasthtml.xtend import Script
|
||||
|
||||
from myfasthtml.core.dbmanager import DbObject
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
logger = logging.getLogger("HierarchicalCanvasGraph")
|
||||
|
||||
|
||||
@dataclass
|
||||
class HierarchicalCanvasGraphConf:
|
||||
"""Configuration for HierarchicalCanvasGraph control.
|
||||
|
||||
Attributes:
|
||||
nodes: List of node dictionaries with keys: id, label, type, kind
|
||||
edges: List of edge dictionaries with keys: from, to
|
||||
events_handlers: Optional dict mapping event names to Command objects
|
||||
Supported events: 'select_node', 'toggle_node'
|
||||
"""
|
||||
nodes: list[dict]
|
||||
edges: list[dict]
|
||||
events_handlers: Optional[dict] = None
|
||||
|
||||
|
||||
class HierarchicalCanvasGraphState(DbObject):
|
||||
"""Persistent state for HierarchicalCanvasGraph.
|
||||
|
||||
Only the collapsed state is persisted. Zoom, pan, and selection are ephemeral.
|
||||
"""
|
||||
|
||||
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 = []
|
||||
|
||||
# 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 HierarchicalCanvasGraph(MultipleInstance):
|
||||
"""A canvas-based hierarchical graph visualization control.
|
||||
|
||||
Displays nodes and edges in a tree layout with expand/collapse functionality.
|
||||
Uses HTML5 Canvas for rendering with stable zoom/pan and search filtering.
|
||||
|
||||
Features:
|
||||
- Reingold-Tilford hierarchical layout
|
||||
- Expand/collapse nodes with children
|
||||
- Zoom and pan with mouse wheel and drag
|
||||
- Search/filter nodes by label or kind
|
||||
- Click to select nodes
|
||||
- Dot grid background
|
||||
- Stable zoom on container resize
|
||||
|
||||
Events:
|
||||
- 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.
|
||||
|
||||
Args:
|
||||
parent: Parent instance
|
||||
conf: Configuration object with nodes, edges, and event handlers
|
||||
_id: Optional custom ID (auto-generated if not provided)
|
||||
"""
|
||||
super().__init__(parent, _id=_id)
|
||||
self.conf = conf
|
||||
self._state = HierarchicalCanvasGraphState(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.
|
||||
|
||||
Returns:
|
||||
HierarchicalCanvasGraphState: The state object
|
||||
"""
|
||||
return self._state
|
||||
|
||||
def get_selected_id(self) -> Optional[str]:
|
||||
"""Get the currently selected node ID.
|
||||
|
||||
Returns:
|
||||
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.
|
||||
|
||||
Args:
|
||||
node_ids: Set of node IDs to mark as collapsed
|
||||
"""
|
||||
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.
|
||||
|
||||
Args:
|
||||
node_id: The ID of the node to toggle
|
||||
|
||||
Returns:
|
||||
self: For chaining
|
||||
"""
|
||||
collapsed_set = set(self._state.collapsed)
|
||||
if node_id in collapsed_set:
|
||||
collapsed_set.remove(node_id)
|
||||
logger.debug(f"toggle_node: expanded {node_id}")
|
||||
else:
|
||||
collapsed_set.add(node_id)
|
||||
logger.debug(f"toggle_node: collapsed {node_id}")
|
||||
|
||||
self._state.collapsed = list(collapsed_set)
|
||||
return self
|
||||
|
||||
def _prepare_options(self) -> dict:
|
||||
"""Prepare JavaScript options object.
|
||||
|
||||
Returns:
|
||||
dict: Options to pass to the JS initialization function
|
||||
"""
|
||||
# Convert event handlers to HTMX options
|
||||
events = {}
|
||||
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
|
||||
}
|
||||
|
||||
def render(self):
|
||||
"""Render the HierarchicalCanvasGraph control.
|
||||
|
||||
Returns:
|
||||
Div: The rendered control with canvas and initialization script
|
||||
"""
|
||||
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() {{
|
||||
if (typeof initHierarchicalCanvasGraph === 'function') {{
|
||||
initHierarchicalCanvasGraph('{self._id}_container', {options_json});
|
||||
}} else {{
|
||||
console.error('initHierarchicalCanvasGraph function not found');
|
||||
}}
|
||||
}})();
|
||||
"""),
|
||||
|
||||
id=self._id,
|
||||
cls="mf-hierarchical-canvas-graph"
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
"""FastHTML magic method for rendering.
|
||||
|
||||
Returns:
|
||||
Div: The rendered control
|
||||
"""
|
||||
return self.render()
|
||||
@@ -1,55 +1,132 @@
|
||||
from myfasthtml.controls.HierarchicalCanvasGraph import HierarchicalCanvasGraph, HierarchicalCanvasGraphConf
|
||||
from myfasthtml.controls.Panel import Panel
|
||||
from myfasthtml.controls.Properties import Properties
|
||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
||||
from myfasthtml.core.vis_network_utils import from_parent_child_list
|
||||
from myfasthtml.core.instances import SingleInstance, UniqueInstance, MultipleInstance, InstancesManager
|
||||
|
||||
|
||||
class InstancesDebugger(SingleInstance):
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self._panel = Panel(self, _id="-panel")
|
||||
self._command = Command("ShowInstance",
|
||||
"Display selected Instance",
|
||||
self,
|
||||
self.on_network_event).htmx(target=f"#{self._panel.get_ids().right}")
|
||||
|
||||
self._select_command = Command("ShowInstance",
|
||||
"Display selected Instance",
|
||||
self,
|
||||
self.on_select_node).htmx(target=f"#{self._panel.get_ids().right}")
|
||||
|
||||
def render(self):
|
||||
nodes, edges = self._get_nodes_and_edges()
|
||||
vis_network = VisNetwork(self, nodes=nodes, edges=edges, _id="-vis", events_handlers={"select_node": self._command})
|
||||
return self._panel.set_main(vis_network)
|
||||
|
||||
def on_network_event(self, event_data: dict):
|
||||
parts = event_data["nodes"][0].split("#")
|
||||
graph_conf = HierarchicalCanvasGraphConf(
|
||||
nodes=nodes,
|
||||
edges=edges,
|
||||
events_handlers={
|
||||
"select_node": self._select_command
|
||||
}
|
||||
)
|
||||
canvas_graph = HierarchicalCanvasGraph(self, conf=graph_conf, _id="-canvas-graph")
|
||||
return self._panel.set_main(canvas_graph)
|
||||
|
||||
def on_select_node(self, event_data: dict):
|
||||
"""Handle node selection event from canvas graph.
|
||||
|
||||
Args:
|
||||
event_data: dict with keys: node_id, label, type, kind
|
||||
"""
|
||||
node_id = event_data.get("node_id")
|
||||
if not node_id:
|
||||
return None
|
||||
|
||||
# Parse full ID (session#instance_id)
|
||||
parts = node_id.split("#")
|
||||
session = parts[0]
|
||||
instance_id = "#".join(parts[1:])
|
||||
properties_def = {"Main": {"Id": "_id", "Parent Id": "_parent._id"},
|
||||
"State": {"_name": "_state._name", "*": "_state"},
|
||||
"Commands": {"*": "commands"},
|
||||
}
|
||||
|
||||
properties_def = {
|
||||
"Main": {"Id": "_id", "Parent Id": "_parent._id"},
|
||||
"State": {"_name": "_state._name", "*": "_state"},
|
||||
"Commands": {"*": "commands"},
|
||||
}
|
||||
|
||||
return self._panel.set_right(Properties(self,
|
||||
InstancesManager.get(session, instance_id),
|
||||
properties_def,
|
||||
_id="-properties"))
|
||||
|
||||
|
||||
def _get_instance_type(self, instance) -> str:
|
||||
"""Determine the instance type for visualization.
|
||||
|
||||
Args:
|
||||
instance: The instance object
|
||||
|
||||
Returns:
|
||||
str: One of 'root', 'single', 'unique', 'multiple'
|
||||
"""
|
||||
# Check if it's the RootInstance (special singleton)
|
||||
if instance.get_parent() is None and instance.get_id() == "mf":
|
||||
return 'root'
|
||||
elif isinstance(instance, SingleInstance):
|
||||
return 'single'
|
||||
elif isinstance(instance, UniqueInstance):
|
||||
return 'unique'
|
||||
elif isinstance(instance, MultipleInstance):
|
||||
return 'multiple'
|
||||
else:
|
||||
return 'multiple' # Default
|
||||
|
||||
def _get_nodes_and_edges(self):
|
||||
"""Build nodes and edges from current instances.
|
||||
|
||||
Returns:
|
||||
tuple: (nodes, edges) where nodes include id, label, type, kind
|
||||
"""
|
||||
instances = self._get_instances()
|
||||
nodes, edges = from_parent_child_list(
|
||||
instances,
|
||||
id_getter=lambda x: x.get_full_id(),
|
||||
label_getter=lambda x: f"{x.get_id()}",
|
||||
parent_getter=lambda x: x.get_parent_full_id()
|
||||
)
|
||||
for edge in edges:
|
||||
edge["color"] = "green"
|
||||
edge["arrows"] = {"to": {"enabled": False, "type": "circle"}}
|
||||
|
||||
for node in nodes:
|
||||
node["shape"] = "box"
|
||||
|
||||
|
||||
nodes = []
|
||||
edges = []
|
||||
existing_ids = set()
|
||||
|
||||
# Create nodes with type and kind information
|
||||
for instance in instances:
|
||||
node_id = instance.get_full_id()
|
||||
existing_ids.add(node_id)
|
||||
|
||||
nodes.append({
|
||||
"id": node_id,
|
||||
"label": instance.get_id(),
|
||||
"type": self._get_instance_type(instance),
|
||||
"kind": instance.__class__.__name__
|
||||
})
|
||||
|
||||
# Track nodes with parents
|
||||
nodes_with_parent = set()
|
||||
|
||||
# Create edges
|
||||
for instance in instances:
|
||||
node_id = instance.get_full_id()
|
||||
parent_id = instance.get_parent_full_id()
|
||||
|
||||
if parent_id is None or parent_id == "":
|
||||
continue
|
||||
|
||||
nodes_with_parent.add(node_id)
|
||||
|
||||
edges.append({
|
||||
"from": parent_id,
|
||||
"to": node_id
|
||||
})
|
||||
|
||||
# Create ghost node if parent not in existing instances
|
||||
if parent_id not in existing_ids:
|
||||
nodes.append({
|
||||
"id": parent_id,
|
||||
"label": f"Ghost: {parent_id}",
|
||||
"type": "multiple", # Default type for ghost nodes
|
||||
"kind": "Ghost"
|
||||
})
|
||||
existing_ids.add(parent_id)
|
||||
|
||||
return nodes, edges
|
||||
|
||||
|
||||
def _get_instances(self):
|
||||
return list(InstancesManager.instances.values())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user