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,99 @@
/**
* Hierarchical Canvas Graph Styles
*
* Styles for the canvas-based hierarchical graph visualization control.
*/
/* Main control wrapper */
.mf-hierarchical-canvas-graph {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
}
/* Container that holds the canvas */
.mf-hcg-container {
flex: 1;
position: relative;
overflow: hidden;
background: #0d1117;
width: 100%;
height: 100%;
}
/* Toggle button positioned absolutely within container */
.mf-hcg-container button {
font-family: inherit;
user-select: none;
}
/* Canvas element (sized by JavaScript) */
.mf-hcg-container canvas {
display: block;
width: 100%;
height: 100%;
cursor: grab;
}
.mf-hcg-container canvas:active {
cursor: grabbing;
}
/* Optional: toolbar/controls overlay (if needed in future) */
.mf-hcg-toolbar {
position: absolute;
top: 12px;
left: 12px;
background: rgba(22, 27, 34, 0.92);
border: 1px solid #30363d;
border-radius: 8px;
padding: 6px;
display: flex;
gap: 6px;
align-items: center;
backdrop-filter: blur(4px);
z-index: 10;
}
/* Layout toggle button */
.mf-hcg-toggle-btn {
position: absolute;
top: 12px;
right: 12px;
width: 32px;
height: 32px;
background: rgba(22, 27, 34, 0.92);
border: 1px solid #30363d;
border-radius: 6px;
color: #7d8590;
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
transition: color 0.15s, background 0.15s;
z-index: 10;
padding: 0;
line-height: 1;
}
.mf-hcg-toggle-btn:hover {
color: #e6edf3;
background: #1c2128;
}
/* Optional: loading state */
.mf-hcg-container.loading::after {
content: 'Loading...';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #7d8590;
font-size: 14px;
font-family: system-ui, sans-serif;
}

View File

