2 Commits

9 changed files with 857 additions and 214 deletions

View File

@@ -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 };
}

View File

@@ -4,6 +4,35 @@
* Styles for the canvas-based hierarchical graph visualization control.
*/
/* *********************************************** */
/* ********** Color Variables (DaisyUI) ********** */
/* *********************************************** */
/* Instance kind colors - hardcoded to preserve visual identity */
:root {
--hcg-color-root: #2563eb;
--hcg-color-single: #7c3aed;
--hcg-color-multiple: #047857;
--hcg-color-unique: #b45309;
/* UI colors */
--hcg-bg-main: var(--color-base-100, #0d1117);
--hcg-bg-button: var(--color-base-200, rgba(22, 27, 34, 0.92));
--hcg-border: var(--color-border, #30363d);
--hcg-text-muted: color-mix(in oklab, var(--color-base-content, #e6edf3) 50%, transparent);
--hcg-text-primary: var(--color-base-content, #e6edf3);
/* Canvas drawing colors */
--hcg-dot-grid: rgba(125, 133, 144, 0.12);
--hcg-edge: rgba(48, 54, 61, 0.9);
--hcg-edge-dimmed: rgba(48, 54, 61, 0.25);
--hcg-node-bg: var(--color-base-300, #1c2128);
--hcg-node-bg-selected: color-mix(in oklab, var(--color-base-300, #1c2128) 70%, #f0883e 30%);
--hcg-node-border-selected: #f0883e;
--hcg-node-border-match: #e3b341;
--hcg-node-glow: #f0883e;
}
/* Main control wrapper */
.mf-hierarchical-canvas-graph {
display: flex;
@@ -19,7 +48,7 @@
flex: 1;
position: relative;
overflow: hidden;
background: #0d1117;
background: var(--hcg-bg-main);
width: 100%;
height: 100%;
}
@@ -47,8 +76,8 @@
position: absolute;
top: 12px;
left: 12px;
background: rgba(22, 27, 34, 0.92);
border: 1px solid #30363d;
background: var(--hcg-bg-button);
border: 1px solid var(--hcg-border);
border-radius: 8px;
padding: 6px;
display: flex;
@@ -65,10 +94,10 @@
right: 12px;
width: 32px;
height: 32px;
background: rgba(22, 27, 34, 0.92);
border: 1px solid #30363d;
background: var(--hcg-bg-button);
border: 1px solid var(--hcg-border);
border-radius: 6px;
color: #7d8590;
color: var(--hcg-text-muted);
font-size: 16px;
cursor: pointer;
display: flex;
@@ -82,8 +111,8 @@
}
.mf-hcg-toggle-btn:hover {
color: #e6edf3;
background: #1c2128;
color: var(--hcg-text-primary);
background: color-mix(in oklab, var(--hcg-bg-main) 90%, var(--hcg-text-primary) 10%);
}
/* Optional: loading state */
@@ -93,7 +122,7 @@
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #7d8590;
color: var(--hcg-text-muted);
font-size: 14px;
font-family: system-ui, sans-serif;
}

View File

@@ -23,8 +23,8 @@
* @param {Array<Object>} options.nodes - Array of node objects with properties:
* @param {string} options.nodes[].id - Unique node identifier
* @param {string} options.nodes[].label - Display label
* @param {string} options.nodes[].type - Node type (root|single|unique|multiple)
* @param {string} options.nodes[].kind - Node kind/class name
* @param {string} options.nodes[].kind - Instance kind (root|single|unique|multiple)
* @param {string} options.nodes[].type - Class type/name
* @param {Array<Object>} options.edges - Array of edge objects with properties:
* @param {string} options.edges[].from - Source node ID
* @param {string} options.edges[].to - Target node ID
@@ -38,8 +38,8 @@
* @example
* initHierarchicalCanvasGraph('graph-container', {
* nodes: [
* { id: 'root', label: 'Root', type: 'root', kind: 'RootInstance' },
* { id: 'child', label: 'Child', type: 'single', kind: 'MyComponent' }
* { id: 'root', label: 'Root', kind: 'root', type: 'RootInstance' },
* { id: 'child', label: 'Child', kind: 'single', type: 'MyComponent' }
* ],
* edges: [{ from: 'root', to: 'child' }],
* collapsed: [],
@@ -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,18 +104,34 @@ 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() {
return layoutMode === 'vertical' ? VERTICAL_MODE_SPACING : HORIZONTAL_MODE_SPACING;
}
const TYPE_COLOR = {
root: '#2563eb',
single: '#7c3aed',
multiple: '#047857',
unique: '#b45309',
// Color mapping based on instance kind (read from CSS variables for DaisyUI theme compatibility)
const computedStyle = getComputedStyle(document.documentElement);
const KIND_COLOR = {
root: computedStyle.getPropertyValue('--hcg-color-root').trim() || '#2563eb',
single: computedStyle.getPropertyValue('--hcg-color-single').trim() || '#7c3aed',
multiple: computedStyle.getPropertyValue('--hcg-color-multiple').trim() || '#047857',
unique: computedStyle.getPropertyValue('--hcg-color-unique').trim() || '#b45309',
};
// UI colors from CSS variables
const UI_COLORS = {
dotGrid: computedStyle.getPropertyValue('--hcg-dot-grid').trim() || 'rgba(125,133,144,0.12)',
edge: computedStyle.getPropertyValue('--hcg-edge').trim() || 'rgba(48,54,61,0.9)',
edgeDimmed: computedStyle.getPropertyValue('--hcg-edge-dimmed').trim() || 'rgba(48,54,61,0.25)',
nodeBg: computedStyle.getPropertyValue('--hcg-node-bg').trim() || '#1c2128',
nodeBgSelected: computedStyle.getPropertyValue('--hcg-node-bg-selected').trim() || '#2a1f0f',
nodeBorderSel: computedStyle.getPropertyValue('--hcg-node-border-selected').trim() || '#f0883e',
nodeBorderMatch: computedStyle.getPropertyValue('--hcg-node-border-match').trim() || '#e3b341',
nodeGlow: computedStyle.getPropertyValue('--hcg-node-glow').trim() || '#f0883e',
textPrimary: computedStyle.getPropertyValue('--hcg-text-primary').trim() || '#e6edf3',
textMuted: computedStyle.getPropertyValue('--hcg-text-muted').trim() || 'rgba(125,133,144,0.5)',
};
// ═══════════════════════════════════════════════════════════
@@ -133,9 +156,9 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
const collapsed = new Set(options.collapsed || []);
let selectedId = null;
let filterQuery = '';
let transform = { x: 0, y: 0, scale: 1 };
let transform = options.transform || { x: 0, y: 0, scale: 1 };
let pos = {};
let layoutMode = 'horizontal'; // 'horizontal' | 'vertical'
let layoutMode = options.layout_mode || 'horizontal'; // 'horizontal' | 'vertical'
// ═══════════════════════════════════════════════════════════
// Visibility & Layout
@@ -151,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));
@@ -187,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;
@@ -230,11 +289,21 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
// ═══════════════════════════════════════════════════════════
const canvas = document.createElement('canvas');
canvas.id = `${containerId}_canvas`;
canvas.style.cssText = 'display:block; width:100%; height:100%; cursor:grab;';
canvas.style.cssText = 'display:block; width:100%; height:100%; cursor:grab; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; font-smooth: always;';
container.appendChild(canvas);
const ctx = canvas.getContext('2d');
// Logical dimensions (CSS pixels) - used for drawing coordinates
let logicalWidth = 0;
let logicalHeight = 0;
// Tooltip element for showing full text when truncated
const tooltip = document.createElement('div');
tooltip.className = 'mf-tooltip-container';
tooltip.setAttribute('data-visible', 'false');
document.body.appendChild(tooltip);
// Layout toggle button overlay
const toggleBtn = document.createElement('button');
toggleBtn.className = 'mf-hcg-toggle-btn';
@@ -249,12 +318,28 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
// Recompute layout with new spacing
recomputeLayout();
fitAll();
// Save layout mode change
saveViewState();
});
container.appendChild(toggleBtn);
function resize() {
canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
const ratio = window.devicePixelRatio || 1;
// Store logical dimensions (CSS pixels) for drawing coordinates
logicalWidth = container.clientWidth;
logicalHeight = container.clientHeight;
// Set canvas internal resolution to match physical pixels (prevents blur on HiDPI screens)
canvas.width = logicalWidth * ratio;
canvas.height = logicalHeight * ratio;
// Reset transformation matrix to identity (prevents cumulative scaling)
ctx.setTransform(1, 0, 0, 1, 0, 0);
// Scale context to maintain logical coordinate system
ctx.scale(ratio, ratio);
draw();
}
@@ -264,9 +349,9 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
function drawDotGrid() {
const ox = ((transform.x % DOT_GRID_SPACING) + DOT_GRID_SPACING) % DOT_GRID_SPACING;
const oy = ((transform.y % DOT_GRID_SPACING) + DOT_GRID_SPACING) % DOT_GRID_SPACING;
ctx.fillStyle = 'rgba(125,133,144,0.12)';
for (let x = ox - DOT_GRID_SPACING; x < canvas.width + DOT_GRID_SPACING; x += DOT_GRID_SPACING) {
for (let y = oy - DOT_GRID_SPACING; y < canvas.height + DOT_GRID_SPACING; y += DOT_GRID_SPACING) {
ctx.fillStyle = UI_COLORS.dotGrid;
for (let x = ox - DOT_GRID_SPACING; x < logicalWidth + DOT_GRID_SPACING; x += DOT_GRID_SPACING) {
for (let y = oy - DOT_GRID_SPACING; y < logicalHeight + DOT_GRID_SPACING; y += DOT_GRID_SPACING) {
ctx.beginPath();
ctx.arc(x, y, DOT_GRID_RADIUS, 0, Math.PI * 2);
ctx.fill();
@@ -275,15 +360,17 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.clearRect(0, 0, logicalWidth, logicalHeight);
drawDotGrid();
const q = filterQuery.trim().toLowerCase();
const matchIds = q
? new Set(NODES.filter(n =>
n.label.toLowerCase().includes(q) || n.kind.toLowerCase().includes(q)
).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();
@@ -296,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);
@@ -303,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);
@@ -319,8 +413,16 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
ctx.bezierCurveTo(cx, y1, cx, y2, x2, y2);
}
ctx.strokeStyle = dimmed ? 'rgba(48,54,61,0.25)' : 'rgba(48,54,61,0.9)';
ctx.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();
}
@@ -330,49 +432,52 @@ 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);
drawNode(node, tp.x, tp.y, isSel, isDesc, isMatch, isDim, transform.scale);
}
ctx.restore();
}
function drawNode(node, cx, cy, isSel, isMatch, isDim) {
// Nodes always keep same dimensions and horizontal text
const hw = NODE_W / 2, hh = NODE_H / 2, r = 6;
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 = TYPE_COLOR[node.type] || '#334155';
const color = KIND_COLOR[node.kind] || '#334155';
ctx.globalAlpha = isDim ? 0.15 : 1;
// Glow for selected
if (isSel) { ctx.shadowColor = '#f0883e'; ctx.shadowBlur = 16; }
if (isSel) { ctx.shadowColor = UI_COLORS.nodeGlow; ctx.shadowBlur = 16; }
// Background
ctx.beginPath();
ctx.roundRect(x, y, NODE_W, NODE_H, r);
ctx.fillStyle = isSel ? '#2a1f0f' : '#1c2128';
ctx.roundRect(x, y, NODE_W, nodeH, r);
ctx.fillStyle = isSel ? UI_COLORS.nodeBgSelected : UI_COLORS.nodeBg;
ctx.fill();
ctx.shadowBlur = 0;
// Left color strip (always on left, regardless of mode)
ctx.save();
ctx.beginPath();
ctx.roundRect(x, y, NODE_W, NODE_H, r);
ctx.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.strokeStyle = '#f0883e';
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) {
ctx.strokeStyle = '#e3b341';
ctx.strokeStyle = UI_COLORS.nodeBorderMatch;
ctx.lineWidth = 1.5;
} else {
ctx.strokeStyle = `${color}44`;
@@ -380,37 +485,57 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
}
ctx.stroke();
// Kind badge
const kindText = node.kind;
ctx.font = '9px system-ui';
// Type badge (class name) - with dynamic font size for sharp rendering at all zoom levels
const kindText = node.type;
const badgeFontSize = 9 * zoomLevel;
ctx.save();
ctx.scale(1 / zoomLevel, 1 / zoomLevel);
ctx.font = `${badgeFontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", "Inter", "Roboto", sans-serif`;
const rawW = ctx.measureText(kindText).width;
const badgeW = Math.min(rawW + 8, 66);
const badgeW = Math.min(rawW + 8, 66 * zoomLevel);
const chevSpace = hasChildren(node.id) ? CHEV_ZONE : 8;
const badgeX = x + NODE_W - chevSpace - badgeW - 2;
const badgeY = y + (NODE_H - 14) / 2;
const badgeX = (x + NODE_W - chevSpace - badgeW / zoomLevel - 2) * zoomLevel;
const badgeY = (y + (nodeH - 14) / 2) * zoomLevel;
ctx.beginPath();
ctx.roundRect(badgeX, badgeY, badgeW, 14, 3);
ctx.roundRect(badgeX, badgeY, badgeW, 14 * zoomLevel, 3 * zoomLevel);
ctx.fillStyle = `${color}22`;
ctx.fill();
ctx.fillStyle = color;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
let kLabel = kindText;
while (kLabel.length > 3 && ctx.measureText(kLabel).width > badgeW - 6) kLabel = kLabel.slice(0, -1);
while (kLabel.length > 3 && ctx.measureText(kLabel).width > badgeW - 6 * zoomLevel) kLabel = kLabel.slice(0, -1);
if (kLabel !== kindText) kLabel += '…';
ctx.fillText(kLabel, badgeX + badgeW / 2, badgeY + 7);
ctx.fillText(kLabel, Math.round(badgeX + badgeW / 2), Math.round(badgeY + 7 * zoomLevel));
ctx.restore();
// Label (always horizontal)
ctx.font = `${isSel ? 500 : 400} 12px monospace`;
ctx.fillStyle = isDim ? 'rgba(125,133,144,0.5)' : '#e6edf3';
// Label (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);
ctx.font = `${isSel ? 500 : 400} ${labelFontSize}px "SF Mono", "Cascadia Code", "Consolas", "Menlo", "Monaco", monospace`;
ctx.fillStyle = isDim ? UI_COLORS.textMuted : UI_COLORS.textPrimary;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
const labelX = x + 12;
const labelMaxW = badgeX - labelX - 6;
const labelX = (x + 12) * zoomLevel;
const labelMaxW = (badgeX / zoomLevel - (x + 12) - 6) * zoomLevel;
let label = node.label;
while (label.length > 3 && ctx.measureText(label).width > labelMaxW) label = label.slice(0, -1);
if (label !== node.label) label += '…';
ctx.fillText(label, labelX, cy);
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)
if (hasChildren(node.id)) {
@@ -449,23 +574,47 @@ 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);
}
const xs = tps.map(p => p.x);
const ys = tps.map(p => p.y);
const minX = Math.min(...xs) - NODE_W / 2 - FIT_PADDING;
const maxX = Math.max(...xs) + NODE_W / 2 + FIT_PADDING;
const minY = Math.min(...ys) - NODE_H / 2 - FIT_PADDING;
const maxY = Math.max(...ys) + NODE_H / 2 + FIT_PADDING;
const scale = Math.min(canvas.width / (maxX - minX), canvas.height / (maxY - minY), FIT_MAX_SCALE);
if (!isFinite(minX)) return;
minX -= FIT_PADDING;
maxX += FIT_PADDING;
minY -= FIT_PADDING;
maxY += FIT_PADDING;
const scale = Math.min(logicalWidth / (maxX - minX), logicalHeight / (maxY - minY), FIT_MAX_SCALE);
transform.scale = scale;
transform.x = (canvas.width - (minX + maxX) * scale) / 2;
transform.y = (canvas.height - (minY + maxY) * scale) / 2;
transform.x = (logicalWidth - (minX + maxX) * scale) / 2;
transform.y = (logicalHeight - (minY + maxY) * scale) / 2;
draw();
}
// ═══════════════════════════════════════════════════════════
// Tooltip helpers
// ═══════════════════════════════════════════════════════════
function showTooltip(text, clientX, clientY) {
tooltip.textContent = text;
tooltip.style.left = `${clientX + 10}px`;
tooltip.style.top = `${clientY + 10}px`;
tooltip.setAttribute('data-visible', 'true');
}
function hideTooltip() {
tooltip.setAttribute('data-visible', 'false');
}
// ═══════════════════════════════════════════════════════════
// Hit testing
// ═══════════════════════════════════════════════════════════
@@ -479,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;
@@ -499,14 +666,20 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
console.warn(`HierarchicalCanvasGraph: No handler for event "${eventName}"`);
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'
});
}
function saveViewState() {
postEvent('_internal_update_state', {
transform: transform,
layout_mode: layoutMode
});
}
// ═══════════════════════════════════════════════════════════
// Interaction
// ═══════════════════════════════════════════════════════════
@@ -520,16 +693,47 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
panOrigin = { x: e.clientX, y: e.clientY };
tfAtStart = { ...transform };
canvas.style.cursor = 'grabbing';
hideTooltip();
});
window.addEventListener('mousemove', e => {
if (!isPanning) return;
const dx = e.clientX - panOrigin.x;
const dy = e.clientY - panOrigin.y;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) didMove = true;
transform.x = tfAtStart.x + dx;
transform.y = tfAtStart.y + dy;
draw();
if (isPanning) {
const dx = e.clientX - panOrigin.x;
const dy = e.clientY - panOrigin.y;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) didMove = true;
transform.x = tfAtStart.x + dx;
transform.y = tfAtStart.y + dy;
draw();
hideTooltip();
return;
}
// Show tooltip if hovering over a node with truncated text
const rect = canvas.getBoundingClientRect();
const canvasX = e.clientX - rect.left;
const canvasY = e.clientY - rect.top;
// Check if mouse is over canvas
if (canvasX >= 0 && canvasX <= rect.width && canvasY >= 0 && canvasY <= rect.height) {
const hit = hitTest(canvasX, canvasY);
if (hit && !hit.isToggle) {
const node = hit.node;
// Check if label or type is truncated (contains ellipsis)
const labelTruncated = node.label.length > 15; // Approximate truncation threshold
const typeTruncated = node.type.length > 8; // Approximate truncation threshold
if (labelTruncated || typeTruncated) {
const tooltipText = `${node.label}${node.type !== node.label ? ` (${node.type})` : ''}`;
showTooltip(tooltipText, e.clientX, e.clientY);
} else {
hideTooltip();
}
} else {
hideTooltip();
}
} else {
hideTooltip();
}
});
window.addEventListener('mouseup', e => {
@@ -542,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,
@@ -557,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;
@@ -564,19 +788,24 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
postEvent('select_node', {
node_id: hit.node.id,
label: hit.node.label,
type: hit.node.type,
kind: hit.node.kind
kind: hit.node.kind,
type: hit.node.type
});
}
} else {
selectedId = null;
}
draw();
} else {
// Panning occurred - save view state
saveViewState();
}
});
let zoomTimeout = null;
canvas.addEventListener('wheel', e => {
e.preventDefault();
hideTooltip();
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
@@ -586,8 +815,16 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
transform.y = my - (my - transform.y) * (ns / transform.scale);
transform.scale = ns;
draw();
// Debounce save to avoid too many requests during continuous zoom
clearTimeout(zoomTimeout);
zoomTimeout = setTimeout(saveViewState, 500);
}, { passive: false });
canvas.addEventListener('mouseleave', () => {
hideTooltip();
});
// ═══════════════════════════════════════════════════════════
// Resize observer (stable zoom on resize)
// ═══════════════════════════════════════════════════════════
@@ -613,5 +850,12 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
// ═══════════════════════════════════════════════════════════
recomputeLayout();
resize();
setTimeout(fitAll, 30);
// Only fit all if no stored transform (first time or reset)
const hasStoredTransform = options.transform &&
(options.transform.x !== 0 || options.transform.y !== 0 || options.transform.scale !== 1);
if (!hasStoredTransform) {
setTimeout(fitAll, 30);
}
}

