2 Commits

9 changed files with 857 additions and 214 deletions

View File

@@ -274,18 +274,18 @@
// Data // Data
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
const NODES = [ const NODES = [
{ id: 'app', label: 'app', type: 'root', kind: 'RootInstance' }, { id: 'app', label: 'app', type: 'root', kind: 'RootInstance', description: 'Main application root' },
{ id: 'layout', label: 'layout', type: 'single', kind: 'Layout' }, { 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: 'left_panel', label: 'left_panel', type: 'multiple', kind: 'Panel' },
{ id: 'right_panel', label: 'right_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: '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: 'dbg_panel', label: 'dbg#panel', type: 'multiple', kind: 'Panel' },
{ id: 'canvas_graph', label: 'dbg#canvas_graph', type: 'multiple', kind: 'CanvasGraph' }, { 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' }, { 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' }, { 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_toolbar', label: 'my_grid#toolbar', type: 'multiple', kind: 'Toolbar' },
{ id: 'grid_search', label: 'my_grid#search', type: 'multiple', kind: 'Search' }, { id: 'grid_search', label: 'my_grid#search', type: 'multiple', kind: 'Search' },
{ id: 'auth_proxy', label: 'auth_proxy', type: 'unique', kind: 'AuthProxy' }, { id: 'auth_proxy', label: 'auth_proxy', type: 'unique', kind: 'AuthProxy', description: 'User authentication proxy' },
]; ];
const EDGES = [ const EDGES = [
@@ -321,10 +321,15 @@ const DETAILS = {
// Constants // Constants
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
const NODE_W = 178; const NODE_W = 178;
const NODE_H = 36; const NODE_H_SMALL = 36; // Without description
const LEVEL_H = 84; // vertical distance between levels const NODE_H_LARGE = 54; // With description
const LEAF_GAP = 22; // horizontal gap between leaf slots const LEVEL_H = 96; // Vertical distance between levels
const CHEV_ZONE = 26; // rightmost px = toggle hit zone 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 = { const TYPE_COLOR = {
root: '#2563eb', root: '#2563eb',
@@ -469,8 +474,12 @@ function draw() {
const p1 = pos[edge.from], p2 = pos[edge.to]; const p1 = pos[edge.from], p2 = pos[edge.to];
if (!p1 || !p2) continue; if (!p1 || !p2) continue;
const dimmed = matchIds && !matchIds.has(edge.from) && !matchIds.has(edge.to); const dimmed = matchIds && !matchIds.has(edge.from) && !matchIds.has(edge.to);
const x1 = p1.x, y1 = p1.y + NODE_H / 2; const node1 = NODES.find(n => n.id === edge.from);
const x2 = p2.x, y2 = p2.y - NODE_H / 2; 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; const cy = (y1 + y2) / 2;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(x1, y1); ctx.moveTo(x1, y1);
@@ -498,7 +507,8 @@ function draw() {
// ── Node renderer ──────────────────────────────────────── // ── Node renderer ────────────────────────────────────────
function drawNode(node, cx, cy, isSel, isMatch, isDim) { 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 x = cx - hw, y = cy - hh;
const color = TYPE_COLOR[node.type] || '#334155'; const color = TYPE_COLOR[node.type] || '#334155';
@@ -509,7 +519,7 @@ function drawNode(node, cx, cy, isSel, isMatch, isDim) {
// Background — dark card // Background — dark card
ctx.beginPath(); 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.fillStyle = isSel ? '#2a1f0f' : '#1c2128';
ctx.fill(); ctx.fill();
ctx.shadowBlur = 0; ctx.shadowBlur = 0;
@@ -517,15 +527,15 @@ function drawNode(node, cx, cy, isSel, isMatch, isDim) {
// Left color strip (clipped) // Left color strip (clipped)
ctx.save(); ctx.save();
ctx.beginPath(); ctx.beginPath();
ctx.roundRect(x, y, NODE_W, NODE_H, r); ctx.roundRect(x, y, NODE_W, nodeH, r);
ctx.clip(); ctx.clip();
ctx.fillStyle = color; ctx.fillStyle = color;
ctx.fillRect(x, y, 4, NODE_H); ctx.fillRect(x, y, 4, nodeH);
ctx.restore(); ctx.restore();
// Border // Border
ctx.beginPath(); ctx.beginPath();
ctx.roundRect(x, y, NODE_W, NODE_H, r); ctx.roundRect(x, y, NODE_W, nodeH, r);
if (isSel) { if (isSel) {
ctx.strokeStyle = '#f0883e'; ctx.strokeStyle = '#f0883e';
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
@@ -545,7 +555,7 @@ function drawNode(node, cx, cy, isSel, isMatch, isDim) {
const badgeW = Math.min(rawW + 8, 66); const badgeW = Math.min(rawW + 8, 66);
const chevSpace = hasChildren(node.id) ? CHEV_ZONE : 8; const chevSpace = hasChildren(node.id) ? CHEV_ZONE : 8;
const badgeX = x + NODE_W - chevSpace - badgeW - 2; const badgeX = x + NODE_W - chevSpace - badgeW - 2;
const badgeY = y + (NODE_H - 14) / 2; const badgeY = y + (nodeH - 14) / 2;
ctx.beginPath(); ctx.beginPath();
ctx.roundRect(badgeX, badgeY, badgeW, 14, 3); ctx.roundRect(badgeX, badgeY, badgeW, 14, 3);
ctx.fillStyle = `${color}22`; ctx.fillStyle = `${color}22`;
@@ -559,7 +569,7 @@ function drawNode(node, cx, cy, isSel, isMatch, isDim) {
if (kLabel !== kindText) kLabel += '…'; if (kLabel !== kindText) kLabel += '…';
ctx.fillText(kLabel, badgeX + badgeW / 2, badgeY + 7); 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.font = `${isSel ? 500 : 400} 12px monospace`;
ctx.fillStyle = isDim ? 'rgba(125,133,144,0.5)' : '#e6edf3'; ctx.fillStyle = isDim ? 'rgba(125,133,144,0.5)' : '#e6edf3';
ctx.textAlign = 'left'; ctx.textAlign = 'left';
@@ -569,7 +579,18 @@ function drawNode(node, cx, cy, isSel, isMatch, isDim) {
let label = node.label; let label = node.label;
while (label.length > 3 && ctx.measureText(label).width > labelMaxW) label = label.slice(0, -1); while (label.length > 3 && ctx.measureText(label).width > labelMaxW) label = label.slice(0, -1);
if (label !== node.label) label += '…'; 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) // Chevron toggle (if has children)
if (hasChildren(node.id)) { if (hasChildren(node.id)) {
@@ -606,13 +627,18 @@ function drawChevron(ctx, cx, cy, pointDown, color) {
function fitAll() { function fitAll() {
const vn = visNodes(); const vn = visNodes();
if (vn.length === 0) return; 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 pad = 48;
const minX = Math.min(...xs) - NODE_W / 2 - pad; let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
const maxX = Math.max(...xs) + NODE_W / 2 + pad; for (const n of vn) {
const minY = Math.min(...ys) - NODE_H / 2 - pad; const p = pos[n.id];
const maxY = Math.max(...ys) + NODE_H / 2 + pad; 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); const scale = Math.min(canvas.width / (maxX - minX), canvas.height / (maxY - minY), 1.5);
transform.scale = scale; transform.scale = scale;
transform.x = (canvas.width - (minX + maxX) * scale) / 2; transform.x = (canvas.width - (minX + maxX) * scale) / 2;
@@ -631,7 +657,8 @@ function hitTest(sx, sy) {
const n = vn[i]; const n = vn[i];
const p = pos[n.id]; const p = pos[n.id];
if (!p) continue; 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; const isToggle = hasChildren(n.id) && wx >= p.x + NODE_W / 2 - CHEV_ZONE;
return { node: n, isToggle }; return { node: n, isToggle };
} }

View File

@@ -4,6 +4,35 @@
* Styles for the canvas-based hierarchical graph visualization control. * 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 */ /* Main control wrapper */
.mf-hierarchical-canvas-graph { .mf-hierarchical-canvas-graph {
display: flex; display: flex;
@@ -19,7 +48,7 @@
flex: 1; flex: 1;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
background: #0d1117; background: var(--hcg-bg-main);
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
@@ -47,8 +76,8 @@
position: absolute; position: absolute;
top: 12px; top: 12px;
left: 12px; left: 12px;
background: rgba(22, 27, 34, 0.92); background: var(--hcg-bg-button);
border: 1px solid #30363d; border: 1px solid var(--hcg-border);
border-radius: 8px; border-radius: 8px;
padding: 6px; padding: 6px;
display: flex; display: flex;
@@ -65,10 +94,10 @@
right: 12px; right: 12px;
width: 32px; width: 32px;
height: 32px; height: 32px;
background: rgba(22, 27, 34, 0.92); background: var(--hcg-bg-button);
border: 1px solid #30363d; border: 1px solid var(--hcg-border);
border-radius: 6px; border-radius: 6px;
color: #7d8590; color: var(--hcg-text-muted);
font-size: 16px; font-size: 16px;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
@@ -82,8 +111,8 @@
} }
.mf-hcg-toggle-btn:hover { .mf-hcg-toggle-btn:hover {
color: #e6edf3; color: var(--hcg-text-primary);
background: #1c2128; background: color-mix(in oklab, var(--hcg-bg-main) 90%, var(--hcg-text-primary) 10%);
} }
/* Optional: loading state */ /* Optional: loading state */
@@ -93,7 +122,7 @@
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
color: #7d8590; color: var(--hcg-text-muted);
font-size: 14px; font-size: 14px;
font-family: system-ui, sans-serif; font-family: system-ui, sans-serif;
} }

View File

@@ -23,8 +23,8 @@
* @param {Array<Object>} options.nodes - Array of node objects with properties: * @param {Array<Object>} options.nodes - Array of node objects with properties:
* @param {string} options.nodes[].id - Unique node identifier * @param {string} options.nodes[].id - Unique node identifier
* @param {string} options.nodes[].label - Display label * @param {string} options.nodes[].label - Display label
* @param {string} options.nodes[].type - Node type (root|single|unique|multiple) * @param {string} options.nodes[].kind - Instance kind (root|single|unique|multiple)
* @param {string} options.nodes[].kind - Node kind/class name * @param {string} options.nodes[].type - Class type/name
* @param {Array<Object>} options.edges - Array of edge objects with properties: * @param {Array<Object>} options.edges - Array of edge objects with properties:
* @param {string} options.edges[].from - Source node ID * @param {string} options.edges[].from - Source node ID
* @param {string} options.edges[].to - Target node ID * @param {string} options.edges[].to - Target node ID
@@ -38,8 +38,8 @@
* @example * @example
* initHierarchicalCanvasGraph('graph-container', { * initHierarchicalCanvasGraph('graph-container', {
* nodes: [ * nodes: [
* { id: 'root', label: 'Root', type: 'root', kind: 'RootInstance' }, * { id: 'root', label: 'Root', kind: 'root', type: 'RootInstance' },
* { id: 'child', label: 'Child', type: 'single', kind: 'MyComponent' } * { id: 'child', label: 'Child', kind: 'single', type: 'MyComponent' }
* ], * ],
* edges: [{ from: 'root', to: 'child' }], * edges: [{ from: 'root', to: 'child' }],
* collapsed: [], * collapsed: [],
@@ -68,14 +68,21 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
const NODES = options.nodes || []; const NODES = options.nodes || [];
const EDGES = options.edges || []; const EDGES = options.edges || [];
const EVENTS = options.events || {}; 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 // Visual Constants
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
const NODE_W = 178; // Node width in pixels const NODE_W = 178; // Node width in pixels
const NODE_H = 36; // Node height 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) 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_SIZE = 32; // Toggle button dimensions
const TOGGLE_BTN_POS = 12; // Toggle button offset from corner const TOGGLE_BTN_POS = 12; // Toggle button offset from corner
@@ -97,18 +104,34 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
const VERTICAL_MODE_SPACING = { const VERTICAL_MODE_SPACING = {
levelGap: 220, // horizontal distance between parent-child (after swap) 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() { function getSpacing() {
return layoutMode === 'vertical' ? VERTICAL_MODE_SPACING : HORIZONTAL_MODE_SPACING; return layoutMode === 'vertical' ? VERTICAL_MODE_SPACING : HORIZONTAL_MODE_SPACING;
} }
const TYPE_COLOR = { // Color mapping based on instance kind (read from CSS variables for DaisyUI theme compatibility)
root: '#2563eb', const computedStyle = getComputedStyle(document.documentElement);
single: '#7c3aed', const KIND_COLOR = {
multiple: '#047857', root: computedStyle.getPropertyValue('--hcg-color-root').trim() || '#2563eb',
unique: '#b45309', 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 +156,9 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
const collapsed = new Set(options.collapsed || []); const collapsed = new Set(options.collapsed || []);
let selectedId = null; let selectedId = null;
let filterQuery = ''; let filterQuery = '';
let transform = { x: 0, y: 0, scale: 1 }; let transform = options.transform || { x: 0, y: 0, scale: 1 };
let pos = {}; let pos = {};
let layoutMode = 'horizontal'; // 'horizontal' | 'vertical' let layoutMode = options.layout_mode || 'horizontal'; // 'horizontal' | 'vertical'
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// Visibility & Layout // Visibility & Layout
@@ -151,6 +174,24 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
return hidden; 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() { function visNodes() {
const h = getHiddenSet(); const h = getHiddenSet();
return NODES.filter(n => !h.has(n.id)); return NODES.filter(n => !h.has(n.id));
@@ -187,16 +228,34 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
const positions = {}; const positions = {};
for (const n of nodes) positions[n.id] = { x: 0, y: (depth[n.id] || 0) * spacing.levelGap }; 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) // Create node map for quick access by ID
const siblingStride = layoutMode === 'vertical' const nodeMap = {};
? NODE_H + spacing.siblingGap // Vertical: nodes stack by height for (const n of nodes) nodeMap[n.id] = n;
: NODE_W + spacing.siblingGap; // Horizontal: nodes spread by width
// Sibling stride for horizontal mode
const siblingStride = NODE_W + spacing.siblingGap;
// DFS to assign x (sibling spacing) // DFS to assign x (sibling spacing)
let slot = 0; let slot = 0;
let currentX = 0; // For dynamic spacing in vertical mode
function dfs(id) { function dfs(id) {
const children = cm[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); for (const c of children) dfs(c);
const xs = children.map(c => positions[c].x); const xs = children.map(c => positions[c].x);
positions[id].x = (Math.min(...xs) + Math.max(...xs)) / 2; positions[id].x = (Math.min(...xs) + Math.max(...xs)) / 2;
@@ -230,11 +289,21 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.id = `${containerId}_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); container.appendChild(canvas);
const ctx = canvas.getContext('2d'); 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 // Layout toggle button overlay
const toggleBtn = document.createElement('button'); const toggleBtn = document.createElement('button');
toggleBtn.className = 'mf-hcg-toggle-btn'; toggleBtn.className = 'mf-hcg-toggle-btn';
@@ -249,12 +318,28 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
// Recompute layout with new spacing // Recompute layout with new spacing
recomputeLayout(); recomputeLayout();
fitAll(); fitAll();
// Save layout mode change
saveViewState();
}); });
container.appendChild(toggleBtn); container.appendChild(toggleBtn);
function resize() { function resize() {
canvas.width = container.clientWidth; const ratio = window.devicePixelRatio || 1;
canvas.height = container.clientHeight;
// 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(); draw();
} }
@@ -264,9 +349,9 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
function drawDotGrid() { function drawDotGrid() {
const ox = ((transform.x % DOT_GRID_SPACING) + DOT_GRID_SPACING) % DOT_GRID_SPACING; 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; const oy = ((transform.y % DOT_GRID_SPACING) + DOT_GRID_SPACING) % DOT_GRID_SPACING;
ctx.fillStyle = 'rgba(125,133,144,0.12)'; ctx.fillStyle = UI_COLORS.dotGrid;
for (let x = ox - DOT_GRID_SPACING; x < canvas.width + DOT_GRID_SPACING; x += DOT_GRID_SPACING) { for (let x = ox - DOT_GRID_SPACING; x < logicalWidth + DOT_GRID_SPACING; x += DOT_GRID_SPACING) {
for (let y = oy - DOT_GRID_SPACING; y < canvas.height + DOT_GRID_SPACING; y += DOT_GRID_SPACING) { for (let y = oy - DOT_GRID_SPACING; y < logicalHeight + DOT_GRID_SPACING; y += DOT_GRID_SPACING) {
ctx.beginPath(); ctx.beginPath();
ctx.arc(x, y, DOT_GRID_RADIUS, 0, Math.PI * 2); ctx.arc(x, y, DOT_GRID_RADIUS, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
@@ -275,15 +360,17 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
} }
function draw() { function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, logicalWidth, logicalHeight);
drawDotGrid(); drawDotGrid();
const q = filterQuery.trim().toLowerCase(); // Calculate matchIds based on filter state:
const matchIds = q // - FILTERED_NODES === null: no filter active → matchIds = null (nothing dimmed)
? new Set(NODES.filter(n => // - FILTERED_NODES.size === 0: filter active, no matches → matchIds = empty Set (everything dimmed)
n.label.toLowerCase().includes(q) || n.kind.toLowerCase().includes(q) // - FILTERED_NODES.size > 0: filter active with matches → matchIds = FILTERED_NODES (dim non-matches)
).map(n => n.id)) const matchIds = FILTERED_NODES === null ? null : FILTERED_NODES;
: null;
// Get descendants of selected node for highlighting
const descendantIds = selectedId ? getDescendants(selectedId) : new Set();
const vn = visNodes(); const vn = visNodes();
@@ -296,6 +383,13 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
const p1 = pos[edge.from], p2 = pos[edge.to]; const p1 = pos[edge.from], p2 = pos[edge.to];
if (!p1 || !p2) continue; if (!p1 || !p2) continue;
const dimmed = matchIds && !matchIds.has(edge.from) && !matchIds.has(edge.to); 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 tp1 = transformPos(p1);
const tp2 = transformPos(p2); const tp2 = transformPos(p2);
@@ -303,8 +397,8 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
let x1, y1, x2, y2, cx, cy; let x1, y1, x2, y2, cx, cy;
if (layoutMode === 'horizontal') { if (layoutMode === 'horizontal') {
// Horizontal: edges go from bottom of parent to top of child // Horizontal: edges go from bottom of parent to top of child
x1 = tp1.x; y1 = tp1.y + NODE_H / 2; x1 = tp1.x; y1 = tp1.y + h1 / 2;
x2 = tp2.x; y2 = tp2.y - NODE_H / 2; x2 = tp2.x; y2 = tp2.y - h2 / 2;
cy = (y1 + y2) / 2; cy = (y1 + y2) / 2;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(x1, y1); ctx.moveTo(x1, y1);
@@ -319,8 +413,16 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
ctx.bezierCurveTo(cx, y1, cx, y2, x2, y2); ctx.bezierCurveTo(cx, y1, cx, y2, x2, y2);
} }
ctx.strokeStyle = dimmed ? 'rgba(48,54,61,0.25)' : 'rgba(48,54,61,0.9)'; if (isHighlighted) {
ctx.strokeStyle = UI_COLORS.nodeBorderSel;
ctx.lineWidth = 2.5;
} else if (dimmed) {
ctx.strokeStyle = UI_COLORS.edgeDimmed;
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
} else {
ctx.strokeStyle = UI_COLORS.edge;
ctx.lineWidth = 1.5;
}
ctx.stroke(); ctx.stroke();
} }
@@ -330,49 +432,52 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
if (!p) continue; if (!p) continue;
const tp = transformPos(p); const tp = transformPos(p);
const isSel = node.id === selectedId; const isSel = node.id === selectedId;
const isDesc = descendantIds.has(node.id);
const isMatch = matchIds !== null && matchIds.has(node.id); const isMatch = matchIds !== null && matchIds.has(node.id);
const isDim = 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, isDesc, isMatch, isDim, transform.scale);
} }
ctx.restore(); ctx.restore();
} }
function drawNode(node, cx, cy, isSel, isMatch, isDim) { function drawNode(node, cx, cy, isSel, isDesc, isMatch, isDim, zoomLevel) {
// Nodes always keep same dimensions and horizontal text // Nodes have dynamic height (with or without description)
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 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; ctx.globalAlpha = isDim ? 0.15 : 1;
// Glow for selected // Glow for selected
if (isSel) { ctx.shadowColor = '#f0883e'; ctx.shadowBlur = 16; } if (isSel) { ctx.shadowColor = UI_COLORS.nodeGlow; ctx.shadowBlur = 16; }
// Background // Background
ctx.beginPath(); 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.fillStyle = isSel ? UI_COLORS.nodeBgSelected : UI_COLORS.nodeBg;
ctx.fill(); ctx.fill();
ctx.shadowBlur = 0; ctx.shadowBlur = 0;
// Left color strip (always on left, regardless of mode) // Left color strip (always on left, regardless of mode)
ctx.save(); ctx.save();
ctx.beginPath(); ctx.beginPath();
ctx.roundRect(x, y, NODE_W, NODE_H, r); ctx.roundRect(x, y, NODE_W, nodeH, r);
ctx.clip(); ctx.clip();
ctx.fillStyle = color; ctx.fillStyle = color;
ctx.fillRect(x, y, 4, NODE_H); ctx.fillRect(x, y, 4, nodeH);
ctx.restore(); ctx.restore();
// Border // Border
ctx.beginPath(); ctx.beginPath();
ctx.roundRect(x, y, NODE_W, NODE_H, r); ctx.roundRect(x, y, NODE_W, nodeH, r);
if (isSel) { if (isSel || isDesc) {
ctx.strokeStyle = '#f0883e'; // Selected node or descendant: orange border
ctx.strokeStyle = UI_COLORS.nodeBorderSel;
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
} else if (isMatch) { } else if (isMatch) {
ctx.strokeStyle = '#e3b341'; ctx.strokeStyle = UI_COLORS.nodeBorderMatch;
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
} else { } else {
ctx.strokeStyle = `${color}44`; ctx.strokeStyle = `${color}44`;
@@ -380,37 +485,57 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
} }
ctx.stroke(); ctx.stroke();
// Kind badge // Type badge (class name) - with dynamic font size for sharp rendering at all zoom levels
const kindText = node.kind; const kindText = node.type;
ctx.font = '9px system-ui'; 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 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 chevSpace = hasChildren(node.id) ? CHEV_ZONE : 8;
const badgeX = x + NODE_W - chevSpace - badgeW - 2; const badgeX = (x + NODE_W - chevSpace - badgeW / zoomLevel - 2) * zoomLevel;
const badgeY = y + (NODE_H - 14) / 2; const badgeY = (y + (nodeH - 14) / 2) * zoomLevel;
ctx.beginPath(); ctx.beginPath();
ctx.roundRect(badgeX, badgeY, badgeW, 14, 3); ctx.roundRect(badgeX, badgeY, badgeW, 14 * zoomLevel, 3 * zoomLevel);
ctx.fillStyle = `${color}22`; ctx.fillStyle = `${color}22`;
ctx.fill(); ctx.fill();
ctx.fillStyle = color; ctx.fillStyle = color;
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
let kLabel = kindText; 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 += '…'; 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) // Label (centered if no description, top if description) - with dynamic font size for sharp rendering at all zoom levels
ctx.font = `${isSel ? 500 : 400} 12px monospace`; const labelFontSize = 12 * zoomLevel;
ctx.fillStyle = isDim ? 'rgba(125,133,144,0.5)' : '#e6edf3'; 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.textAlign = 'left';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
const labelX = x + 12; const labelX = (x + 12) * zoomLevel;
const labelMaxW = badgeX - labelX - 6; const labelMaxW = (badgeX / zoomLevel - (x + 12) - 6) * zoomLevel;
let label = node.label; let label = node.label;
while (label.length > 3 && ctx.measureText(label).width > labelMaxW) label = label.slice(0, -1); while (label.length > 3 && ctx.measureText(label).width > labelMaxW) label = label.slice(0, -1);
if (label !== node.label) label += '…'; if (label !== node.label) label += '…';
ctx.fillText(label, labelX, cy); 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) // Chevron toggle (same position in both modes)
if (hasChildren(node.id)) { if (hasChildren(node.id)) {
@@ -449,23 +574,47 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
const vn = visNodes(); const vn = visNodes();
if (vn.length === 0) return; if (vn.length === 0) return;
// Get transformed positions // Calculate bounds using dynamic node heights
const tps = vn.map(n => pos[n.id] ? transformPos(pos[n.id]) : null).filter(p => p !== null); let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
if (tps.length === 0) return; 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);
}
const xs = tps.map(p => p.x); if (!isFinite(minX)) return;
const ys = tps.map(p => p.y);
const minX = Math.min(...xs) - NODE_W / 2 - FIT_PADDING; minX -= FIT_PADDING;
const maxX = Math.max(...xs) + NODE_W / 2 + FIT_PADDING; maxX += FIT_PADDING;
const minY = Math.min(...ys) - NODE_H / 2 - FIT_PADDING; minY -= FIT_PADDING;
const maxY = Math.max(...ys) + NODE_H / 2 + FIT_PADDING; maxY += 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.scale = scale;
transform.x = (canvas.width - (minX + maxX) * scale) / 2; transform.x = (logicalWidth - (minX + maxX) * scale) / 2;
transform.y = (canvas.height - (minY + maxY) * scale) / 2; transform.y = (logicalHeight - (minY + maxY) * scale) / 2;
draw(); 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 // Hit testing
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
@@ -479,12 +628,30 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
const p = pos[n.id]; const p = pos[n.id];
if (!p) continue; if (!p) continue;
const tp = transformPos(p); const tp = transformPos(p);
const nodeH = getNodeHeight(n);
// Nodes keep same dimensions in both modes // Nodes have dynamic height based on description
if (Math.abs(wx - tp.x) <= NODE_W / 2 && Math.abs(wy - tp.y) <= NODE_H / 2) { if (Math.abs(wx - tp.x) <= NODE_W / 2 && Math.abs(wy - tp.y) <= nodeH / 2) {
// Toggle zone always on the right side of node 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; 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; return null;
@@ -499,14 +666,20 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
console.warn(`HierarchicalCanvasGraph: No handler for event "${eventName}"`); console.warn(`HierarchicalCanvasGraph: No handler for event "${eventName}"`);
return; return;
} }
htmx.ajax('POST', handler.url, { 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', target: handler.target || 'body',
swap: handler.swap || 'none' swap: handler.swap || 'none'
}); });
} }
function saveViewState() {
postEvent('_internal_update_state', {
transform: transform,
layout_mode: layoutMode
});
}
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// Interaction // Interaction
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
@@ -520,16 +693,47 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
panOrigin = { x: e.clientX, y: e.clientY }; panOrigin = { x: e.clientX, y: e.clientY };
tfAtStart = { ...transform }; tfAtStart = { ...transform };
canvas.style.cursor = 'grabbing'; canvas.style.cursor = 'grabbing';
hideTooltip();
}); });
window.addEventListener('mousemove', e => { window.addEventListener('mousemove', e => {
if (!isPanning) return; if (isPanning) {
const dx = e.clientX - panOrigin.x; const dx = e.clientX - panOrigin.x;
const dy = e.clientY - panOrigin.y; const dy = e.clientY - panOrigin.y;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) didMove = true; if (Math.abs(dx) > 3 || Math.abs(dy) > 3) didMove = true;
transform.x = tfAtStart.x + dx; transform.x = tfAtStart.x + dx;
transform.y = tfAtStart.y + dy; transform.y = tfAtStart.y + dy;
draw(); 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 => { window.addEventListener('mouseup', e => {
@@ -542,11 +746,25 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
const hit = hitTest(e.clientX - rect.left, e.clientY - rect.top); const hit = hitTest(e.clientX - rect.left, e.clientY - rect.top);
if (hit) { if (hit) {
if (hit.isToggle) { 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 // Toggle collapse
if (collapsed.has(hit.node.id)) collapsed.delete(hit.node.id); if (collapsed.has(hit.node.id)) collapsed.delete(hit.node.id);
else collapsed.add(hit.node.id); else collapsed.add(hit.node.id);
recomputeLayout(); 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 // Post toggle_node event
postEvent('toggle_node', { postEvent('toggle_node', {
node_id: hit.node.id, node_id: hit.node.id,
@@ -557,6 +775,12 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
if (selectedId && !visNodes().find(n => n.id === selectedId)) { if (selectedId && !visNodes().find(n => n.id === selectedId)) {
selectedId = null; 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 { } else {
selectedId = hit.node.id; selectedId = hit.node.id;
@@ -564,19 +788,24 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
postEvent('select_node', { postEvent('select_node', {
node_id: hit.node.id, node_id: hit.node.id,
label: hit.node.label, label: hit.node.label,
type: hit.node.type, kind: hit.node.kind,
kind: hit.node.kind type: hit.node.type
}); });
} }
} else { } else {
selectedId = null; selectedId = null;
} }
draw(); draw();
} else {
// Panning occurred - save view state
saveViewState();
} }
}); });
let zoomTimeout = null;
canvas.addEventListener('wheel', e => { canvas.addEventListener('wheel', e => {
e.preventDefault(); e.preventDefault();
hideTooltip();
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left; const mx = e.clientX - rect.left;
const my = e.clientY - rect.top; const my = e.clientY - rect.top;
@@ -586,8 +815,16 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
transform.y = my - (my - transform.y) * (ns / transform.scale); transform.y = my - (my - transform.y) * (ns / transform.scale);
transform.scale = ns; transform.scale = ns;
draw(); draw();
// Debounce save to avoid too many requests during continuous zoom
clearTimeout(zoomTimeout);
zoomTimeout = setTimeout(saveViewState, 500);
}, { passive: false }); }, { passive: false });
canvas.addEventListener('mouseleave', () => {
hideTooltip();
});
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// Resize observer (stable zoom on resize) // Resize observer (stable zoom on resize)
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
@@ -613,5 +850,12 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
recomputeLayout(); recomputeLayout();
resize(); resize();
// 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); setTimeout(fitAll, 30);
} }
}

View File

@@ -14,12 +14,12 @@ from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.CycleStateControl import CycleStateControl from myfasthtml.controls.CycleStateControl import CycleStateControl
from myfasthtml.controls.DataGridColumnsManager import DataGridColumnsManager from myfasthtml.controls.DataGridColumnsManager import DataGridColumnsManager
from myfasthtml.controls.DataGridFormattingEditor import DataGridFormattingEditor from myfasthtml.controls.DataGridFormattingEditor import DataGridFormattingEditor
from myfasthtml.controls.DataGridQuery import DataGridQuery, DG_QUERY_FILTER
from myfasthtml.controls.DslEditor import DslEditorConf from myfasthtml.controls.DslEditor import DslEditorConf
from myfasthtml.controls.IconsHelper import IconsHelper from myfasthtml.controls.IconsHelper import IconsHelper
from myfasthtml.controls.Keyboard import Keyboard from myfasthtml.controls.Keyboard import Keyboard
from myfasthtml.controls.Mouse import Mouse from myfasthtml.controls.Mouse import Mouse
from myfasthtml.controls.Panel import Panel, PanelConf from myfasthtml.controls.Panel import Panel, PanelConf
from myfasthtml.controls.Query import Query, QUERY_FILTER
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \ from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState
from myfasthtml.controls.helpers import mk, column_type_defaults from myfasthtml.controls.helpers import mk, column_type_defaults
@@ -142,7 +142,7 @@ class Commands(BaseCommands):
return Command("Filter", return Command("Filter",
"Filter Grid", "Filter Grid",
self._owner, self._owner,
self._owner.filter self._owner.filter,
) )
def change_selection_mode(self): 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("ToggleColumnsManager", self._panel.commands.toggle_side("right"))
self.bind_command("ToggleFormattingEditor", self._panel.commands.toggle_side("right")) self.bind_command("ToggleFormattingEditor", self._panel.commands.toggle_side("right"))
# add DataGridQuery # add Query
self._datagrid_filter = DataGridQuery(self) self._datagrid_filter = Query(self)
self._datagrid_filter.bind_command("QueryChanged", self.commands.filter()) self._datagrid_filter.bind_command("QueryChanged", self.commands.filter())
self._datagrid_filter.bind_command("CancelQuery", self.commands.filter()) self._datagrid_filter.bind_command("CancelQuery", self.commands.filter())
self._datagrid_filter.bind_command("ChangeFilterType", 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(): for col_id, values in self._state.filtered.items():
if col_id == FILTER_INPUT_CID: if col_id == FILTER_INPUT_CID:
if values is not None: 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] 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)] df = df[df[visible_columns].map(lambda x: values.lower() in str(x).lower()).any(axis=1)]
else: else:
@@ -632,6 +632,9 @@ class DataGrid(MultipleInstance):
def get_state(self): def get_state(self):
return self._state return self._state
def get_description(self) -> str:
return self.get_table_name()
def get_settings(self): def get_settings(self):
return self._settings return self._settings

View File

@@ -6,6 +6,9 @@ from typing import Optional
from fasthtml.components import Div from fasthtml.components import Div
from fasthtml.xtend import Script 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.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance from myfasthtml.core.instances import MultipleInstance
@@ -30,7 +33,7 @@ class HierarchicalCanvasGraphConf:
class HierarchicalCanvasGraphState(DbObject): class HierarchicalCanvasGraphState(DbObject):
"""Persistent state for HierarchicalCanvasGraph. """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): def __init__(self, owner, save_state=True):
@@ -39,11 +42,48 @@ class HierarchicalCanvasGraphState(DbObject):
# Persisted: set of collapsed node IDs (stored as list for JSON serialization) # Persisted: set of collapsed node IDs (stored as list for JSON serialization)
self.collapsed: list = [] 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'
# 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) # Not persisted: current selection (ephemeral)
self.ns_selected_id: Optional[str] = None 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')
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): class HierarchicalCanvasGraph(MultipleInstance):
@@ -77,6 +117,12 @@ class HierarchicalCanvasGraph(MultipleInstance):
super().__init__(parent, _id=_id) super().__init__(parent, _id=_id)
self.conf = conf self.conf = conf
self._state = HierarchicalCanvasGraphState(self) 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}, " logger.debug(f"HierarchicalCanvasGraph created with id={self._id}, "
f"nodes={len(conf.nodes)}, edges={len(conf.edges)}") f"nodes={len(conf.nodes)}, edges={len(conf.edges)}")
@@ -126,6 +172,102 @@ class HierarchicalCanvasGraph(MultipleInstance):
self._state.collapsed = list(collapsed_set) self._state.collapsed = list(collapsed_set)
return self return self
def _handle_update_view_state(self, transform=None, layout_mode=None):
"""Internal handler to update view state from client.
Args:
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 is not None:
self._state.transform = transform
logger.debug(f"Transform updated: {self._state.transform}")
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: def _prepare_options(self) -> dict:
"""Prepare JavaScript options object. """Prepare JavaScript options object.
@@ -134,14 +276,29 @@ class HierarchicalCanvasGraph(MultipleInstance):
""" """
# Convert event handlers to HTMX options # Convert event handlers to HTMX options
events = {} events = {}
# 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: if self.conf.events_handlers:
for event_name, command in self.conf.events_handlers.items(): for event_name, command in self.conf.events_handlers.items():
events[event_name] = command.ajax_htmx_options() events[event_name] = command.ajax_htmx_options()
# Calculate filtered nodes
filtered_nodes = self._calculate_filtered_nodes()
return { return {
"nodes": self.conf.nodes, "nodes": self.conf.nodes,
"edges": self.conf.edges, "edges": self.conf.edges,
"collapsed": self._state.collapsed, "collapsed": self._state.collapsed,
"transform": self._state.transform,
"layout_mode": self._state.layout_mode,
"filtered_nodes": filtered_nodes,
"events": events "events": events
} }
@@ -155,6 +312,9 @@ class HierarchicalCanvasGraph(MultipleInstance):
options_json = json.dumps(options, indent=2) options_json = json.dumps(options, indent=2)
return Div( return Div(
# Query filter bar
self._query,
# Canvas element (sized by JS to fill container) # Canvas element (sized by JS to fill container)
Div( Div(
id=f"{self._id}_container", id=f"{self._id}_container",

View File

@@ -1,3 +1,5 @@
from dataclasses import dataclass
from myfasthtml.controls.HierarchicalCanvasGraph import HierarchicalCanvasGraph, HierarchicalCanvasGraphConf from myfasthtml.controls.HierarchicalCanvasGraph import HierarchicalCanvasGraph, HierarchicalCanvasGraphConf
from myfasthtml.controls.Panel import Panel from myfasthtml.controls.Panel import Panel
from myfasthtml.controls.Properties import Properties 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 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): class InstancesDebugger(SingleInstance):
def __init__(self, parent, _id=None): def __init__(self, parent, conf: InstancesDebuggerConf = None, _id=None):
super().__init__(parent, _id=_id) super().__init__(parent, _id=_id)
self.conf = conf if conf is not None else InstancesDebuggerConf()
self._panel = Panel(self, _id="-panel") self._panel = Panel(self, _id="-panel")
self._select_command = Command("ShowInstance", self._select_command = Command("ShowInstance",
"Display selected Instance", "Display selected Instance",
@@ -30,7 +45,7 @@ class InstancesDebugger(SingleInstance):
"""Handle node selection event from canvas graph. """Handle node selection event from canvas graph.
Args: 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") node_id = event_data.get("node_id")
if not node_id: if not node_id:
@@ -52,8 +67,8 @@ class InstancesDebugger(SingleInstance):
properties_def, properties_def,
_id="-properties")) _id="-properties"))
def _get_instance_type(self, instance) -> str: def _get_instance_kind(self, instance) -> str:
"""Determine the instance type for visualization. """Determine the instance kind for visualization.
Args: Args:
instance: The instance object instance: The instance object
@@ -77,7 +92,7 @@ class InstancesDebugger(SingleInstance):
"""Build nodes and edges from current instances. """Build nodes and edges from current instances.
Returns: 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() instances = self._get_instances()
@@ -85,7 +100,7 @@ class InstancesDebugger(SingleInstance):
edges = [] edges = []
existing_ids = set() existing_ids = set()
# Create nodes with type and kind information # Create nodes with kind (instance kind) and type (class name)
for instance in instances: for instance in instances:
node_id = instance.get_full_id() node_id = instance.get_full_id()
existing_ids.add(node_id) existing_ids.add(node_id)
@@ -93,8 +108,9 @@ class InstancesDebugger(SingleInstance):
nodes.append({ nodes.append({
"id": node_id, "id": node_id,
"label": instance.get_id(), "label": instance.get_id(),
"type": self._get_instance_type(instance), "kind": self._get_instance_kind(instance),
"kind": instance.__class__.__name__ "type": instance.__class__.__name__,
"description": instance.get_description()
}) })
# Track nodes with parents # Track nodes with parents
@@ -120,13 +136,48 @@ class InstancesDebugger(SingleInstance):
nodes.append({ nodes.append({
"id": parent_id, "id": parent_id,
"label": f"Ghost: {parent_id}", "label": f"Ghost: {parent_id}",
"type": "multiple", # Default type for ghost nodes "kind": "multiple", # Default kind for ghost nodes
"kind": "Ghost" "type": "Ghost"
}) })
existing_ids.add(parent_id) 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 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): def _get_instances(self):
return list(InstancesManager.instances.values()) return list(InstancesManager.instances.values())

View File

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

View File

@@ -77,7 +77,8 @@ class Command:
return key return key
def __init__(self, name, def __init__(self,
name,
description, description,
owner=None, owner=None,
callback=None, callback=None,
@@ -170,7 +171,10 @@ class Command:
# Set the hx-swap-oob attribute on all elements returned by the callback # Set the hx-swap-oob attribute on all elements returned by the callback
if self._htmx_extra[AUTO_SWAP_OOB]: 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') if (hasattr(r, 'attrs')
and "hx-swap-oob" not in r.attrs and "hx-swap-oob" not in r.attrs
and r.get("id", None) is not None): and r.get("id", None) is not None):

View File

@@ -106,6 +106,9 @@ class BaseInstance:
def get_full_id(self) -> str: def get_full_id(self) -> str:
return f"{InstancesManager.get_session_id(self._session)}#{self._id}" return f"{InstancesManager.get_session_id(self._session)}#{self._id}"
def get_description(self) -> str:
pass
def get_parent_full_id(self) -> Optional[str]: def get_parent_full_id(self) -> Optional[str]:
parent = self.get_parent() parent = self.get_parent()
return parent.get_full_id() if parent else None 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: Command name or Command instance to bind to
command_to_bind: Command to execute when the main command is triggered command_to_bind: Command to execute when the main command is triggered
when: "before" or "after" - when to execute the bound command (default: "after") 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 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) bound = BoundCommand(command=command_to_bind, when=when)
self._bound_commands.setdefault(command_name, []).append(bound) self._bound_commands.setdefault(command_name, []).append(bound)