@@ -0,0 +1,617 @@
/**
* Hierarchical Canvas Graph
*
* Canvas-based visualization for hierarchical graph data with expand/collapse.
* Features: Reingold-Tilford layout, zoom/pan, search filter, dot grid background.
*/
/**
* Initialize hierarchical canvas graph visualization.
*
* Creates an interactive canvas-based hierarchical graph with the following features:
* - Reingold-Tilford tree layout algorithm
* - Expand/collapse nodes with children
* - Zoom (mouse wheel) and pan (drag) controls
* - Layout mode toggle (horizontal/vertical)
* - Search/filter nodes by label or kind
* - Click events for node selection and toggle
* - Stable zoom on container resize
* - Dot grid background (Figma-style)
*
* @param {string} containerId - The ID of the container div element
* @param {Object} options - Configuration options
* @param {Array<Object>} options.nodes - Array of node objects with properties:
* @param {string} options.nodes[].id - Unique node identifier
* @param {string} options.nodes[].label - Display label
* @param {string} options.nodes[].type - Node type (root|single|unique|multiple)
* @param {string} options.nodes[].kind - Node kind/class name
* @param {Array<Object>} options.edges - Array of edge objects with properties:
* @param {string} options.edges[].from - Source node ID
* @param {string} options.edges[].to - Target node ID
* @param {Array<string>} [options.collapsed=[]] - Array of initially collapsed node IDs
* @param {Object} [options.events={}] - Event handlers mapping event names to HTMX options:
* @param {Object} [options.events.select_node] - Handler for node selection (click on node)
* @param {Object} [options.events.toggle_node] - Handler for expand/collapse toggle
*
* @returns {void}
*
* @example
* initHierarchicalCanvasGraph('graph-container', {
* nodes: [
* { id: 'root', label: 'Root', type: 'root', kind: 'RootInstance' },
* { id: 'child', label: 'Child', type: 'single', kind: 'MyComponent' }
* ],
* edges: [{ from: 'root', to: 'child' }],
* collapsed: [],
* events: {
* select_node: { url: '/api/select', target: '#panel', swap: 'innerHTML' }
* }
* });
*/
function initHierarchicalCanvasGraph(containerId, options = {}) {
const container = document.getElementById(containerId);
if (!container) {
console.error(`HierarchicalCanvasGraph: Container "${containerId}" not found`);
return;
}
// Prevent double initialization
if (container._hcgInitialized) {
console.warn(`HierarchicalCanvasGraph: Container "${containerId}" already initialized`);
return;
}
container._hcgInitialized = true;
// ═══════════════════════════════════════════════════════════
// Configuration & Constants
// ═══════════════════════════════════════════════════════════
const NODES = options.nodes || [];
const EDGES = options.edges || [];
const EVENTS = options.events || {};
// ═══════════════════════════════════════════════════════════
// Visual Constants
// ═══════════════════════════════════════════════════════════
const NODE_W = 178; // Node width in pixels
const NODE_H = 36; // Node height in pixels
const CHEV_ZONE = 26; // Toggle button hit area width (rightmost px of node)
const TOGGLE_BTN_SIZE = 32; // Toggle button dimensions
const TOGGLE_BTN_POS = 12; // Toggle button offset from corner
const FIT_PADDING = 48; // Padding around graph when fitting
const FIT_MAX_SCALE = 1.5; // Maximum zoom level when fitting
const DOT_GRID_SPACING = 24; // Dot grid spacing in pixels
const DOT_GRID_RADIUS = 0.9; // Dot radius in pixels
const ZOOM_FACTOR = 1.12; // Zoom multiplier per wheel tick
const ZOOM_MIN = 0.12; // Minimum zoom level
const ZOOM_MAX = 3.5; // Maximum zoom level
// Spacing constants (adjusted per mode)
const HORIZONTAL_MODE_SPACING = {
levelGap: 84, // vertical distance between parent-child levels
siblingGap: 22 // gap between siblings (in addition to NODE_W)
};
const VERTICAL_MODE_SPACING = {
levelGap: 220, // horizontal distance between parent-child (after swap)
siblingGap: 14 // gap between siblings (in addition to NODE_H)
};
function getSpacing() {
return layoutMode === 'vertical' ? VERTICAL_MODE_SPACING : HORIZONTAL_MODE_SPACING;
}
const TYPE_COLOR = {
root: '#2563eb',
single: '#7c3aed',
multiple: '#047857',
unique: '#b45309',
};
// ═══════════════════════════════════════════════════════════
// Graph structure
// ═══════════════════════════════════════════════════════════
const childMap = {};
const hasParentSet = new Set();
for (const n of NODES) childMap[n.id] = [];
for (const e of EDGES) {
(childMap[e.from] = childMap[e.from] || []).push(e.to);
hasParentSet.add(e.to);
}
function hasChildren(id) {
return (childMap[id] || []).length > 0;
}
// ═══════════════════════════════════════════════════════════
// State
// ═══════════════════════════════════════════════════════════
const collapsed = new Set(options.collapsed || []);
let selectedId = null;
let filterQuery = '';
let transform = { x: 0, y: 0, scale: 1 };
let pos = {};
let layoutMode = 'horizontal'; // 'horizontal' | 'vertical'
// ═══════════════════════════════════════════════════════════
// Visibility & Layout
// ═══════════════════════════════════════════════════════════
function getHiddenSet() {
const hidden = new Set();
function addDesc(id) {
for (const c of childMap[id] || []) {
if (!hidden.has(c)) { hidden.add(c); addDesc(c); }
}
}
for (const id of collapsed) addDesc(id);
return hidden;
}
function visNodes() {
const h = getHiddenSet();
return NODES.filter(n => !h.has(n.id));
}
function visEdges(vn) {
const vi = new Set(vn.map(n => n.id));
return EDGES.filter(e => vi.has(e.from) && vi.has(e.to));
}
/**
* Compute hierarchical layout using Reingold-Tilford algorithm (simplified)
*/
function computeLayout(nodes, edges) {
const spacing = getSpacing();
const cm = {};
const hp = new Set();
for (const n of nodes) cm[n.id] = [];
for (const e of edges) { (cm[e.from] = cm[e.from] || []).push(e.to); hp.add(e.to); }
const roots = nodes.filter(n => !hp.has(n.id)).map(n => n.id);
// Assign depth via BFS
const depth = {};
const q = roots.map(r => [r, 0]);
while (q.length) {
const [id, d] = q.shift();
if (depth[id] !== undefined) continue;
depth[id] = d;
for (const c of cm[id] || []) q.push([c, d + 1]);
}
// Assign positions
const positions = {};
for (const n of nodes) positions[n.id] = { x: 0, y: (depth[n.id] || 0) * spacing.levelGap };
// Sibling stride: in vertical mode, use NODE_H (height); in horizontal mode, use NODE_W (width)
const siblingStride = layoutMode === 'vertical'
? NODE_H + spacing.siblingGap // Vertical: nodes stack by height
: NODE_W + spacing.siblingGap; // Horizontal: nodes spread by width
// DFS to assign x (sibling spacing)
let slot = 0;
function dfs(id) {
const children = cm[id] || [];
if (children.length === 0) { positions[id].x = slot++ * siblingStride; return; }
for (const c of children) dfs(c);
const xs = children.map(c => positions[c].x);
positions[id].x = (Math.min(...xs) + Math.max(...xs)) / 2;
}
for (const r of roots) dfs(r);
return positions;
}
function recomputeLayout() {
const vn = visNodes();
pos = computeLayout(vn, visEdges(vn));
}
/**
* Transform position based on current layout mode
* In vertical mode: swap x and y so tree grows left-to-right instead of top-to-bottom
* @param {Object} p - Position {x, y}
* @returns {Object} - Transformed position
*/
function transformPos(p) {
if (layoutMode === 'vertical') {
// Swap x and y: horizontal spread becomes vertical, depth becomes horizontal
return { x: p.y, y: p.x };
}
return p;
}
// ═══════════════════════════════════════════════════════════
// Canvas Setup
// ═══════════════════════════════════════════════════════════
const canvas = document.createElement('canvas');
canvas.id = `${containerId}_canvas`;
canvas.style.cssText = 'display:block; width:100%; height:100%; cursor:grab;';
container.appendChild(canvas);
const ctx = canvas.getContext('2d');
// Layout toggle button overlay
const toggleBtn = document.createElement('button');
toggleBtn.className = 'mf-hcg-toggle-btn';
toggleBtn.innerHTML = '⇄';
toggleBtn.title = 'Toggle layout orientation';
toggleBtn.setAttribute('aria-label', 'Toggle between horizontal and vertical layout');
toggleBtn.addEventListener('click', () => {
layoutMode = layoutMode === 'horizontal' ? 'vertical' : 'horizontal';
toggleBtn.innerHTML = layoutMode === 'horizontal' ? '⇄' : '⇅';
toggleBtn.title = `Switch to ${layoutMode === 'horizontal' ? 'vertical' : 'horizontal'} layout`;
toggleBtn.setAttribute('aria-label', `Switch to ${layoutMode === 'horizontal' ? 'vertical' : 'horizontal'} layout`);
// Recompute layout with new spacing
recomputeLayout();
fitAll();
});
container.appendChild(toggleBtn);
function resize() {
canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
draw();
}
// ═══════════════════════════════════════════════════════════
// Drawing
// ═══════════════════════════════════════════════════════════
function drawDotGrid() {
const ox = ((transform.x % DOT_GRID_SPACING) + DOT_GRID_SPACING) % DOT_GRID_SPACING;
const oy = ((transform.y % DOT_GRID_SPACING) + DOT_GRID_SPACING) % DOT_GRID_SPACING;
ctx.fillStyle = 'rgba(125,133,144,0.12)';
for (let x = ox - DOT_GRID_SPACING; x < canvas.width + DOT_GRID_SPACING; x += DOT_GRID_SPACING) {
for (let y = oy - DOT_GRID_SPACING; y < canvas.height + DOT_GRID_SPACING; y += DOT_GRID_SPACING) {
ctx.beginPath();
ctx.arc(x, y, DOT_GRID_RADIUS, 0, Math.PI * 2);
ctx.fill();
}
}
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawDotGrid();
const q = filterQuery.trim().toLowerCase();
const matchIds = q
? new Set(NODES.filter(n =>
n.label.toLowerCase().includes(q) || n.kind.toLowerCase().includes(q)
).map(n => n.id))
: null;
const vn = visNodes();
ctx.save();
ctx.translate(transform.x, transform.y);
ctx.scale(transform.scale, transform.scale);
// Edges
for (const edge of EDGES) {
const p1 = pos[edge.from], p2 = pos[edge.to];
if (!p1 || !p2) continue;
const dimmed = matchIds && !matchIds.has(edge.from) && !matchIds.has(edge.to);
const tp1 = transformPos(p1);
const tp2 = transformPos(p2);
let x1, y1, x2, y2, cx, cy;
if (layoutMode === 'horizontal') {
// Horizontal: edges go from bottom of parent to top of child
x1 = tp1.x; y1 = tp1.y + NODE_H / 2;
x2 = tp2.x; y2 = tp2.y - NODE_H / 2;
cy = (y1 + y2) / 2;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.bezierCurveTo(x1, cy, x2, cy, x2, y2);
} else {
// Vertical: edges go from right of parent to left of child
x1 = tp1.x + NODE_W / 2; y1 = tp1.y;
x2 = tp2.x - NODE_W / 2; y2 = tp2.y;
cx = (x1 + x2) / 2;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.bezierCurveTo(cx, y1, cx, y2, x2, y2);
}
ctx.strokeStyle = dimmed ? 'rgba(48,54,61,0.25)' : 'rgba(48,54,61,0.9)';
ctx.lineWidth = 1.5;
ctx.stroke();
}
// Nodes
for (const node of vn) {
const p = pos[node.id];
if (!p) continue;
const tp = transformPos(p);
const isSel = node.id === selectedId;
const isMatch = matchIds !== null && matchIds.has(node.id);
const isDim = matchIds !== null && !matchIds.has(node.id);
drawNode(node, tp.x, tp.y, isSel, isMatch, isDim);
}
ctx.restore();
}
function drawNode(node, cx, cy, isSel, isMatch, isDim) {
// Nodes always keep same dimensions and horizontal text
const hw = NODE_W / 2, hh = NODE_H / 2, r = 6;
const x = cx - hw, y = cy - hh;
const color = TYPE_COLOR[node.type] || '#334155';
ctx.globalAlpha = isDim ? 0.15 : 1;
// Glow for selected
if (isSel) { ctx.shadowColor = '#f0883e'; ctx.shadowBlur = 16; }
// Background
ctx.beginPath();
ctx.roundRect(x, y, NODE_W, NODE_H, r);
ctx.fillStyle = isSel ? '#2a1f0f' : '#1c2128';
ctx.fill();
ctx.shadowBlur = 0;
// Left color strip (always on left, regardless of mode)
ctx.save();
ctx.beginPath();
ctx.roundRect(x, y, NODE_W, NODE_H, r);
ctx.clip();
ctx.fillStyle = color;
ctx.fillRect(x, y, 4, NODE_H);
ctx.restore();
// Border
ctx.beginPath();
ctx.roundRect(x, y, NODE_W, NODE_H, r);
if (isSel) {
ctx.strokeStyle = '#f0883e';
ctx.lineWidth = 1.5;
} else if (isMatch) {
ctx.strokeStyle = '#e3b341';
ctx.lineWidth = 1.5;
} else {
ctx.strokeStyle = `${color}44`;
ctx.lineWidth = 1;
}
ctx.stroke();
// Kind badge
const kindText = node.kind;
ctx.font = '9px system-ui';
const rawW = ctx.measureText(kindText).width;
const badgeW = Math.min(rawW + 8, 66);
const chevSpace = hasChildren(node.id) ? CHEV_ZONE : 8;
const badgeX = x + NODE_W - chevSpace - badgeW - 2;
const badgeY = y + (NODE_H - 14) / 2;
ctx.beginPath();
ctx.roundRect(badgeX, badgeY, badgeW, 14, 3);
ctx.fillStyle = `${color}22`;
ctx.fill();
ctx.fillStyle = color;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
let kLabel = kindText;
while (kLabel.length > 3 && ctx.measureText(kLabel).width > badgeW - 6) kLabel = kLabel.slice(0, -1);
if (kLabel !== kindText) kLabel += '…';
ctx.fillText(kLabel, badgeX + badgeW / 2, badgeY + 7);
// Label (always horizontal)
ctx.font = `${isSel ? 500 : 400} 12px monospace`;
ctx.fillStyle = isDim ? 'rgba(125,133,144,0.5)' : '#e6edf3';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
const labelX = x + 12;
const labelMaxW = badgeX - labelX - 6;
let label = node.label;
while (label.length > 3 && ctx.measureText(label).width > labelMaxW) label = label.slice(0, -1);
if (label !== node.label) label += '…';
ctx.fillText(label, labelX, cy);
// Chevron toggle (same position in both modes)
if (hasChildren(node.id)) {
const chevX = x + NODE_W - CHEV_ZONE / 2 - 1;
const isCollapsed = collapsed.has(node.id);
drawChevron(ctx, chevX, cy, !isCollapsed, color);
}
ctx.globalAlpha = 1;
}
function drawChevron(ctx, cx, cy, pointDown, color) {
const s = 4;
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
// Chevron always drawn the same way (down or right)
if (pointDown) {
ctx.moveTo(cx - s, cy - s * 0.5);
ctx.lineTo(cx, cy + s * 0.5);
ctx.lineTo(cx + s, cy - s * 0.5);
} else {
ctx.moveTo(cx - s * 0.5, cy - s);
ctx.lineTo(cx + s * 0.5, cy);
ctx.lineTo(cx - s * 0.5, cy + s);
}
ctx.stroke();
}
// ═══════════════════════════════════════════════════════════
// Fit all
// ═══════════════════════════════════════════════════════════
function fitAll() {
const vn = visNodes();
if (vn.length === 0) return;
// Get transformed positions
const tps = vn.map(n => pos[n.id] ? transformPos(pos[n.id]) : null).filter(p => p !== null);
if (tps.length === 0) return;
const xs = tps.map(p => p.x);
const ys = tps.map(p => p.y);
const minX = Math.min(...xs) - NODE_W / 2 - FIT_PADDING;
const maxX = Math.max(...xs) + NODE_W / 2 + FIT_PADDING;
const minY = Math.min(...ys) - NODE_H / 2 - FIT_PADDING;
const maxY = Math.max(...ys) + NODE_H / 2 + FIT_PADDING;
const scale = Math.min(canvas.width / (maxX - minX), canvas.height / (maxY - minY), FIT_MAX_SCALE);
transform.scale = scale;
transform.x = (canvas.width - (minX + maxX) * scale) / 2;
transform.y = (canvas.height - (minY + maxY) * scale) / 2;
draw();
}
// ═══════════════════════════════════════════════════════════
// Hit testing
// ═══════════════════════════════════════════════════════════
function hitTest(sx, sy) {
const wx = (sx - transform.x) / transform.scale;
const wy = (sy - transform.y) / transform.scale;
const vn = visNodes();
for (let i = vn.length - 1; i >= 0; i--) {
const n = vn[i];
const p = pos[n.id];
if (!p) continue;
const tp = transformPos(p);
// Nodes keep same dimensions in both modes
if (Math.abs(wx - tp.x) <= NODE_W / 2 && Math.abs(wy - tp.y) <= NODE_H / 2) {
// Toggle zone always on the right side of node
const isToggle = hasChildren(n.id) && wx >= tp.x + NODE_W / 2 - CHEV_ZONE;
return { node: n, isToggle };
}
}
return null;
}
// ═══════════════════════════════════════════════════════════
// Event posting to server
// ═══════════════════════════════════════════════════════════
function postEvent(eventName, eventData) {
const handler = EVENTS[eventName];
if (!handler || !handler.url) {
console.warn(`HierarchicalCanvasGraph: No handler for event "${eventName}"`);
return;
}
htmx.ajax('POST', handler.url, {
values: { event_data: JSON.stringify(eventData) },
target: handler.target || 'body',
swap: handler.swap || 'none'
});
}
// ═══════════════════════════════════════════════════════════
// Interaction
// ═══════════════════════════════════════════════════════════
let isPanning = false;
let panOrigin = { x: 0, y: 0 };
let tfAtStart = null;
let didMove = false;
canvas.addEventListener('mousedown', e => {
isPanning = true; didMove = false;
panOrigin = { x: e.clientX, y: e.clientY };
tfAtStart = { ...transform };
canvas.style.cursor = 'grabbing';
});
window.addEventListener('mousemove', e => {
if (!isPanning) return;
const dx = e.clientX - panOrigin.x;
const dy = e.clientY - panOrigin.y;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) didMove = true;
transform.x = tfAtStart.x + dx;
transform.y = tfAtStart.y + dy;
draw();
});
window.addEventListener('mouseup', e => {
if (!isPanning) return;
isPanning = false;
canvas.style.cursor = 'grab';
if (!didMove) {
const rect = canvas.getBoundingClientRect();
const hit = hitTest(e.clientX - rect.left, e.clientY - rect.top);
if (hit) {
if (hit.isToggle) {
// Toggle collapse
if (collapsed.has(hit.node.id)) collapsed.delete(hit.node.id);
else collapsed.add(hit.node.id);
recomputeLayout();
// Post toggle_node event
postEvent('toggle_node', {
node_id: hit.node.id,
collapsed: collapsed.has(hit.node.id)
});
// Clear selection if node is now hidden
if (selectedId && !visNodes().find(n => n.id === selectedId)) {
selectedId = null;
}
} else {
selectedId = hit.node.id;
// Post select_node event
postEvent('select_node', {
node_id: hit.node.id,
label: hit.node.label,
type: hit.node.type,
kind: hit.node.kind
});
}
} else {
selectedId = null;
}
draw();
}
});
canvas.addEventListener('wheel', e => {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const f = e.deltaY < 0 ? ZOOM_FACTOR : 1 / ZOOM_FACTOR;
const ns = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, transform.scale * f));
transform.x = mx - (mx - transform.x) * (ns / transform.scale);
transform.y = my - (my - transform.y) * (ns / transform.scale);
transform.scale = ns;
draw();
}, { passive: false });
// ═══════════════════════════════════════════════════════════
// Resize observer (stable zoom on resize)
// ═══════════════════════════════════════════════════════════
new ResizeObserver(resize).observe(container);
// ═══════════════════════════════════════════════════════════
// Public API (attached to container for potential external access)
// ═══════════════════════════════════════════════════════════
container._hcgAPI = {
fitAll,
setFilter: (query) => { filterQuery = query; draw(); },
expandAll: () => { collapsed.clear(); recomputeLayout(); draw(); },
collapseAll: () => {
for (const n of NODES) if (hasChildren(n.id)) collapsed.add(n.id);
if (selectedId && !visNodes().find(n => n.id === selectedId)) selectedId = null;
recomputeLayout(); draw();
},
redraw: draw
};
// ═══════════════════════════════════════════════════════════
// Init
// ═══════════════════════════════════════════════════════════
recomputeLayout();
resize();
setTimeout(fitAll, 30);
}

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