View File

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

View File

@@ -6,6 +6,9 @@ from typing import Optional
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
@@ -30,7 +33,7 @@ class HierarchicalCanvasGraphConf:
class HierarchicalCanvasGraphState(DbObject):
"""Persistent state for HierarchicalCanvasGraph.
Only the collapsed state is persisted. Zoom, pan, and selection are ephemeral.
Persists collapsed nodes, view transform (zoom/pan), and layout orientation.
"""
def __init__(self, owner, save_state=True):
@@ -39,11 +42,48 @@ class HierarchicalCanvasGraphState(DbObject):
# Persisted: set of collapsed node IDs (stored as list for JSON serialization)
self.collapsed: list = []
# Persisted: zoom/pan transform
self.transform: dict = {"x": 0, "y": 0, "scale": 1}
# Persisted: layout orientation ('horizontal' or 'vertical')
self.layout_mode: str = 'horizontal'
# 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
# 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):
@@ -77,6 +117,12 @@ class HierarchicalCanvasGraph(MultipleInstance):
super().__init__(parent, _id=_id)
self.conf = conf
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)}")
@@ -126,6 +172,102 @@ class HierarchicalCanvasGraph(MultipleInstance):
self._state.collapsed = list(collapsed_set)
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:
"""Prepare JavaScript options object.
@@ -134,15 +276,30 @@ class HierarchicalCanvasGraph(MultipleInstance):
"""
# Convert event handlers to HTMX options
events = {}
# Add internal handler for view state persistence
events['_internal_update_state'] = self.commands.update_view_state().ajax_htmx_options()
# Add 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,
"events": events
"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
}
def render(self):
@@ -155,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",

