diff --git a/examples/canvas_graph_prototype.html b/examples/canvas_graph_prototype.html index 8f0e5e9..392155a 100644 --- a/examples/canvas_graph_prototype.html +++ b/examples/canvas_graph_prototype.html @@ -274,18 +274,18 @@ // Data // ═══════════════════════════════════════════════════════════ const NODES = [ - { id: 'app', label: 'app', type: 'root', kind: 'RootInstance' }, - { id: 'layout', label: 'layout', type: 'single', kind: 'Layout' }, - { id: 'left_panel', label: 'left_panel', type: 'multiple', kind: 'Panel' }, - { id: 'right_panel', label: 'right_panel', type: 'multiple', kind: 'Panel' }, - { id: 'instances_debugger', label: 'instances_debugger', type: 'single', kind: 'InstancesDebugger' }, - { id: 'dbg_panel', label: 'dbg#panel', type: 'multiple', kind: 'Panel' }, - { id: 'canvas_graph', label: 'dbg#canvas_graph', type: 'multiple', kind: 'CanvasGraph' }, - { id: 'data_grid_manager', label: 'data_grid_manager', type: 'single', kind: 'DataGridsManager' }, - { id: 'my_grid', label: 'my_grid', type: 'multiple', kind: 'DataGrid' }, - { id: 'grid_toolbar', label: 'my_grid#toolbar', type: 'multiple', kind: 'Toolbar' }, - { id: 'grid_search', label: 'my_grid#search', type: 'multiple', kind: 'Search' }, - { id: 'auth_proxy', label: 'auth_proxy', type: 'unique', kind: 'AuthProxy' }, + { id: 'app', label: 'app', type: 'root', kind: 'RootInstance', description: 'Main application root' }, + { id: 'layout', label: 'layout', type: 'single', kind: 'Layout', description: 'Main layout with 2 panels' }, + { id: 'left_panel', label: 'left_panel', type: 'multiple', kind: 'Panel' }, + { id: 'right_panel', label: 'right_panel', type: 'multiple', kind: 'Panel' }, + { id: 'instances_debugger', label: 'instances_debugger', type: 'single', kind: 'InstancesDebugger', description: 'Debug tool for instances' }, + { id: 'dbg_panel', label: 'dbg#panel', type: 'multiple', kind: 'Panel' }, + { id: 'canvas_graph', label: 'dbg#canvas_graph', type: 'multiple', kind: 'CanvasGraph', description: 'Canvas-based graph view' }, + { id: 'data_grid_manager', label: 'data_grid_manager', type: 'single', kind: 'DataGridsManager', description: 'Manages all data grids' }, + { id: 'my_grid', label: 'my_grid', type: 'multiple', kind: 'DataGrid', description: '42 rows × 5 columns' }, + { id: 'grid_toolbar', label: 'my_grid#toolbar', type: 'multiple', kind: 'Toolbar' }, + { id: 'grid_search', label: 'my_grid#search', type: 'multiple', kind: 'Search' }, + { id: 'auth_proxy', label: 'auth_proxy', type: 'unique', kind: 'AuthProxy', description: 'User authentication proxy' }, ]; const EDGES = [ @@ -320,11 +320,16 @@ const DETAILS = { // ═══════════════════════════════════════════════════════════ // Constants // ═══════════════════════════════════════════════════════════ -const NODE_W = 178; -const NODE_H = 36; -const LEVEL_H = 84; // vertical distance between levels -const LEAF_GAP = 22; // horizontal gap between leaf slots -const CHEV_ZONE = 26; // rightmost px = toggle hit zone +const NODE_W = 178; +const NODE_H_SMALL = 36; // Without description +const NODE_H_LARGE = 54; // With description +const LEVEL_H = 96; // Vertical distance between levels +const LEAF_GAP = 22; // Horizontal gap between leaf slots +const CHEV_ZONE = 26; // Rightmost px = toggle hit zone + +function getNodeHeight(node) { + return node.description ? NODE_H_LARGE : NODE_H_SMALL; +} const TYPE_COLOR = { root: '#2563eb', @@ -469,8 +474,12 @@ function draw() { 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 x1 = p1.x, y1 = p1.y + NODE_H / 2; - const x2 = p2.x, y2 = p2.y - NODE_H / 2; + const node1 = NODES.find(n => n.id === edge.from); + const node2 = NODES.find(n => n.id === edge.to); + const h1 = node1 ? getNodeHeight(node1) : NODE_H_SMALL; + const h2 = node2 ? getNodeHeight(node2) : NODE_H_SMALL; + const x1 = p1.x, y1 = p1.y + h1 / 2; + const x2 = p2.x, y2 = p2.y - h2 / 2; const cy = (y1 + y2) / 2; ctx.beginPath(); ctx.moveTo(x1, y1); @@ -498,7 +507,8 @@ function draw() { // ── Node renderer ──────────────────────────────────────── function drawNode(node, cx, cy, isSel, isMatch, isDim) { - const hw = NODE_W / 2, hh = NODE_H / 2, r = 6; + const nodeH = getNodeHeight(node); + const hw = NODE_W / 2, hh = nodeH / 2, r = 6; const x = cx - hw, y = cy - hh; const color = TYPE_COLOR[node.type] || '#334155'; @@ -509,7 +519,7 @@ function drawNode(node, cx, cy, isSel, isMatch, isDim) { // Background — dark card ctx.beginPath(); - ctx.roundRect(x, y, NODE_W, NODE_H, r); + ctx.roundRect(x, y, NODE_W, nodeH, r); ctx.fillStyle = isSel ? '#2a1f0f' : '#1c2128'; ctx.fill(); ctx.shadowBlur = 0; @@ -517,15 +527,15 @@ function drawNode(node, cx, cy, isSel, isMatch, isDim) { // Left color strip (clipped) ctx.save(); ctx.beginPath(); - ctx.roundRect(x, y, NODE_W, NODE_H, r); + ctx.roundRect(x, y, NODE_W, nodeH, r); ctx.clip(); ctx.fillStyle = color; - ctx.fillRect(x, y, 4, NODE_H); + ctx.fillRect(x, y, 4, nodeH); ctx.restore(); // Border ctx.beginPath(); - ctx.roundRect(x, y, NODE_W, NODE_H, r); + ctx.roundRect(x, y, NODE_W, nodeH, r); if (isSel) { ctx.strokeStyle = '#f0883e'; ctx.lineWidth = 1.5; @@ -545,7 +555,7 @@ function drawNode(node, cx, cy, isSel, isMatch, isDim) { 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; + const badgeY = y + (nodeH - 14) / 2; ctx.beginPath(); ctx.roundRect(badgeX, badgeY, badgeW, 14, 3); ctx.fillStyle = `${color}22`; @@ -559,7 +569,7 @@ function drawNode(node, cx, cy, isSel, isMatch, isDim) { if (kLabel !== kindText) kLabel += '…'; ctx.fillText(kLabel, badgeX + badgeW / 2, badgeY + 7); - // Label + // Label (centered if no description, top if description) ctx.font = `${isSel ? 500 : 400} 12px monospace`; ctx.fillStyle = isDim ? 'rgba(125,133,144,0.5)' : '#e6edf3'; ctx.textAlign = 'left'; @@ -569,7 +579,18 @@ function drawNode(node, cx, cy, isSel, isMatch, isDim) { 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); + const labelY = node.description ? cy - 9 : cy; + ctx.fillText(label, labelX, labelY); + + // Description (bottom line, only if present) + if (node.description) { + ctx.font = '9px system-ui'; + ctx.fillStyle = isDim ? 'rgba(125,133,144,0.3)' : 'rgba(125,133,144,0.7)'; + let desc = node.description; + while (desc.length > 3 && ctx.measureText(desc).width > labelMaxW) desc = desc.slice(0, -1); + if (desc !== node.description) desc += '…'; + ctx.fillText(desc, labelX, cy + 8); + } // Chevron toggle (if has children) if (hasChildren(node.id)) { @@ -606,13 +627,18 @@ function drawChevron(ctx, cx, cy, pointDown, color) { function fitAll() { const vn = visNodes(); if (vn.length === 0) return; - const xs = vn.map(n => pos[n.id]?.x ?? 0); - const ys = vn.map(n => pos[n.id]?.y ?? 0); const pad = 48; - const minX = Math.min(...xs) - NODE_W / 2 - pad; - const maxX = Math.max(...xs) + NODE_W / 2 + pad; - const minY = Math.min(...ys) - NODE_H / 2 - pad; - const maxY = Math.max(...ys) + NODE_H / 2 + pad; + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + for (const n of vn) { + const p = pos[n.id]; + if (!p) continue; + const h = getNodeHeight(n); + minX = Math.min(minX, p.x - NODE_W / 2); + maxX = Math.max(maxX, p.x + NODE_W / 2); + minY = Math.min(minY, p.y - h / 2); + maxY = Math.max(maxY, p.y + h / 2); + } + minX -= pad; maxX += pad; minY -= pad; maxY += pad; const scale = Math.min(canvas.width / (maxX - minX), canvas.height / (maxY - minY), 1.5); transform.scale = scale; transform.x = (canvas.width - (minX + maxX) * scale) / 2; @@ -631,7 +657,8 @@ function hitTest(sx, sy) { const n = vn[i]; const p = pos[n.id]; if (!p) continue; - if (Math.abs(wx - p.x) <= NODE_W / 2 && Math.abs(wy - p.y) <= NODE_H / 2) { + const nodeH = getNodeHeight(n); + if (Math.abs(wx - p.x) <= NODE_W / 2 && Math.abs(wy - p.y) <= nodeH / 2) { const isToggle = hasChildren(n.id) && wx >= p.x + NODE_W / 2 - CHEV_ZONE; return { node: n, isToggle }; } diff --git a/src/myfasthtml/assets/core/hierarchical_canvas_graph.js b/src/myfasthtml/assets/core/hierarchical_canvas_graph.js index 2a4119e..66d073a 100644 --- a/src/myfasthtml/assets/core/hierarchical_canvas_graph.js +++ b/src/myfasthtml/assets/core/hierarchical_canvas_graph.js @@ -65,16 +65,23 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { // ═══════════════════════════════════════════════════════════ // Configuration & Constants // ═══════════════════════════════════════════════════════════ - const NODES = options.nodes || []; - const EDGES = options.edges || []; - const EVENTS = options.events || {}; + const NODES = options.nodes || []; + const EDGES = options.edges || []; + const EVENTS = options.events || {}; + // filtered_nodes: null = no filter, [] = filter but no matches, [ids] = filter with matches + const FILTERED_NODES = options.filtered_nodes === null ? null : new Set(options.filtered_nodes); // ═══════════════════════════════════════════════════════════ // 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 NODE_W = 178; // Node width in pixels + const NODE_H_SMALL = 36; // Node height without description + const NODE_H_LARGE = 54; // Node height with description + const CHEV_ZONE = 26; // Toggle button hit area width (rightmost px of node) + + function getNodeHeight(node) { + return node.description ? NODE_H_LARGE : NODE_H_SMALL; + } const TOGGLE_BTN_SIZE = 32; // Toggle button dimensions const TOGGLE_BTN_POS = 12; // Toggle button offset from corner @@ -97,7 +104,7 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { const VERTICAL_MODE_SPACING = { levelGap: 220, // horizontal distance between parent-child (after swap) - siblingGap: 14 // gap between siblings (in addition to NODE_H) + siblingGap: 14 // gap between siblings (in addition to NODE_H_LARGE) }; function getSpacing() { @@ -167,6 +174,24 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { return hidden; } + function getDescendants(nodeId) { + /** + * Get all descendant node IDs for a given node. + * Used to highlight descendants when a node is selected. + */ + const descendants = new Set(); + function addDesc(id) { + for (const child of childMap[id] || []) { + if (!descendants.has(child)) { + descendants.add(child); + addDesc(child); + } + } + } + addDesc(nodeId); + return descendants; + } + function visNodes() { const h = getHiddenSet(); return NODES.filter(n => !h.has(n.id)); @@ -203,16 +228,34 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { 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 + // Create node map for quick access by ID + const nodeMap = {}; + for (const n of nodes) nodeMap[n.id] = n; + + // Sibling stride for horizontal mode + const siblingStride = NODE_W + spacing.siblingGap; // DFS to assign x (sibling spacing) let slot = 0; + let currentX = 0; // For dynamic spacing in vertical mode + function dfs(id) { const children = cm[id] || []; - if (children.length === 0) { positions[id].x = slot++ * siblingStride; return; } + if (children.length === 0) { + // Leaf node: assign x position based on layout mode + if (layoutMode === 'vertical') { + // Dynamic spacing based on actual node height + const node = nodeMap[id]; + const h = getNodeHeight(node); + positions[id].x = currentX + h / 2; // Center of the node + currentX += h + spacing.siblingGap; // Move to next position + } else { + // Horizontal mode: constant spacing + positions[id].x = slot++ * siblingStride; + } + return; + } + // Non-leaf: recurse children, then center between them 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; @@ -320,12 +363,14 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { 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.type.toLowerCase().includes(q) - ).map(n => n.id)) - : null; + // Calculate matchIds based on filter state: + // - FILTERED_NODES === null: no filter active → matchIds = null (nothing dimmed) + // - FILTERED_NODES.size === 0: filter active, no matches → matchIds = empty Set (everything dimmed) + // - FILTERED_NODES.size > 0: filter active with matches → matchIds = FILTERED_NODES (dim non-matches) + const matchIds = FILTERED_NODES === null ? null : FILTERED_NODES; + + // Get descendants of selected node for highlighting + const descendantIds = selectedId ? getDescendants(selectedId) : new Set(); const vn = visNodes(); @@ -338,6 +383,13 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { 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 isHighlighted = selectedId && (edge.from === selectedId || descendantIds.has(edge.from)); + + // Get dynamic heights for source and target nodes + const node1 = NODES.find(n => n.id === edge.from); + const node2 = NODES.find(n => n.id === edge.to); + const h1 = node1 ? getNodeHeight(node1) : NODE_H_SMALL; + const h2 = node2 ? getNodeHeight(node2) : NODE_H_SMALL; const tp1 = transformPos(p1); const tp2 = transformPos(p2); @@ -345,8 +397,8 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { 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; + x1 = tp1.x; y1 = tp1.y + h1 / 2; + x2 = tp2.x; y2 = tp2.y - h2 / 2; cy = (y1 + y2) / 2; ctx.beginPath(); ctx.moveTo(x1, y1); @@ -361,8 +413,16 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { ctx.bezierCurveTo(cx, y1, cx, y2, x2, y2); } - ctx.strokeStyle = dimmed ? UI_COLORS.edgeDimmed : UI_COLORS.edge; - ctx.lineWidth = 1.5; + if (isHighlighted) { + ctx.strokeStyle = UI_COLORS.nodeBorderSel; + ctx.lineWidth = 2.5; + } else if (dimmed) { + ctx.strokeStyle = UI_COLORS.edgeDimmed; + ctx.lineWidth = 1.5; + } else { + ctx.strokeStyle = UI_COLORS.edge; + ctx.lineWidth = 1.5; + } ctx.stroke(); } @@ -372,17 +432,19 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { if (!p) continue; const tp = transformPos(p); const isSel = node.id === selectedId; + const isDesc = descendantIds.has(node.id); 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, transform.scale); + drawNode(node, tp.x, tp.y, isSel, isDesc, isMatch, isDim, transform.scale); } ctx.restore(); } - 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; + function drawNode(node, cx, cy, isSel, isDesc, isMatch, isDim, zoomLevel) { + // Nodes have dynamic height (with or without description) + const nodeH = getNodeHeight(node); + const hw = NODE_W / 2, hh = nodeH / 2, r = 6; const x = cx - hw, y = cy - hh; const color = KIND_COLOR[node.kind] || '#334155'; @@ -393,7 +455,7 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { // Background ctx.beginPath(); - ctx.roundRect(x, y, NODE_W, NODE_H, r); + ctx.roundRect(x, y, NODE_W, nodeH, r); ctx.fillStyle = isSel ? UI_COLORS.nodeBgSelected : UI_COLORS.nodeBg; ctx.fill(); ctx.shadowBlur = 0; @@ -401,16 +463,17 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { // Left color strip (always on left, regardless of mode) ctx.save(); ctx.beginPath(); - ctx.roundRect(x, y, NODE_W, NODE_H, r); + ctx.roundRect(x, y, NODE_W, nodeH, r); ctx.clip(); ctx.fillStyle = color; - ctx.fillRect(x, y, 4, NODE_H); + ctx.fillRect(x, y, 4, nodeH); ctx.restore(); // Border ctx.beginPath(); - ctx.roundRect(x, y, NODE_W, NODE_H, r); - if (isSel) { + ctx.roundRect(x, y, NODE_W, nodeH, r); + if (isSel || isDesc) { + // Selected node or descendant: orange border ctx.strokeStyle = UI_COLORS.nodeBorderSel; ctx.lineWidth = 1.5; } else if (isMatch) { @@ -432,7 +495,7 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { const badgeW = Math.min(rawW + 8, 66 * zoomLevel); const chevSpace = hasChildren(node.id) ? CHEV_ZONE : 8; const badgeX = (x + NODE_W - chevSpace - badgeW / zoomLevel - 2) * zoomLevel; - const badgeY = (y + (NODE_H - 14) / 2) * zoomLevel; + const badgeY = (y + (nodeH - 14) / 2) * zoomLevel; ctx.beginPath(); ctx.roundRect(badgeX, badgeY, badgeW, 14 * zoomLevel, 3 * zoomLevel); ctx.fillStyle = `${color}22`; @@ -446,7 +509,7 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { ctx.fillText(kLabel, Math.round(badgeX + badgeW / 2), Math.round(badgeY + 7 * zoomLevel)); ctx.restore(); - // Label (always horizontal) - with dynamic font size for sharp rendering at all zoom levels + // Label (centered if no description, top if description) - with dynamic font size for sharp rendering at all zoom levels const labelFontSize = 12 * zoomLevel; ctx.save(); ctx.scale(1 / zoomLevel, 1 / zoomLevel); @@ -459,7 +522,19 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { 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, Math.round(labelX), Math.round(cy * zoomLevel)); + const labelY = node.description ? (cy - 9) * zoomLevel : cy * zoomLevel; + ctx.fillText(label, Math.round(labelX), Math.round(labelY)); + + // Description (bottom line, only if present) + if (node.description) { + const descFontSize = 9 * zoomLevel; + ctx.font = `${descFontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", "Inter", "Roboto", sans-serif`; + ctx.fillStyle = isDim ? 'rgba(125,133,144,0.3)' : 'rgba(125,133,144,0.7)'; + let desc = node.description; + while (desc.length > 3 && ctx.measureText(desc).width > labelMaxW) desc = desc.slice(0, -1); + if (desc !== node.description) desc += '…'; + ctx.fillText(desc, Math.round(labelX), Math.round((cy + 8) * zoomLevel)); + } ctx.restore(); // Chevron toggle (same position in both modes) @@ -499,16 +574,26 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { 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; + // Calculate bounds using dynamic node heights + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + for (const n of vn) { + const p = pos[n.id]; + if (!p) continue; + const tp = transformPos(p); + const h = getNodeHeight(n); + minX = Math.min(minX, tp.x - NODE_W / 2); + maxX = Math.max(maxX, tp.x + NODE_W / 2); + minY = Math.min(minY, tp.y - h / 2); + maxY = Math.max(maxY, tp.y + h / 2); + } + + if (!isFinite(minX)) return; + + minX -= FIT_PADDING; + maxX += FIT_PADDING; + minY -= FIT_PADDING; + maxY += FIT_PADDING; - 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(logicalWidth / (maxX - minX), logicalHeight / (maxY - minY), FIT_MAX_SCALE); transform.scale = scale; transform.x = (logicalWidth - (minX + maxX) * scale) / 2; @@ -543,12 +628,30 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { const p = pos[n.id]; if (!p) continue; const tp = transformPos(p); + const nodeH = getNodeHeight(n); - // 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 + // Nodes have dynamic height based on description + if (Math.abs(wx - tp.x) <= NODE_W / 2 && Math.abs(wy - tp.y) <= nodeH / 2) { + const hw = NODE_W / 2, hh = nodeH / 2; + const x = tp.x - hw, y = tp.y - hh; + + // Check border (left strip) - 4px wide + const isBorder = wx >= x && wx <= x + 4; + + // Check toggle zone (chevron on right) const isToggle = hasChildren(n.id) && wx >= tp.x + NODE_W / 2 - CHEV_ZONE; - return { node: n, isToggle }; + + // Check badge (type badge) - approximate zone (right side, excluding toggle) + // Badge is positioned at: x + NODE_W - chevSpace - badgeW - 2 + // For hit testing, we use a simplified zone: last ~70px before toggle area + const chevSpace = hasChildren(n.id) ? CHEV_ZONE : 8; + const badgeZoneStart = x + NODE_W - chevSpace - 70; + const badgeZoneEnd = x + NODE_W - chevSpace - 2; + const badgeY = y + (nodeH - 14) / 2; + const isBadge = !isToggle && wx >= badgeZoneStart && wx <= badgeZoneEnd + && wy >= badgeY && wy <= badgeY + 14; + + return { node: n, isToggle, isBadge, isBorder }; } } return null; @@ -564,7 +667,7 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { return; } htmx.ajax('POST', handler.url, { - values: { event_data: JSON.stringify(eventData) }, + values: eventData, // Send data as separate fields (default command engine behavior) target: handler.target || 'body', swap: handler.swap || 'none' }); @@ -643,11 +746,25 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { const hit = hitTest(e.clientX - rect.left, e.clientY - rect.top); if (hit) { if (hit.isToggle) { + // Save screen position of clicked node before layout change + const oldPos = pos[hit.node.id]; + const oldTp = transformPos(oldPos); + const screenX = oldTp.x * transform.scale + transform.x; + const screenY = oldTp.y * transform.scale + transform.y; + // Toggle collapse if (collapsed.has(hit.node.id)) collapsed.delete(hit.node.id); else collapsed.add(hit.node.id); recomputeLayout(); + // Adjust transform to keep clicked node at same screen position + const newPos = pos[hit.node.id]; + if (newPos) { + const newTp = transformPos(newPos); + transform.x = screenX - newTp.x * transform.scale; + transform.y = screenY - newTp.y * transform.scale; + } + // Post toggle_node event postEvent('toggle_node', { node_id: hit.node.id, @@ -658,6 +775,12 @@ function initHierarchicalCanvasGraph(containerId, options = {}) { if (selectedId && !visNodes().find(n => n.id === selectedId)) { selectedId = null; } + } else if (hit.isBadge) { + // Badge click: filter by type + postEvent('_internal_filter_by_type', { query_param: 'type', value: hit.node.type }); + } else if (hit.isBorder) { + // Border click: filter by kind + postEvent('_internal_filter_by_kind', { query_param: 'kind', value: hit.node.kind }); } else { selectedId = hit.node.id; diff --git a/src/myfasthtml/controls/DataGrid.py b/src/myfasthtml/controls/DataGrid.py index 65d02e6..83fb0f9 100644 --- a/src/myfasthtml/controls/DataGrid.py +++ b/src/myfasthtml/controls/DataGrid.py @@ -14,12 +14,12 @@ from myfasthtml.controls.BaseCommands import BaseCommands from myfasthtml.controls.CycleStateControl import CycleStateControl from myfasthtml.controls.DataGridColumnsManager import DataGridColumnsManager from myfasthtml.controls.DataGridFormattingEditor import DataGridFormattingEditor -from myfasthtml.controls.DataGridQuery import DataGridQuery, DG_QUERY_FILTER from myfasthtml.controls.DslEditor import DslEditorConf from myfasthtml.controls.IconsHelper import IconsHelper from myfasthtml.controls.Keyboard import Keyboard from myfasthtml.controls.Mouse import Mouse from myfasthtml.controls.Panel import Panel, PanelConf +from myfasthtml.controls.Query import Query, QUERY_FILTER from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \ DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState from myfasthtml.controls.helpers import mk, column_type_defaults @@ -142,7 +142,7 @@ class Commands(BaseCommands): return Command("Filter", "Filter Grid", self._owner, - self._owner.filter + self._owner.filter, ) def change_selection_mode(self): @@ -212,8 +212,8 @@ class DataGrid(MultipleInstance): self.bind_command("ToggleColumnsManager", self._panel.commands.toggle_side("right")) self.bind_command("ToggleFormattingEditor", self._panel.commands.toggle_side("right")) - # add DataGridQuery - self._datagrid_filter = DataGridQuery(self) + # add Query + self._datagrid_filter = Query(self) self._datagrid_filter.bind_command("QueryChanged", self.commands.filter()) self._datagrid_filter.bind_command("CancelQuery", self.commands.filter()) self._datagrid_filter.bind_command("ChangeFilterType", self.commands.filter()) @@ -295,7 +295,7 @@ class DataGrid(MultipleInstance): for col_id, values in self._state.filtered.items(): if col_id == FILTER_INPUT_CID: if values is not None: - if self._datagrid_filter.get_query_type() == DG_QUERY_FILTER: + if self._datagrid_filter.get_query_type() == QUERY_FILTER: visible_columns = [c.col_id for c in self._state.columns if c.visible and c.col_id in df.columns] df = df[df[visible_columns].map(lambda x: values.lower() in str(x).lower()).any(axis=1)] else: @@ -632,6 +632,9 @@ class DataGrid(MultipleInstance): def get_state(self): return self._state + def get_description(self) -> str: + return self.get_table_name() + def get_settings(self): return self._settings diff --git a/src/myfasthtml/controls/HierarchicalCanvasGraph.py b/src/myfasthtml/controls/HierarchicalCanvasGraph.py index 6931f6a..547e418 100644 --- a/src/myfasthtml/controls/HierarchicalCanvasGraph.py +++ b/src/myfasthtml/controls/HierarchicalCanvasGraph.py @@ -7,6 +7,7 @@ from fasthtml.components import Div from fasthtml.xtend import Script from myfasthtml.controls.BaseCommands import BaseCommands +from myfasthtml.controls.Query import Query, QueryConf from myfasthtml.core.commands import Command from myfasthtml.core.dbmanager import DbObject from myfasthtml.core.instances import MultipleInstance @@ -47,6 +48,11 @@ class HierarchicalCanvasGraphState(DbObject): # Persisted: layout orientation ('horizontal' or 'vertical') self.layout_mode: str = 'horizontal' + # Persisted: filter state + self.filter_text: Optional[str] = None # Text search filter + self.filter_type: Optional[str] = None # Type filter (badge click) + self.filter_kind: Optional[str] = None # Kind filter (border click) + # Not persisted: current selection (ephemeral) self.ns_selected_id: Optional[str] = None @@ -65,6 +71,19 @@ class Commands(BaseCommands): self._owner, self._owner._handle_update_view_state ).htmx(target=f"#{self._id}", swap='none') + + def apply_filter(self): + """Apply current filter and update the graph display. + + This command is called when the filter changes (search text, type, or kind). + """ + return Command( + "ApplyFilter", + "Apply filter to graph", + self._owner, + self._owner._handle_apply_filter, + key="#{id}-apply-filter", + ).htmx(target=f"#{self._id}") class HierarchicalCanvasGraph(MultipleInstance): @@ -100,6 +119,11 @@ class HierarchicalCanvasGraph(MultipleInstance): self._state = HierarchicalCanvasGraphState(self) self.commands = Commands(self) + # Add Query component for filtering + self._query = Query(self, QueryConf(placeholder="Filter instances..."), _id="-query") + self._query.bind_command("QueryChanged", self.commands.apply_filter()) + self._query.bind_command("CancelQuery", self.commands.apply_filter()) + logger.debug(f"HierarchicalCanvasGraph created with id={self._id}, " f"nodes={len(conf.nodes)}, edges={len(conf.edges)}") @@ -148,25 +172,102 @@ class HierarchicalCanvasGraph(MultipleInstance): self._state.collapsed = list(collapsed_set) return self - def _handle_update_view_state(self, event_data: dict): + def _handle_update_view_state(self, transform=None, layout_mode=None): """Internal handler to update view state from client. Args: - event_data: Dictionary with 'transform' and/or 'layout_mode' keys + transform: Optional dict with zoom/pan transform state + layout_mode: Optional string with layout orientation Returns: str: Empty string (no UI update needed) """ - if 'transform' in event_data: - self._state.transform = event_data['transform'] + if transform is not None: + self._state.transform = transform logger.debug(f"Transform updated: {self._state.transform}") - if 'layout_mode' in event_data: - self._state.layout_mode = event_data['layout_mode'] + if layout_mode is not None: + self._state.layout_mode = layout_mode logger.debug(f"Layout mode updated: {self._state.layout_mode}") return "" + def _handle_apply_filter(self, query_param="text", value=None): + """Internal handler to apply filter and re-render the graph. + + Args: + query_param: Type of filter - "text", "type", or "kind" + value: The filter value (type name or kind name). Toggles off if same value clicked again. + + Returns: + self: For HTMX to render the updated graph + """ + # Save old values to detect toggle + old_filter_type = self._state.filter_type + old_filter_kind = self._state.filter_kind + + # Reset all filters + self._state.filter_text = None + self._state.filter_type = None + self._state.filter_kind = None + + # Apply the requested filter + if query_param == "text": + # Text filter from Query component + self._state.filter_text = self._query.get_query() + + elif query_param == "type": + # Type filter from badge click - toggle if same type clicked again + if old_filter_type != value: + self._state.filter_type = value + + elif query_param == "kind": + # Kind filter from border click - toggle if same kind clicked again + if old_filter_kind != value: + self._state.filter_kind = value + + logger.debug(f"Applying filter: query_param={query_param}, value={value}, " + f"text={self._state.filter_text}, type={self._state.filter_type}, kind={self._state.filter_kind}") + + return self + + def _calculate_filtered_nodes(self) -> Optional[list[str]]: + """Calculate which node IDs match the current filter criteria. + + Returns: + Optional[list[str]]: + - None: No filter is active (all nodes visible, nothing dimmed) + - []: Filter active but no matches (all nodes dimmed) + - [ids]: Filter active with matches (only these nodes visible) + """ + # If no filters are active, return None (no filtering) + if not self._state.filter_text and not self._state.filter_type and not self._state.filter_kind: + return None + + filtered_ids = [] + for node in self.conf.nodes: + matches = True + + # Check text filter (searches in id, label, type, kind) + if self._state.filter_text: + search_text = self._state.filter_text.lower() + searchable = f"{node.get('id', '')} {node.get('label', '')} {node.get('type', '')} {node.get('kind', '')}".lower() + if search_text not in searchable: + matches = False + + # Check type filter + if self._state.filter_type and node.get('type') != self._state.filter_type: + matches = False + + # Check kind filter + if self._state.filter_kind and node.get('kind') != self._state.filter_kind: + matches = False + + if matches: + filtered_ids.append(node['id']) + + return filtered_ids + def _prepare_options(self) -> dict: """Prepare JavaScript options object. @@ -179,17 +280,25 @@ class HierarchicalCanvasGraph(MultipleInstance): # Add internal handler for view state persistence events['_internal_update_state'] = self.commands.update_view_state().ajax_htmx_options() + # Add internal handlers for filtering by type and kind (badge/border clicks) + events['_internal_filter_by_type'] = self.commands.apply_filter().ajax_htmx_options() + events['_internal_filter_by_kind'] = self.commands.apply_filter().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() + # Calculate filtered nodes + filtered_nodes = self._calculate_filtered_nodes() + return { "nodes": self.conf.nodes, "edges": self.conf.edges, "collapsed": self._state.collapsed, "transform": self._state.transform, "layout_mode": self._state.layout_mode, + "filtered_nodes": filtered_nodes, "events": events } @@ -203,6 +312,9 @@ class HierarchicalCanvasGraph(MultipleInstance): options_json = json.dumps(options, indent=2) return Div( + # Query filter bar + self._query, + # Canvas element (sized by JS to fill container) Div( id=f"{self._id}_container", diff --git a/src/myfasthtml/controls/InstancesDebugger.py b/src/myfasthtml/controls/InstancesDebugger.py index 689637b..86161aa 100644 --- a/src/myfasthtml/controls/InstancesDebugger.py +++ b/src/myfasthtml/controls/InstancesDebugger.py @@ -25,22 +25,22 @@ class InstancesDebugger(SingleInstance): self.conf = conf if conf is not None else InstancesDebuggerConf() self._panel = Panel(self, _id="-panel") self._select_command = Command("ShowInstance", - "Display selected Instance", - self, - self.on_select_node).htmx(target=f"#{self._panel.get_ids().right}") - + "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() graph_conf = HierarchicalCanvasGraphConf( nodes=nodes, edges=edges, events_handlers={ - "select_node": self._select_command + "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. @@ -50,23 +50,23 @@ class InstancesDebugger(SingleInstance): 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"}, + "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_kind(self, instance) -> str: """Determine the instance kind for visualization. @@ -87,7 +87,7 @@ class InstancesDebugger(SingleInstance): return 'multiple' else: return 'multiple' # Default - + def _get_nodes_and_edges(self): """Build nodes and edges from current instances. @@ -95,57 +95,58 @@ class InstancesDebugger(SingleInstance): tuple: (nodes, edges) where nodes include id, label, kind, type """ instances = self._get_instances() - + nodes = [] edges = [] existing_ids = set() - + # 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) - + nodes.append({ - "id": node_id, - "label": instance.get_id(), - "kind": self._get_instance_kind(instance), - "type": instance.__class__.__name__ + "id": node_id, + "label": instance.get_id(), + "kind": self._get_instance_kind(instance), + "type": instance.__class__.__name__, + "description": instance.get_description() }) - + # 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 + "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}", - "kind": "multiple", # Default kind for ghost nodes - "type": "Ghost" + "id": parent_id, + "label": f"Ghost: {parent_id}", + "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. @@ -157,15 +158,15 @@ class InstancesDebugger(SingleInstance): 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: @@ -174,9 +175,9 @@ class InstancesDebugger(SingleInstance): 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()) diff --git a/src/myfasthtml/controls/Query.py b/src/myfasthtml/controls/Query.py new file mode 100644 index 0000000..968fa0e --- /dev/null +++ b/src/myfasthtml/controls/Query.py @@ -0,0 +1,109 @@ +import logging +from dataclasses import dataclass +from typing import Optional + +from fasthtml.components import * + +from myfasthtml.controls.BaseCommands import BaseCommands +from myfasthtml.controls.helpers import mk +from myfasthtml.core.commands import Command +from myfasthtml.core.dbmanager import DbObject +from myfasthtml.core.instances import MultipleInstance +from myfasthtml.icons.fluent import brain_circuit20_regular +from myfasthtml.icons.fluent_p1 import filter20_regular, search20_regular +from myfasthtml.icons.fluent_p2 import dismiss_circle20_regular + +logger = logging.getLogger("Query") + +QUERY_FILTER = "filter" +QUERY_SEARCH = "search" +QUERY_AI = "ai" + +query_type = { + QUERY_FILTER: filter20_regular, + QUERY_SEARCH: search20_regular, + QUERY_AI: brain_circuit20_regular +} + + +@dataclass +class QueryConf: + """Configuration for Query control. + + Attributes: + placeholder: Placeholder text for the search input + """ + placeholder: str = "Search..." + + +class QueryState(DbObject): + def __init__(self, owner): + with self.initializing(): + super().__init__(owner) + self.filter_type: str = "filter" + self.query: Optional[str] = None + + +class Commands(BaseCommands): + def change_filter_type(self): + return Command("ChangeFilterType", + "Change filter type", + self._owner, + self._owner.change_query_type).htmx(target=f"#{self._id}") + + def on_filter_changed(self): + return Command("QueryChanged", + "Query changed", + self._owner, + self._owner.query_changed).htmx(target=None) # prevent focus loss when typing + + def on_cancel_query(self): + return Command("CancelQuery", + "Cancel query", + self._owner, + self._owner.query_changed, + kwargs={"query": ""} + ).htmx(target=f"#{self._id}") + + +class Query(MultipleInstance): + def __init__(self, parent, conf: Optional[QueryConf] = None, _id=None): + super().__init__(parent, _id=_id) + self.conf = conf or QueryConf() + self.commands = Commands(self) + self._state = QueryState(self) + + def get_query(self): + return self._state.query + + def get_query_type(self): + return self._state.filter_type + + def change_query_type(self): + keys = list(query_type.keys()) # ["filter", "search", "ai"] + current_idx = keys.index(self._state.filter_type) + self._state.filter_type = keys[(current_idx + 1) % len(keys)] + return self + + def query_changed(self, query): + logger.debug(f"query_changed {query=}") + self._state.query = query.strip() if query is not None else None + return self # needed anyway to allow oob swap + + def render(self): + return Div( + mk.label( + Input(name="query", + value=self._state.query if self._state.query is not None else "", + placeholder=self.conf.placeholder, + **self.commands.on_filter_changed().get_htmx_params(values_encode="json")), + icon=mk.icon(query_type[self._state.filter_type], command=self.commands.change_filter_type()), + cls="input input-xs flex gap-3" + ), + mk.icon(dismiss_circle20_regular, size=24, command=self.commands.on_cancel_query()), + cls="flex", + id=self._id + ) + + def __ft__(self): + return self.render() diff --git a/src/myfasthtml/core/commands.py b/src/myfasthtml/core/commands.py index 3681b0a..7d0b45c 100644 --- a/src/myfasthtml/core/commands.py +++ b/src/myfasthtml/core/commands.py @@ -77,7 +77,8 @@ class Command: return key - def __init__(self, name, + def __init__(self, + name, description, owner=None, callback=None, @@ -102,10 +103,10 @@ class Command: if auto_register: if self._key is not None: if self._key in CommandsManager.commands_by_key: - #logger.debug(f"Command {self.name} with key={self._key} will not be registered.") + # logger.debug(f"Command {self.name} with key={self._key} will not be registered.") self.id = CommandsManager.commands_by_key[self._key].id else: - #logger.debug(f"Command {self.name} with key={self._key} will be registered.") + # logger.debug(f"Command {self.name} with key={self._key} will be registered.") CommandsManager.register(self) else: logger.warning(f"Command {self.name} has no key, it will not be registered.") @@ -150,13 +151,13 @@ class Command: logger.debug(f" will execute bound command {bound_cmd.command.name} BEFORE...") r = bound_cmd.command.execute(client_response) ret_from_before_commands.append(r) - + # Execute main callback kwargs = self._create_kwargs(self.default_kwargs, client_response, {"client_response": client_response or {}}) ret = self.callback(*self.default_args, **kwargs) - + # Execute "after" bound commands ret_from_after_commands = [] if self.owner: @@ -165,12 +166,15 @@ class Command: logger.debug(f" will execute bound command {bound_cmd.command.name} AFTER...") r = bound_cmd.command.execute(client_response) ret_from_after_commands.append(r) - + all_ret = flatten(ret, ret_from_before_commands, ret_from_after_commands, collector.results) # Set the hx-swap-oob attribute on all elements returned by the callback if self._htmx_extra[AUTO_SWAP_OOB]: - for r in all_ret[1:]: + for index, r in enumerate(all_ret[1:]): + if hasattr(r, "__ft__"): + r = r.__ft__() + all_ret[index + 1] = r if (hasattr(r, 'attrs') and "hx-swap-oob" not in r.attrs and r.get("id", None) is not None): diff --git a/src/myfasthtml/core/instances.py b/src/myfasthtml/core/instances.py index 170736e..eecd92a 100644 --- a/src/myfasthtml/core/instances.py +++ b/src/myfasthtml/core/instances.py @@ -106,6 +106,9 @@ class BaseInstance: def get_full_id(self) -> str: return f"{InstancesManager.get_session_id(self._session)}#{self._id}" + def get_description(self) -> str: + pass + def get_parent_full_id(self) -> Optional[str]: parent = self.get_parent() return parent.get_full_id() if parent else None @@ -118,8 +121,21 @@ class BaseInstance: command: Command name or Command instance to bind to command_to_bind: Command to execute when the main command is triggered when: "before" or "after" - when to execute the bound command (default: "after") + + Note: + Duplicate bindings are automatically prevented using two mechanisms: + 1. Check if the same binding already exists """ command_name = command.name if hasattr(command, "name") else command + + # Protection 1: Check if this binding already exists to prevent duplicates + existing_bindings = self._bound_commands.get(command_name, []) + for existing in existing_bindings: + if existing.command.name == command_to_bind.name and existing.when == when: + # Binding already exists, don't add it again + return + + # Add new binding bound = BoundCommand(command=command_to_bind, when=when) self._bound_commands.setdefault(command_name, []).append(bound)