From 44691be30f18fd8dbb4b01d79bf0c09a1e1616b2 Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Sat, 21 Feb 2026 23:53:05 +0100 Subject: [PATCH] New hierarchical component, used for InstancesDebugger.py --- examples/canvas_graph_prototype.html | 779 ++++++++++++++++++ {tests/html => examples}/keyboard_support.js | 0 {tests/html => examples}/mouse_support.js | 0 .../test_keyboard_support.html | 0 .../html => examples}/test_mouse_support.html | 0 .../assets/core/hierarchical_canvas_graph.css | 99 +++ .../assets/core/hierarchical_canvas_graph.js | 617 ++++++++++++++ .../controls/HierarchicalCanvasGraph.py | 185 +++++ src/myfasthtml/controls/InstancesDebugger.py | 141 +++- 9 files changed, 1789 insertions(+), 32 deletions(-) create mode 100644 examples/canvas_graph_prototype.html rename {tests/html => examples}/keyboard_support.js (100%) rename {tests/html => examples}/mouse_support.js (100%) rename {tests/html => examples}/test_keyboard_support.html (100%) rename {tests/html => examples}/test_mouse_support.html (100%) create mode 100644 src/myfasthtml/assets/core/hierarchical_canvas_graph.css create mode 100644 src/myfasthtml/assets/core/hierarchical_canvas_graph.js create mode 100644 src/myfasthtml/controls/HierarchicalCanvasGraph.py diff --git a/examples/canvas_graph_prototype.html b/examples/canvas_graph_prototype.html new file mode 100644 index 0000000..8f0e5e9 --- /dev/null +++ b/examples/canvas_graph_prototype.html @@ -0,0 +1,779 @@ + + + + + InstancesDebugger — Canvas Prototype v2 + + + + +
+ + InstancesDebugger +
+ + + + + + + +
+ +
+ + + + + +
+ +
+ + 100% + Wheel · Drag · Click +
+ +
+
+ +
+
RootInstance
+
SingleInstance
+
MultipleInstance
+
UniqueInstance
+
+
+ +
+ Click a node to inspect +
+
+ + + + diff --git a/tests/html/keyboard_support.js b/examples/keyboard_support.js similarity index 100% rename from tests/html/keyboard_support.js rename to examples/keyboard_support.js diff --git a/tests/html/mouse_support.js b/examples/mouse_support.js similarity index 100% rename from tests/html/mouse_support.js rename to examples/mouse_support.js diff --git a/tests/html/test_keyboard_support.html b/examples/test_keyboard_support.html similarity index 100% rename from tests/html/test_keyboard_support.html rename to examples/test_keyboard_support.html diff --git a/tests/html/test_mouse_support.html b/examples/test_mouse_support.html similarity index 100% rename from tests/html/test_mouse_support.html rename to examples/test_mouse_support.html diff --git a/src/myfasthtml/assets/core/hierarchical_canvas_graph.css b/src/myfasthtml/assets/core/hierarchical_canvas_graph.css new file mode 100644 index 0000000..8d717d2 --- /dev/null +++ b/src/myfasthtml/assets/core/hierarchical_canvas_graph.css @@ -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; +} diff --git a/src/myfasthtml/assets/core/hierarchical_canvas_graph.js b/src/myfasthtml/assets/core/hierarchical_canvas_graph.js new file mode 100644 index 0000000..7ca41ff --- /dev/null +++ b/src/myfasthtml/assets/core/hierarchical_canvas_graph.js @@ -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} 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} 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} [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); +} diff --git a/src/myfasthtml/controls/HierarchicalCanvasGraph.py b/src/myfasthtml/controls/HierarchicalCanvasGraph.py new file mode 100644 index 0000000..b15bf7c --- /dev/null +++ b/src/myfasthtml/controls/HierarchicalCanvasGraph.py @@ -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() diff --git a/src/myfasthtml/controls/InstancesDebugger.py b/src/myfasthtml/controls/InstancesDebugger.py index 6d2e5a1..16284a6 100644 --- a/src/myfasthtml/controls/InstancesDebugger.py +++ b/src/myfasthtml/controls/InstancesDebugger.py @@ -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())