View File

@@ -1,3 +1,5 @@
from dataclasses import dataclass
from myfasthtml.controls.HierarchicalCanvasGraph import HierarchicalCanvasGraph, HierarchicalCanvasGraphConf
from myfasthtml.controls.Panel import Panel
from myfasthtml.controls.Properties import Properties
@@ -5,14 +7,27 @@ from myfasthtml.core.commands import Command
from myfasthtml.core.instances import SingleInstance, UniqueInstance, MultipleInstance, InstancesManager
@dataclass
class InstancesDebuggerConf:
"""Configuration for InstancesDebugger control.
Attributes:
group_siblings_by_type: If True, sibling nodes (same parent) are grouped
by their type for easier visual identification.
Useful for detecting memory leaks. Default: True.
"""
group_siblings_by_type: bool = True
class InstancesDebugger(SingleInstance):
def __init__(self, parent, _id=None):
def __init__(self, parent, conf: InstancesDebuggerConf = None, _id=None):
super().__init__(parent, _id=_id)
self.conf = conf if conf is not None else InstancesDebuggerConf()
self._panel = Panel(self, _id="-panel")
self._select_command = Command("ShowInstance",
"Display selected Instance",
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()
@@ -20,7 +35,7 @@ class InstancesDebugger(SingleInstance):
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")
@@ -30,7 +45,7 @@ class InstancesDebugger(SingleInstance):
"""Handle node selection event from canvas graph.
Args:
event_data: dict with keys: node_id, label, type, kind
event_data: dict with keys: node_id, label, kind, type
"""
node_id = event_data.get("node_id")
if not node_id:
@@ -42,9 +57,9 @@ class InstancesDebugger(SingleInstance):
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,
@@ -52,8 +67,8 @@ class InstancesDebugger(SingleInstance):
properties_def,
_id="-properties"))
def _get_instance_type(self, instance) -> str:
"""Determine the instance type for visualization.
def _get_instance_kind(self, instance) -> str:
"""Determine the instance kind for visualization.
Args:
instance: The instance object
@@ -77,7 +92,7 @@ class InstancesDebugger(SingleInstance):
"""Build nodes and edges from current instances.
Returns:
tuple: (nodes, edges) where nodes include id, label, type, kind
tuple: (nodes, edges) where nodes include id, label, kind, type
"""
instances = self._get_instances()
@@ -85,16 +100,17 @@ class InstancesDebugger(SingleInstance):
edges = []
existing_ids = set()
# Create nodes with type and kind information
# Create nodes with kind (instance kind) and type (class name)
for instance in instances:
node_id = instance.get_full_id()
existing_ids.add(node_id)
nodes.append({
"id": node_id,
"label": instance.get_id(),
"type": self._get_instance_type(instance),
"kind": 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
@@ -111,22 +127,57 @@ class InstancesDebugger(SingleInstance):
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}",
"type": "multiple", # Default type for ghost nodes
"kind": "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.
Args:
nodes: List of node dictionaries
edges: List of edge dictionaries
Returns:
list: Sorted edges with siblings grouped by type
"""
from collections import defaultdict
# Create mapping node_id -> type for quick lookup
node_types = {node["id"]: node["type"] for node in nodes}
# Group edges by parent
edges_by_parent = defaultdict(list)
for edge in edges:
edges_by_parent[edge["from"]].append(edge)
# Sort each parent's children by type and rebuild edges list
sorted_edges = []
for parent_id in edges_by_parent:
parent_edges = sorted(
edges_by_parent[parent_id],
key=lambda e: node_types.get(e["to"], "")
)
sorted_edges.extend(parent_edges)
return sorted_edges
def _get_instances(self):
return list(InstancesManager.instances.values())

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
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.")
@@ -170,7 +171,10 @@ class Command:
# 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):

View File

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