New hierarchical component, used for InstancesDebugger.py

This commit is contained in:
2026-02-21 23:53:05 +01:00
parent 9a25591edf
commit 44691be30f
9 changed files with 1789 additions and 32 deletions

View 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()

View File

@@ -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())