diff --git a/src/myfasthtml/assets/core/hierarchical_canvas_graph.css b/src/myfasthtml/assets/core/hierarchical_canvas_graph.css index 8d717d2..da462d2 100644 --- a/src/myfasthtml/assets/core/hierarchical_canvas_graph.css +++ b/src/myfasthtml/assets/core/hierarchical_canvas_graph.css @@ -4,6 +4,35 @@ * Styles for the canvas-based hierarchical graph visualization control. */ +/* *********************************************** */ +/* ********** Color Variables (DaisyUI) ********** */ +/* *********************************************** */ + +/* Instance kind colors - hardcoded to preserve visual identity */ +:root { + --hcg-color-root: #2563eb; + --hcg-color-single: #7c3aed; + --hcg-color-multiple: #047857; + --hcg-color-unique: #b45309; + + /* UI colors */ + --hcg-bg-main: var(--color-base-100, #0d1117); + --hcg-bg-button: var(--color-base-200, rgba(22, 27, 34, 0.92)); + --hcg-border: var(--color-border, #30363d); + --hcg-text-muted: color-mix(in oklab, var(--color-base-content, #e6edf3) 50%, transparent); + --hcg-text-primary: var(--color-base-content, #e6edf3); + + /* Canvas drawing colors */ + --hcg-dot-grid: rgba(125, 133, 144, 0.12); + --hcg-edge: rgba(48, 54, 61, 0.9); + --hcg-edge-dimmed: rgba(48, 54, 61, 0.25); + --hcg-node-bg: var(--color-base-300, #1c2128); + --hcg-node-bg-selected: color-mix(in oklab, var(--color-base-300, #1c2128) 70%, #f0883e 30%); + --hcg-node-border-selected: #f0883e; + --hcg-node-border-match: #e3b341; + --hcg-node-glow: #f0883e; +} + /* Main control wrapper */ .mf-hierarchical-canvas-graph { display: flex; @@ -19,7 +48,7 @@ flex: 1; position: relative; overflow: hidden; - background: #0d1117; + background: var(--hcg-bg-main); width: 100%; height: 100%; } @@ -47,8 +76,8 @@ position: absolute; top: 12px; left: 12px; - background: rgba(22, 27, 34, 0.92); - border: 1px solid #30363d; + background: var(--hcg-bg-button); + border: 1px solid var(--hcg-border); border-radius: 8px; padding: 6px; display: flex; @@ -65,10 +94,10 @@ right: 12px; width: 32px; height: 32px; - background: rgba(22, 27, 34, 0.92); - border: 1px solid #30363d; + background: var(--hcg-bg-button); + border: 1px solid var(--hcg-border); border-radius: 6px; - color: #7d8590; + color: var(--hcg-text-muted); font-size: 16px; cursor: pointer; display: flex; @@ -82,8 +111,8 @@ } .mf-hcg-toggle-btn:hover { - color: #e6edf3; - background: #1c2128; + color: var(--hcg-text-primary); + background: color-mix(in oklab, var(--hcg-bg-main) 90%, var(--hcg-text-primary) 10%); } /* Optional: loading state */ @@ -93,7 +122,7 @@ top: 50%; left: 50%; transform: translate(-50%, -50%); - color: #7d8590; + color: var(--hcg-text-muted); 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 index 7ca41ff..2a4119e 100644 --- a/src/myfasthtml/assets/core/hierarchical_canvas_graph.js +++ b/src/myfasthtml/assets/core/hierarchical_canvas_graph.js @@ -23,8 +23,8 @@ * @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 {string} options.nodes[].kind - Instance kind (root|single|unique|multiple) + * @param {string} options.nodes[].type - Class type/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 @@ -38,8 +38,8 @@ * @example * initHierarchicalCanvasGraph('graph-container', { * nodes: [ - * { id: 'root', label: 'Root', type: 'root', kind: 'RootInstance' }, - * { id: 'child', label: 'Child', type: 'single', kind: 'MyComponent' } + * { id: 'root', label: 'Root', kind: 'root', type: 'RootInstance' }, + * { id: 'child', label: 'Child', kind: 'single', type: 'MyComponent' } * ], * edges: [{ from: 'root', to: 'child' }], * collapsed: [], @@ -104,11 +104,27 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { return layoutMode === 'vertical' ? VERTICAL_MODE_SPACING : HORIZONTAL_MODE_SPACING; } - const TYPE_COLOR = { - root: '#2563eb', - single: '#7c3aed', - multiple: '#047857', - unique: '#b45309', + // Color mapping based on instance kind (read from CSS variables for DaisyUI theme compatibility) + const computedStyle = getComputedStyle(document.documentElement); + const KIND_COLOR = { + root: computedStyle.getPropertyValue('--hcg-color-root').trim() || '#2563eb', + single: computedStyle.getPropertyValue('--hcg-color-single').trim() || '#7c3aed', + multiple: computedStyle.getPropertyValue('--hcg-color-multiple').trim() || '#047857', + unique: computedStyle.getPropertyValue('--hcg-color-unique').trim() || '#b45309', + }; + + // UI colors from CSS variables + const UI_COLORS = { + dotGrid: computedStyle.getPropertyValue('--hcg-dot-grid').trim() || 'rgba(125,133,144,0.12)', + edge: computedStyle.getPropertyValue('--hcg-edge').trim() || 'rgba(48,54,61,0.9)', + edgeDimmed: computedStyle.getPropertyValue('--hcg-edge-dimmed').trim() || 'rgba(48,54,61,0.25)', + nodeBg: computedStyle.getPropertyValue('--hcg-node-bg').trim() || '#1c2128', + nodeBgSelected: computedStyle.getPropertyValue('--hcg-node-bg-selected').trim() || '#2a1f0f', + nodeBorderSel: computedStyle.getPropertyValue('--hcg-node-border-selected').trim() || '#f0883e', + nodeBorderMatch: computedStyle.getPropertyValue('--hcg-node-border-match').trim() || '#e3b341', + nodeGlow: computedStyle.getPropertyValue('--hcg-node-glow').trim() || '#f0883e', + textPrimary: computedStyle.getPropertyValue('--hcg-text-primary').trim() || '#e6edf3', + textMuted: computedStyle.getPropertyValue('--hcg-text-muted').trim() || 'rgba(125,133,144,0.5)', }; // ═══════════════════════════════════════════════════════════ @@ -133,9 +149,9 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { const collapsed = new Set(options.collapsed || []); let selectedId = null; let filterQuery = ''; - let transform = { x: 0, y: 0, scale: 1 }; + let transform = options.transform || { x: 0, y: 0, scale: 1 }; let pos = {}; - let layoutMode = 'horizontal'; // 'horizontal' | 'vertical' + let layoutMode = options.layout_mode || 'horizontal'; // 'horizontal' | 'vertical' // ═══════════════════════════════════════════════════════════ // Visibility & Layout @@ -230,11 +246,21 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { // ═══════════════════════════════════════════════════════════ const canvas = document.createElement('canvas'); canvas.id = `${containerId}_canvas`; - canvas.style.cssText = 'display:block; width:100%; height:100%; cursor:grab;'; + canvas.style.cssText = 'display:block; width:100%; height:100%; cursor:grab; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; font-smooth: always;'; container.appendChild(canvas); const ctx = canvas.getContext('2d'); + // Logical dimensions (CSS pixels) - used for drawing coordinates + let logicalWidth = 0; + let logicalHeight = 0; + + // Tooltip element for showing full text when truncated + const tooltip = document.createElement('div'); + tooltip.className = 'mf-tooltip-container'; + tooltip.setAttribute('data-visible', 'false'); + document.body.appendChild(tooltip); + // Layout toggle button overlay const toggleBtn = document.createElement('button'); toggleBtn.className = 'mf-hcg-toggle-btn'; @@ -249,12 +275,28 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { // Recompute layout with new spacing recomputeLayout(); fitAll(); + // Save layout mode change + saveViewState(); }); container.appendChild(toggleBtn); function resize() { - canvas.width = container.clientWidth; - canvas.height = container.clientHeight; + const ratio = window.devicePixelRatio || 1; + + // Store logical dimensions (CSS pixels) for drawing coordinates + logicalWidth = container.clientWidth; + logicalHeight = container.clientHeight; + + // Set canvas internal resolution to match physical pixels (prevents blur on HiDPI screens) + canvas.width = logicalWidth * ratio; + canvas.height = logicalHeight * ratio; + + // Reset transformation matrix to identity (prevents cumulative scaling) + ctx.setTransform(1, 0, 0, 1, 0, 0); + + // Scale context to maintain logical coordinate system + ctx.scale(ratio, ratio); + draw(); } @@ -264,9 +306,9 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { 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.fillStyle = UI_COLORS.dotGrid; + for (let x = ox - DOT_GRID_SPACING; x < logicalWidth + DOT_GRID_SPACING; x += DOT_GRID_SPACING) { + for (let y = oy - DOT_GRID_SPACING; y < logicalHeight + DOT_GRID_SPACING; y += DOT_GRID_SPACING) { ctx.beginPath(); ctx.arc(x, y, DOT_GRID_RADIUS, 0, Math.PI * 2); ctx.fill(); @@ -275,13 +317,13 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { } function draw() { - ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.clearRect(0, 0, logicalWidth, logicalHeight); drawDotGrid(); const q = filterQuery.trim().toLowerCase(); const matchIds = q ? new Set(NODES.filter(n => - n.label.toLowerCase().includes(q) || n.kind.toLowerCase().includes(q) + n.label.toLowerCase().includes(q) || n.type.toLowerCase().includes(q) ).map(n => n.id)) : null; @@ -319,7 +361,7 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { ctx.bezierCurveTo(cx, y1, cx, y2, x2, y2); } - ctx.strokeStyle = dimmed ? 'rgba(48,54,61,0.25)' : 'rgba(48,54,61,0.9)'; + ctx.strokeStyle = dimmed ? UI_COLORS.edgeDimmed : UI_COLORS.edge; ctx.lineWidth = 1.5; ctx.stroke(); } @@ -332,27 +374,27 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { 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); + drawNode(node, tp.x, tp.y, isSel, isMatch, isDim, transform.scale); } ctx.restore(); } - function drawNode(node, cx, cy, isSel, isMatch, isDim) { + function drawNode(node, cx, cy, isSel, isMatch, isDim, zoomLevel) { // 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'; + const color = KIND_COLOR[node.kind] || '#334155'; ctx.globalAlpha = isDim ? 0.15 : 1; // Glow for selected - if (isSel) { ctx.shadowColor = '#f0883e'; ctx.shadowBlur = 16; } + if (isSel) { ctx.shadowColor = UI_COLORS.nodeGlow; ctx.shadowBlur = 16; } // Background ctx.beginPath(); ctx.roundRect(x, y, NODE_W, NODE_H, r); - ctx.fillStyle = isSel ? '#2a1f0f' : '#1c2128'; + ctx.fillStyle = isSel ? UI_COLORS.nodeBgSelected : UI_COLORS.nodeBg; ctx.fill(); ctx.shadowBlur = 0; @@ -369,10 +411,10 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { ctx.beginPath(); ctx.roundRect(x, y, NODE_W, NODE_H, r); if (isSel) { - ctx.strokeStyle = '#f0883e'; + ctx.strokeStyle = UI_COLORS.nodeBorderSel; ctx.lineWidth = 1.5; } else if (isMatch) { - ctx.strokeStyle = '#e3b341'; + ctx.strokeStyle = UI_COLORS.nodeBorderMatch; ctx.lineWidth = 1.5; } else { ctx.strokeStyle = `${color}44`; @@ -380,37 +422,45 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { } ctx.stroke(); - // Kind badge - const kindText = node.kind; - ctx.font = '9px system-ui'; + // Type badge (class name) - with dynamic font size for sharp rendering at all zoom levels + const kindText = node.type; + const badgeFontSize = 9 * zoomLevel; + ctx.save(); + ctx.scale(1 / zoomLevel, 1 / zoomLevel); + ctx.font = `${badgeFontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", "Inter", "Roboto", sans-serif`; const rawW = ctx.measureText(kindText).width; - const badgeW = Math.min(rawW + 8, 66); + const badgeW = Math.min(rawW + 8, 66 * zoomLevel); const chevSpace = hasChildren(node.id) ? CHEV_ZONE : 8; - const badgeX = x + NODE_W - chevSpace - badgeW - 2; - const badgeY = y + (NODE_H - 14) / 2; + const badgeX = (x + NODE_W - chevSpace - badgeW / zoomLevel - 2) * zoomLevel; + const badgeY = (y + (NODE_H - 14) / 2) * zoomLevel; ctx.beginPath(); - ctx.roundRect(badgeX, badgeY, badgeW, 14, 3); + ctx.roundRect(badgeX, badgeY, badgeW, 14 * zoomLevel, 3 * zoomLevel); 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); + while (kLabel.length > 3 && ctx.measureText(kLabel).width > badgeW - 6 * zoomLevel) kLabel = kLabel.slice(0, -1); if (kLabel !== kindText) kLabel += '…'; - ctx.fillText(kLabel, badgeX + badgeW / 2, badgeY + 7); + ctx.fillText(kLabel, Math.round(badgeX + badgeW / 2), Math.round(badgeY + 7 * zoomLevel)); + ctx.restore(); - // Label (always horizontal) - ctx.font = `${isSel ? 500 : 400} 12px monospace`; - ctx.fillStyle = isDim ? 'rgba(125,133,144,0.5)' : '#e6edf3'; + // Label (always horizontal) - with dynamic font size for sharp rendering at all zoom levels + const labelFontSize = 12 * zoomLevel; + ctx.save(); + ctx.scale(1 / zoomLevel, 1 / zoomLevel); + ctx.font = `${isSel ? 500 : 400} ${labelFontSize}px "SF Mono", "Cascadia Code", "Consolas", "Menlo", "Monaco", monospace`; + ctx.fillStyle = isDim ? UI_COLORS.textMuted : UI_COLORS.textPrimary; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; - const labelX = x + 12; - const labelMaxW = badgeX - labelX - 6; + const labelX = (x + 12) * zoomLevel; + const labelMaxW = (badgeX / zoomLevel - (x + 12) - 6) * zoomLevel; 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); + ctx.fillText(label, Math.round(labelX), Math.round(cy * zoomLevel)); + ctx.restore(); // Chevron toggle (same position in both modes) if (hasChildren(node.id)) { @@ -459,13 +509,27 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { 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); + const scale = Math.min(logicalWidth / (maxX - minX), logicalHeight / (maxY - minY), FIT_MAX_SCALE); transform.scale = scale; - transform.x = (canvas.width - (minX + maxX) * scale) / 2; - transform.y = (canvas.height - (minY + maxY) * scale) / 2; + transform.x = (logicalWidth - (minX + maxX) * scale) / 2; + transform.y = (logicalHeight - (minY + maxY) * scale) / 2; draw(); } + // ═══════════════════════════════════════════════════════════ + // Tooltip helpers + // ═══════════════════════════════════════════════════════════ + function showTooltip(text, clientX, clientY) { + tooltip.textContent = text; + tooltip.style.left = `${clientX + 10}px`; + tooltip.style.top = `${clientY + 10}px`; + tooltip.setAttribute('data-visible', 'true'); + } + + function hideTooltip() { + tooltip.setAttribute('data-visible', 'false'); + } + // ═══════════════════════════════════════════════════════════ // Hit testing // ═══════════════════════════════════════════════════════════ @@ -499,7 +563,6 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { console.warn(`HierarchicalCanvasGraph: No handler for event "${eventName}"`); return; } - htmx.ajax('POST', handler.url, { values: { event_data: JSON.stringify(eventData) }, target: handler.target || 'body', @@ -507,6 +570,13 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { }); } + function saveViewState() { + postEvent('_internal_update_state', { + transform: transform, + layout_mode: layoutMode + }); + } + // ═══════════════════════════════════════════════════════════ // Interaction // ═══════════════════════════════════════════════════════════ @@ -520,16 +590,47 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { panOrigin = { x: e.clientX, y: e.clientY }; tfAtStart = { ...transform }; canvas.style.cursor = 'grabbing'; + hideTooltip(); }); 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(); + if (isPanning) { + 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(); + hideTooltip(); + return; + } + + // Show tooltip if hovering over a node with truncated text + const rect = canvas.getBoundingClientRect(); + const canvasX = e.clientX - rect.left; + const canvasY = e.clientY - rect.top; + + // Check if mouse is over canvas + if (canvasX >= 0 && canvasX <= rect.width && canvasY >= 0 && canvasY <= rect.height) { + const hit = hitTest(canvasX, canvasY); + if (hit && !hit.isToggle) { + const node = hit.node; + // Check if label or type is truncated (contains ellipsis) + const labelTruncated = node.label.length > 15; // Approximate truncation threshold + const typeTruncated = node.type.length > 8; // Approximate truncation threshold + + if (labelTruncated || typeTruncated) { + const tooltipText = `${node.label}${node.type !== node.label ? ` (${node.type})` : ''}`; + showTooltip(tooltipText, e.clientX, e.clientY); + } else { + hideTooltip(); + } + } else { + hideTooltip(); + } + } else { + hideTooltip(); + } }); window.addEventListener('mouseup', e => { @@ -564,19 +665,24 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { postEvent('select_node', { node_id: hit.node.id, label: hit.node.label, - type: hit.node.type, - kind: hit.node.kind + kind: hit.node.kind, + type: hit.node.type }); } } else { selectedId = null; } draw(); + } else { + // Panning occurred - save view state + saveViewState(); } }); + let zoomTimeout = null; canvas.addEventListener('wheel', e => { e.preventDefault(); + hideTooltip(); const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; @@ -586,8 +692,16 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { transform.y = my - (my - transform.y) * (ns / transform.scale); transform.scale = ns; draw(); + + // Debounce save to avoid too many requests during continuous zoom + clearTimeout(zoomTimeout); + zoomTimeout = setTimeout(saveViewState, 500); }, { passive: false }); + canvas.addEventListener('mouseleave', () => { + hideTooltip(); + }); + // ═══════════════════════════════════════════════════════════ // Resize observer (stable zoom on resize) // ═══════════════════════════════════════════════════════════ @@ -613,5 +727,12 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { // ═══════════════════════════════════════════════════════════ recomputeLayout(); resize(); - setTimeout(fitAll, 30); + + // Only fit all if no stored transform (first time or reset) + const hasStoredTransform = options.transform && + (options.transform.x !== 0 || options.transform.y !== 0 || options.transform.scale !== 1); + + if (!hasStoredTransform) { + setTimeout(fitAll, 30); + } } diff --git a/src/myfasthtml/controls/HierarchicalCanvasGraph.py b/src/myfasthtml/controls/HierarchicalCanvasGraph.py index b15bf7c..6931f6a 100644 --- a/src/myfasthtml/controls/HierarchicalCanvasGraph.py +++ b/src/myfasthtml/controls/HierarchicalCanvasGraph.py @@ -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. diff --git a/src/myfasthtml/controls/InstancesDebugger.py b/src/myfasthtml/controls/InstancesDebugger.py index 16284a6..689637b 100644 --- a/src/myfasthtml/controls/InstancesDebugger.py +++ b/src/myfasthtml/controls/InstancesDebugger.py @@ -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())