New hierarchical component, used for InstancesDebugger.py
This commit is contained in:
99
src/myfasthtml/assets/core/hierarchical_canvas_graph.css
Normal file
99
src/myfasthtml/assets/core/hierarchical_canvas_graph.css
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Hierarchical Canvas Graph Styles
|
||||
*
|
||||
* Styles for the canvas-based hierarchical graph visualization control.
|
||||
*/
|
||||
|
||||
/* Main control wrapper */
|
||||
.mf-hierarchical-canvas-graph {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Container that holds the canvas */
|
||||
.mf-hcg-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #0d1117;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Toggle button positioned absolutely within container */
|
||||
.mf-hcg-container button {
|
||||
font-family: inherit;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Canvas element (sized by JavaScript) */
|
||||
.mf-hcg-container canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.mf-hcg-container canvas:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Optional: toolbar/controls overlay (if needed in future) */
|
||||
.mf-hcg-toolbar {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
background: rgba(22, 27, 34, 0.92);
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Layout toggle button */
|
||||
.mf-hcg-toggle-btn {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: rgba(22, 27, 34, 0.92);
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #7d8590;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(4px);
|
||||
transition: color 0.15s, background 0.15s;
|
||||
z-index: 10;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mf-hcg-toggle-btn:hover {
|
||||
color: #e6edf3;
|
||||
background: #1c2128;
|
||||
}
|
||||
|
||||
/* Optional: loading state */
|
||||
.mf-hcg-container.loading::after {
|
||||
content: 'Loading...';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #7d8590;
|
||||
font-size: 14px;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
617
src/myfasthtml/assets/core/hierarchical_canvas_graph.js
Normal file
617
src/myfasthtml/assets/core/hierarchical_canvas_graph.js
Normal file
@@ -0,0 +1,617 @@
|
||||
/**
|
||||
* Hierarchical Canvas Graph
|
||||
*
|
||||
* Canvas-based visualization for hierarchical graph data with expand/collapse.
|
||||
* Features: Reingold-Tilford layout, zoom/pan, search filter, dot grid background.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Initialize hierarchical canvas graph visualization.
|
||||
*
|
||||
* Creates an interactive canvas-based hierarchical graph with the following features:
|
||||
* - Reingold-Tilford tree layout algorithm
|
||||
* - Expand/collapse nodes with children
|
||||
* - Zoom (mouse wheel) and pan (drag) controls
|
||||
* - Layout mode toggle (horizontal/vertical)
|
||||
* - Search/filter nodes by label or kind
|
||||
* - Click events for node selection and toggle
|
||||
* - Stable zoom on container resize
|
||||
* - Dot grid background (Figma-style)
|
||||
*
|
||||
* @param {string} containerId - The ID of the container div element
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {Array<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 {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
|
||||
* @param {Array<string>} [options.collapsed=[]] - Array of initially collapsed node IDs
|
||||
* @param {Object} [options.events={}] - Event handlers mapping event names to HTMX options:
|
||||
* @param {Object} [options.events.select_node] - Handler for node selection (click on node)
|
||||
* @param {Object} [options.events.toggle_node] - Handler for expand/collapse toggle
|
||||
*
|
||||
* @returns {void}
|
||||
*
|
||||
* @example
|
||||
* initHierarchicalCanvasGraph('graph-container', {
|
||||
* nodes: [
|
||||
* { id: 'root', label: 'Root', type: 'root', kind: 'RootInstance' },
|
||||
* { id: 'child', label: 'Child', type: 'single', kind: 'MyComponent' }
|
||||
* ],
|
||||
* edges: [{ from: 'root', to: 'child' }],
|
||||
* collapsed: [],
|
||||
* events: {
|
||||
* select_node: { url: '/api/select', target: '#panel', swap: 'innerHTML' }
|
||||
* }
|
||||
* });
|
||||
*/
|
||||
function initHierarchicalCanvasGraph(containerId, options = {}) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) {
|
||||
console.error(`HierarchicalCanvasGraph: Container "${containerId}" not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent double initialization
|
||||
if (container._hcgInitialized) {
|
||||
console.warn(`HierarchicalCanvasGraph: Container "${containerId}" already initialized`);
|
||||
return;
|
||||
}
|
||||
container._hcgInitialized = true;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Configuration & Constants
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
const NODES = options.nodes || [];
|
||||
const EDGES = options.edges || [];
|
||||
const EVENTS = options.events || {};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Visual Constants
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
const NODE_W = 178; // Node width in pixels
|
||||
const NODE_H = 36; // Node height in pixels
|
||||
const CHEV_ZONE = 26; // Toggle button hit area width (rightmost px of node)
|
||||
|
||||
const TOGGLE_BTN_SIZE = 32; // Toggle button dimensions
|
||||
const TOGGLE_BTN_POS = 12; // Toggle button offset from corner
|
||||
|
||||
const FIT_PADDING = 48; // Padding around graph when fitting
|
||||
const FIT_MAX_SCALE = 1.5; // Maximum zoom level when fitting
|
||||
|
||||
const DOT_GRID_SPACING = 24; // Dot grid spacing in pixels
|
||||
const DOT_GRID_RADIUS = 0.9; // Dot radius in pixels
|
||||
|
||||
const ZOOM_FACTOR = 1.12; // Zoom multiplier per wheel tick
|
||||
const ZOOM_MIN = 0.12; // Minimum zoom level
|
||||
const ZOOM_MAX = 3.5; // Maximum zoom level
|
||||
|
||||
// Spacing constants (adjusted per mode)
|
||||
const HORIZONTAL_MODE_SPACING = {
|
||||
levelGap: 84, // vertical distance between parent-child levels
|
||||
siblingGap: 22 // gap between siblings (in addition to NODE_W)
|
||||
};
|
||||
|
||||
const VERTICAL_MODE_SPACING = {
|
||||
levelGap: 220, // horizontal distance between parent-child (after swap)
|
||||
siblingGap: 14 // gap between siblings (in addition to NODE_H)
|
||||
};
|
||||
|
||||
function getSpacing() {
|
||||
return layoutMode === 'vertical' ? VERTICAL_MODE_SPACING : HORIZONTAL_MODE_SPACING;
|
||||
}
|
||||
|
||||
const TYPE_COLOR = {
|
||||
root: '#2563eb',
|
||||
single: '#7c3aed',
|
||||
multiple: '#047857',
|
||||
unique: '#b45309',
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Graph structure
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
const childMap = {};
|
||||
const hasParentSet = new Set();
|
||||
|
||||
for (const n of NODES) childMap[n.id] = [];
|
||||
for (const e of EDGES) {
|
||||
(childMap[e.from] = childMap[e.from] || []).push(e.to);
|
||||
hasParentSet.add(e.to);
|
||||
}
|
||||
|
||||
function hasChildren(id) {
|
||||
return (childMap[id] || []).length > 0;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// State
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
const collapsed = new Set(options.collapsed || []);
|
||||
let selectedId = null;
|
||||
let filterQuery = '';
|
||||
let transform = { x: 0, y: 0, scale: 1 };
|
||||
let pos = {};
|
||||
let layoutMode = 'horizontal'; // 'horizontal' | 'vertical'
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Visibility & Layout
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
function getHiddenSet() {
|
||||
const hidden = new Set();
|
||||
function addDesc(id) {
|
||||
for (const c of childMap[id] || []) {
|
||||
if (!hidden.has(c)) { hidden.add(c); addDesc(c); }
|
||||
}
|
||||
}
|
||||
for (const id of collapsed) addDesc(id);
|
||||
return hidden;
|
||||
}
|
||||
|
||||
function visNodes() {
|
||||
const h = getHiddenSet();
|
||||
return NODES.filter(n => !h.has(n.id));
|
||||
}
|
||||
|
||||
function visEdges(vn) {
|
||||
const vi = new Set(vn.map(n => n.id));
|
||||
return EDGES.filter(e => vi.has(e.from) && vi.has(e.to));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute hierarchical layout using Reingold-Tilford algorithm (simplified)
|
||||
*/
|
||||
function computeLayout(nodes, edges) {
|
||||
const spacing = getSpacing();
|
||||
const cm = {};
|
||||
const hp = new Set();
|
||||
for (const n of nodes) cm[n.id] = [];
|
||||
for (const e of edges) { (cm[e.from] = cm[e.from] || []).push(e.to); hp.add(e.to); }
|
||||
|
||||
const roots = nodes.filter(n => !hp.has(n.id)).map(n => n.id);
|
||||
|
||||
// Assign depth via BFS
|
||||
const depth = {};
|
||||
const q = roots.map(r => [r, 0]);
|
||||
while (q.length) {
|
||||
const [id, d] = q.shift();
|
||||
if (depth[id] !== undefined) continue;
|
||||
depth[id] = d;
|
||||
for (const c of cm[id] || []) q.push([c, d + 1]);
|
||||
}
|
||||
|
||||
// Assign positions
|
||||
const positions = {};
|
||||
for (const n of nodes) positions[n.id] = { x: 0, y: (depth[n.id] || 0) * spacing.levelGap };
|
||||
|
||||
// Sibling stride: in vertical mode, use NODE_H (height); in horizontal mode, use NODE_W (width)
|
||||
const siblingStride = layoutMode === 'vertical'
|
||||
? NODE_H + spacing.siblingGap // Vertical: nodes stack by height
|
||||
: NODE_W + spacing.siblingGap; // Horizontal: nodes spread by width
|
||||
|
||||
// DFS to assign x (sibling spacing)
|
||||
let slot = 0;
|
||||
function dfs(id) {
|
||||
const children = cm[id] || [];
|
||||
if (children.length === 0) { positions[id].x = slot++ * siblingStride; return; }
|
||||
for (const c of children) dfs(c);
|
||||
const xs = children.map(c => positions[c].x);
|
||||
positions[id].x = (Math.min(...xs) + Math.max(...xs)) / 2;
|
||||
}
|
||||
for (const r of roots) dfs(r);
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
function recomputeLayout() {
|
||||
const vn = visNodes();
|
||||
pos = computeLayout(vn, visEdges(vn));
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform position based on current layout mode
|
||||
* In vertical mode: swap x and y so tree grows left-to-right instead of top-to-bottom
|
||||
* @param {Object} p - Position {x, y}
|
||||
* @returns {Object} - Transformed position
|
||||
*/
|
||||
function transformPos(p) {
|
||||
if (layoutMode === 'vertical') {
|
||||
// Swap x and y: horizontal spread becomes vertical, depth becomes horizontal
|
||||
return { x: p.y, y: p.x };
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Canvas Setup
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = `${containerId}_canvas`;
|
||||
canvas.style.cssText = 'display:block; width:100%; height:100%; cursor:grab;';
|
||||
container.appendChild(canvas);
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Layout toggle button overlay
|
||||
const toggleBtn = document.createElement('button');
|
||||
toggleBtn.className = 'mf-hcg-toggle-btn';
|
||||
toggleBtn.innerHTML = '⇄';
|
||||
toggleBtn.title = 'Toggle layout orientation';
|
||||
toggleBtn.setAttribute('aria-label', 'Toggle between horizontal and vertical layout');
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
layoutMode = layoutMode === 'horizontal' ? 'vertical' : 'horizontal';
|
||||
toggleBtn.innerHTML = layoutMode === 'horizontal' ? '⇄' : '⇅';
|
||||
toggleBtn.title = `Switch to ${layoutMode === 'horizontal' ? 'vertical' : 'horizontal'} layout`;
|
||||
toggleBtn.setAttribute('aria-label', `Switch to ${layoutMode === 'horizontal' ? 'vertical' : 'horizontal'} layout`);
|
||||
// Recompute layout with new spacing
|
||||
recomputeLayout();
|
||||
fitAll();
|
||||
});
|
||||
container.appendChild(toggleBtn);
|
||||
|
||||
function resize() {
|
||||
canvas.width = container.clientWidth;
|
||||
canvas.height = container.clientHeight;
|
||||
draw();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Drawing
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
function drawDotGrid() {
|
||||
const ox = ((transform.x % DOT_GRID_SPACING) + DOT_GRID_SPACING) % DOT_GRID_SPACING;
|
||||
const oy = ((transform.y % DOT_GRID_SPACING) + DOT_GRID_SPACING) % DOT_GRID_SPACING;
|
||||
ctx.fillStyle = 'rgba(125,133,144,0.12)';
|
||||
for (let x = ox - DOT_GRID_SPACING; x < canvas.width + DOT_GRID_SPACING; x += DOT_GRID_SPACING) {
|
||||
for (let y = oy - DOT_GRID_SPACING; y < canvas.height + DOT_GRID_SPACING; y += DOT_GRID_SPACING) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, DOT_GRID_RADIUS, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function draw() {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
drawDotGrid();
|
||||
|
||||
const q = filterQuery.trim().toLowerCase();
|
||||
const matchIds = q
|
||||
? new Set(NODES.filter(n =>
|
||||
n.label.toLowerCase().includes(q) || n.kind.toLowerCase().includes(q)
|
||||
).map(n => n.id))
|
||||
: null;
|
||||
|
||||
const vn = visNodes();
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(transform.x, transform.y);
|
||||
ctx.scale(transform.scale, transform.scale);
|
||||
|
||||
// Edges
|
||||
for (const edge of EDGES) {
|
||||
const p1 = pos[edge.from], p2 = pos[edge.to];
|
||||
if (!p1 || !p2) continue;
|
||||
const dimmed = matchIds && !matchIds.has(edge.from) && !matchIds.has(edge.to);
|
||||
|
||||
const tp1 = transformPos(p1);
|
||||
const tp2 = transformPos(p2);
|
||||
|
||||
let x1, y1, x2, y2, cx, cy;
|
||||
if (layoutMode === 'horizontal') {
|
||||
// Horizontal: edges go from bottom of parent to top of child
|
||||
x1 = tp1.x; y1 = tp1.y + NODE_H / 2;
|
||||
x2 = tp2.x; y2 = tp2.y - NODE_H / 2;
|
||||
cy = (y1 + y2) / 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.bezierCurveTo(x1, cy, x2, cy, x2, y2);
|
||||
} else {
|
||||
// Vertical: edges go from right of parent to left of child
|
||||
x1 = tp1.x + NODE_W / 2; y1 = tp1.y;
|
||||
x2 = tp2.x - NODE_W / 2; y2 = tp2.y;
|
||||
cx = (x1 + x2) / 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.bezierCurveTo(cx, y1, cx, y2, x2, y2);
|
||||
}
|
||||
|
||||
ctx.strokeStyle = dimmed ? 'rgba(48,54,61,0.25)' : 'rgba(48,54,61,0.9)';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Nodes
|
||||
for (const node of vn) {
|
||||
const p = pos[node.id];
|
||||
if (!p) continue;
|
||||
const tp = transformPos(p);
|
||||
const isSel = node.id === selectedId;
|
||||
const isMatch = matchIds !== null && matchIds.has(node.id);
|
||||
const isDim = matchIds !== null && !matchIds.has(node.id);
|
||||
drawNode(node, tp.x, tp.y, isSel, isMatch, isDim);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawNode(node, cx, cy, isSel, isMatch, isDim) {
|
||||
// Nodes always keep same dimensions and horizontal text
|
||||
const hw = NODE_W / 2, hh = NODE_H / 2, r = 6;
|
||||
const x = cx - hw, y = cy - hh;
|
||||
const color = TYPE_COLOR[node.type] || '#334155';
|
||||
|
||||
ctx.globalAlpha = isDim ? 0.15 : 1;
|
||||
|
||||
// Glow for selected
|
||||
if (isSel) { ctx.shadowColor = '#f0883e'; ctx.shadowBlur = 16; }
|
||||
|
||||
// Background
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, NODE_W, NODE_H, r);
|
||||
ctx.fillStyle = isSel ? '#2a1f0f' : '#1c2128';
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Left color strip (always on left, regardless of mode)
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, NODE_W, NODE_H, r);
|
||||
ctx.clip();
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(x, y, 4, NODE_H);
|
||||
ctx.restore();
|
||||
|
||||
// Border
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, NODE_W, NODE_H, r);
|
||||
if (isSel) {
|
||||
ctx.strokeStyle = '#f0883e';
|
||||
ctx.lineWidth = 1.5;
|
||||
} else if (isMatch) {
|
||||
ctx.strokeStyle = '#e3b341';
|
||||
ctx.lineWidth = 1.5;
|
||||
} else {
|
||||
ctx.strokeStyle = `${color}44`;
|
||||
ctx.lineWidth = 1;
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Kind badge
|
||||
const kindText = node.kind;
|
||||
ctx.font = '9px system-ui';
|
||||
const rawW = ctx.measureText(kindText).width;
|
||||
const badgeW = Math.min(rawW + 8, 66);
|
||||
const chevSpace = hasChildren(node.id) ? CHEV_ZONE : 8;
|
||||
const badgeX = x + NODE_W - chevSpace - badgeW - 2;
|
||||
const badgeY = y + (NODE_H - 14) / 2;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(badgeX, badgeY, badgeW, 14, 3);
|
||||
ctx.fillStyle = `${color}22`;
|
||||
ctx.fill();
|
||||
ctx.fillStyle = color;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
let kLabel = kindText;
|
||||
while (kLabel.length > 3 && ctx.measureText(kLabel).width > badgeW - 6) kLabel = kLabel.slice(0, -1);
|
||||
if (kLabel !== kindText) kLabel += '…';
|
||||
ctx.fillText(kLabel, badgeX + badgeW / 2, badgeY + 7);
|
||||
|
||||
// Label (always horizontal)
|
||||
ctx.font = `${isSel ? 500 : 400} 12px monospace`;
|
||||
ctx.fillStyle = isDim ? 'rgba(125,133,144,0.5)' : '#e6edf3';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
const labelX = x + 12;
|
||||
const labelMaxW = badgeX - labelX - 6;
|
||||
let label = node.label;
|
||||
while (label.length > 3 && ctx.measureText(label).width > labelMaxW) label = label.slice(0, -1);
|
||||
if (label !== node.label) label += '…';
|
||||
ctx.fillText(label, labelX, cy);
|
||||
|
||||
// Chevron toggle (same position in both modes)
|
||||
if (hasChildren(node.id)) {
|
||||
const chevX = x + NODE_W - CHEV_ZONE / 2 - 1;
|
||||
const isCollapsed = collapsed.has(node.id);
|
||||
drawChevron(ctx, chevX, cy, !isCollapsed, color);
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function drawChevron(ctx, cx, cy, pointDown, color) {
|
||||
const s = 4;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.beginPath();
|
||||
// Chevron always drawn the same way (down or right)
|
||||
if (pointDown) {
|
||||
ctx.moveTo(cx - s, cy - s * 0.5);
|
||||
ctx.lineTo(cx, cy + s * 0.5);
|
||||
ctx.lineTo(cx + s, cy - s * 0.5);
|
||||
} else {
|
||||
ctx.moveTo(cx - s * 0.5, cy - s);
|
||||
ctx.lineTo(cx + s * 0.5, cy);
|
||||
ctx.lineTo(cx - s * 0.5, cy + s);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Fit all
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
function fitAll() {
|
||||
const vn = visNodes();
|
||||
if (vn.length === 0) return;
|
||||
|
||||
// Get transformed positions
|
||||
const tps = vn.map(n => pos[n.id] ? transformPos(pos[n.id]) : null).filter(p => p !== null);
|
||||
if (tps.length === 0) return;
|
||||
|
||||
const xs = tps.map(p => p.x);
|
||||
const ys = tps.map(p => p.y);
|
||||
const minX = Math.min(...xs) - NODE_W / 2 - FIT_PADDING;
|
||||
const maxX = Math.max(...xs) + NODE_W / 2 + FIT_PADDING;
|
||||
const minY = Math.min(...ys) - NODE_H / 2 - FIT_PADDING;
|
||||
const maxY = Math.max(...ys) + NODE_H / 2 + FIT_PADDING;
|
||||
const scale = Math.min(canvas.width / (maxX - minX), canvas.height / (maxY - minY), FIT_MAX_SCALE);
|
||||
transform.scale = scale;
|
||||
transform.x = (canvas.width - (minX + maxX) * scale) / 2;
|
||||
transform.y = (canvas.height - (minY + maxY) * scale) / 2;
|
||||
draw();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Hit testing
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
function hitTest(sx, sy) {
|
||||
const wx = (sx - transform.x) / transform.scale;
|
||||
const wy = (sy - transform.y) / transform.scale;
|
||||
const vn = visNodes();
|
||||
|
||||
for (let i = vn.length - 1; i >= 0; i--) {
|
||||
const n = vn[i];
|
||||
const p = pos[n.id];
|
||||
if (!p) continue;
|
||||
const tp = transformPos(p);
|
||||
|
||||
// Nodes keep same dimensions in both modes
|
||||
if (Math.abs(wx - tp.x) <= NODE_W / 2 && Math.abs(wy - tp.y) <= NODE_H / 2) {
|
||||
// Toggle zone always on the right side of node
|
||||
const isToggle = hasChildren(n.id) && wx >= tp.x + NODE_W / 2 - CHEV_ZONE;
|
||||
return { node: n, isToggle };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Event posting to server
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
function postEvent(eventName, eventData) {
|
||||
const handler = EVENTS[eventName];
|
||||
if (!handler || !handler.url) {
|
||||
console.warn(`HierarchicalCanvasGraph: No handler for event "${eventName}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
htmx.ajax('POST', handler.url, {
|
||||
values: { event_data: JSON.stringify(eventData) },
|
||||
target: handler.target || 'body',
|
||||
swap: handler.swap || 'none'
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Interaction
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
let isPanning = false;
|
||||
let panOrigin = { x: 0, y: 0 };
|
||||
let tfAtStart = null;
|
||||
let didMove = false;
|
||||
|
||||
canvas.addEventListener('mousedown', e => {
|
||||
isPanning = true; didMove = false;
|
||||
panOrigin = { x: e.clientX, y: e.clientY };
|
||||
tfAtStart = { ...transform };
|
||||
canvas.style.cursor = 'grabbing';
|
||||
});
|
||||
|
||||
window.addEventListener('mousemove', e => {
|
||||
if (!isPanning) return;
|
||||
const dx = e.clientX - panOrigin.x;
|
||||
const dy = e.clientY - panOrigin.y;
|
||||
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) didMove = true;
|
||||
transform.x = tfAtStart.x + dx;
|
||||
transform.y = tfAtStart.y + dy;
|
||||
draw();
|
||||
});
|
||||
|
||||
window.addEventListener('mouseup', e => {
|
||||
if (!isPanning) return;
|
||||
isPanning = false;
|
||||
canvas.style.cursor = 'grab';
|
||||
|
||||
if (!didMove) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const hit = hitTest(e.clientX - rect.left, e.clientY - rect.top);
|
||||
if (hit) {
|
||||
if (hit.isToggle) {
|
||||
// Toggle collapse
|
||||
if (collapsed.has(hit.node.id)) collapsed.delete(hit.node.id);
|
||||
else collapsed.add(hit.node.id);
|
||||
recomputeLayout();
|
||||
|
||||
// Post toggle_node event
|
||||
postEvent('toggle_node', {
|
||||
node_id: hit.node.id,
|
||||
collapsed: collapsed.has(hit.node.id)
|
||||
});
|
||||
|
||||
// Clear selection if node is now hidden
|
||||
if (selectedId && !visNodes().find(n => n.id === selectedId)) {
|
||||
selectedId = null;
|
||||
}
|
||||
} else {
|
||||
selectedId = hit.node.id;
|
||||
|
||||
// Post select_node event
|
||||
postEvent('select_node', {
|
||||
node_id: hit.node.id,
|
||||
label: hit.node.label,
|
||||
type: hit.node.type,
|
||||
kind: hit.node.kind
|
||||
});
|
||||
}
|
||||
} else {
|
||||
selectedId = null;
|
||||
}
|
||||
draw();
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener('wheel', e => {
|
||||
e.preventDefault();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
const f = e.deltaY < 0 ? ZOOM_FACTOR : 1 / ZOOM_FACTOR;
|
||||
const ns = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, transform.scale * f));
|
||||
transform.x = mx - (mx - transform.x) * (ns / transform.scale);
|
||||
transform.y = my - (my - transform.y) * (ns / transform.scale);
|
||||
transform.scale = ns;
|
||||
draw();
|
||||
}, { passive: false });
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Resize observer (stable zoom on resize)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
new ResizeObserver(resize).observe(container);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Public API (attached to container for potential external access)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
container._hcgAPI = {
|
||||
fitAll,
|
||||
setFilter: (query) => { filterQuery = query; draw(); },
|
||||
expandAll: () => { collapsed.clear(); recomputeLayout(); draw(); },
|
||||
collapseAll: () => {
|
||||
for (const n of NODES) if (hasChildren(n.id)) collapsed.add(n.id);
|
||||
if (selectedId && !visNodes().find(n => n.id === selectedId)) selectedId = null;
|
||||
recomputeLayout(); draw();
|
||||
},
|
||||
redraw: draw
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Init
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
recomputeLayout();
|
||||
resize();
|
||||
setTimeout(fitAll, 30);
|
||||
}
|
||||
185
src/myfasthtml/controls/HierarchicalCanvasGraph.py
Normal file
185
src/myfasthtml/controls/HierarchicalCanvasGraph.py
Normal file
@@ -0,0 +1,185 @@
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from fasthtml.components import Div
|
||||
from fasthtml.xtend import Script
|
||||
|
||||
from myfasthtml.core.dbmanager import DbObject
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
logger = logging.getLogger("HierarchicalCanvasGraph")
|
||||
|
||||
|
||||
@dataclass
|
||||
class HierarchicalCanvasGraphConf:
|
||||
"""Configuration for HierarchicalCanvasGraph control.
|
||||
|
||||
Attributes:
|
||||
nodes: List of node dictionaries with keys: id, label, type, kind
|
||||
edges: List of edge dictionaries with keys: from, to
|
||||
events_handlers: Optional dict mapping event names to Command objects
|
||||
Supported events: 'select_node', 'toggle_node'
|
||||
"""
|
||||
nodes: list[dict]
|
||||
edges: list[dict]
|
||||
events_handlers: Optional[dict] = None
|
||||
|
||||
|
||||
class HierarchicalCanvasGraphState(DbObject):
|
||||
"""Persistent state for HierarchicalCanvasGraph.
|
||||
|
||||
Only the collapsed state is persisted. Zoom, pan, and selection are ephemeral.
|
||||
"""
|
||||
|
||||
def __init__(self, owner, save_state=True):
|
||||
super().__init__(owner, save_state=save_state)
|
||||
with self.initializing():
|
||||
# Persisted: set of collapsed node IDs (stored as list for JSON serialization)
|
||||
self.collapsed: list = []
|
||||
|
||||
# Not persisted: current selection (ephemeral)
|
||||
self.ns_selected_id: Optional[str] = None
|
||||
|
||||
# Not persisted: zoom/pan transform (ephemeral)
|
||||
self.ns_transform: dict = {"x": 0, "y": 0, "scale": 1}
|
||||
|
||||
|
||||
class HierarchicalCanvasGraph(MultipleInstance):
|
||||
"""A canvas-based hierarchical graph visualization control.
|
||||
|
||||
Displays nodes and edges in a tree layout with expand/collapse functionality.
|
||||
Uses HTML5 Canvas for rendering with stable zoom/pan and search filtering.
|
||||
|
||||
Features:
|
||||
- Reingold-Tilford hierarchical layout
|
||||
- Expand/collapse nodes with children
|
||||
- Zoom and pan with mouse wheel and drag
|
||||
- Search/filter nodes by label or kind
|
||||
- Click to select nodes
|
||||
- Dot grid background
|
||||
- Stable zoom on container resize
|
||||
|
||||
Events:
|
||||
- select_node: Fired when a node is clicked (not on toggle button)
|
||||
- toggle_node: Fired when a node's expand/collapse button is clicked
|
||||
"""
|
||||
|
||||
def __init__(self, parent, conf: HierarchicalCanvasGraphConf, _id=None):
|
||||
"""Initialize the HierarchicalCanvasGraph control.
|
||||
|
||||
Args:
|
||||
parent: Parent instance
|
||||
conf: Configuration object with nodes, edges, and event handlers
|
||||
_id: Optional custom ID (auto-generated if not provided)
|
||||
"""
|
||||
super().__init__(parent, _id=_id)
|
||||
self.conf = conf
|
||||
self._state = HierarchicalCanvasGraphState(self)
|
||||
|
||||
logger.debug(f"HierarchicalCanvasGraph created with id={self._id}, "
|
||||
f"nodes={len(conf.nodes)}, edges={len(conf.edges)}")
|
||||
|
||||
def get_state(self):
|
||||
"""Get the control's persistent state.
|
||||
|
||||
Returns:
|
||||
HierarchicalCanvasGraphState: The state object
|
||||
"""
|
||||
return self._state
|
||||
|
||||
def get_selected_id(self) -> Optional[str]:
|
||||
"""Get the currently selected node ID.
|
||||
|
||||
Returns:
|
||||
str or None: The selected node ID, or None if no selection
|
||||
"""
|
||||
return self._state.ns_selected_id
|
||||
|
||||
def set_collapsed(self, node_ids: set):
|
||||
"""Set the collapsed state of nodes.
|
||||
|
||||
Args:
|
||||
node_ids: Set of node IDs to mark as collapsed
|
||||
"""
|
||||
self._state.collapsed = list(node_ids)
|
||||
logger.debug(f"set_collapsed: {len(node_ids)} nodes collapsed")
|
||||
|
||||
def toggle_node(self, node_id: str):
|
||||
"""Toggle the collapsed state of a node.
|
||||
|
||||
Args:
|
||||
node_id: The ID of the node to toggle
|
||||
|
||||
Returns:
|
||||
self: For chaining
|
||||
"""
|
||||
collapsed_set = set(self._state.collapsed)
|
||||
if node_id in collapsed_set:
|
||||
collapsed_set.remove(node_id)
|
||||
logger.debug(f"toggle_node: expanded {node_id}")
|
||||
else:
|
||||
collapsed_set.add(node_id)
|
||||
logger.debug(f"toggle_node: collapsed {node_id}")
|
||||
|
||||
self._state.collapsed = list(collapsed_set)
|
||||
return self
|
||||
|
||||
def _prepare_options(self) -> dict:
|
||||
"""Prepare JavaScript options object.
|
||||
|
||||
Returns:
|
||||
dict: Options to pass to the JS initialization function
|
||||
"""
|
||||
# Convert event handlers to HTMX options
|
||||
events = {}
|
||||
if self.conf.events_handlers:
|
||||
for event_name, command in self.conf.events_handlers.items():
|
||||
events[event_name] = command.ajax_htmx_options()
|
||||
|
||||
return {
|
||||
"nodes": self.conf.nodes,
|
||||
"edges": self.conf.edges,
|
||||
"collapsed": self._state.collapsed,
|
||||
"events": events
|
||||
}
|
||||
|
||||
def render(self):
|
||||
"""Render the HierarchicalCanvasGraph control.
|
||||
|
||||
Returns:
|
||||
Div: The rendered control with canvas and initialization script
|
||||
"""
|
||||
options = self._prepare_options()
|
||||
options_json = json.dumps(options, indent=2)
|
||||
|
||||
return Div(
|
||||
# Canvas element (sized by JS to fill container)
|
||||
Div(
|
||||
id=f"{self._id}_container",
|
||||
cls="mf-hcg-container"
|
||||
),
|
||||
|
||||
# Initialization script
|
||||
Script(f"""
|
||||
(function() {{
|
||||
if (typeof initHierarchicalCanvasGraph === 'function') {{
|
||||
initHierarchicalCanvasGraph('{self._id}_container', {options_json});
|
||||
}} else {{
|
||||
console.error('initHierarchicalCanvasGraph function not found');
|
||||
}}
|
||||
}})();
|
||||
"""),
|
||||
|
||||
id=self._id,
|
||||
cls="mf-hierarchical-canvas-graph"
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
"""FastHTML magic method for rendering.
|
||||
|
||||
Returns:
|
||||
Div: The rendered control
|
||||
"""
|
||||
return self.render()
|
||||
@@ -1,55 +1,132 @@
|
||||
from myfasthtml.controls.HierarchicalCanvasGraph import HierarchicalCanvasGraph, HierarchicalCanvasGraphConf
|
||||
from myfasthtml.controls.Panel import Panel
|
||||
from myfasthtml.controls.Properties import Properties
|
||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
||||
from myfasthtml.core.vis_network_utils import from_parent_child_list
|
||||
from myfasthtml.core.instances import SingleInstance, UniqueInstance, MultipleInstance, InstancesManager
|
||||
|
||||
|
||||
class InstancesDebugger(SingleInstance):
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self._panel = Panel(self, _id="-panel")
|
||||
self._command = Command("ShowInstance",
|
||||
"Display selected Instance",
|
||||
self,
|
||||
self.on_network_event).htmx(target=f"#{self._panel.get_ids().right}")
|
||||
|
||||
self._select_command = Command("ShowInstance",
|
||||
"Display selected Instance",
|
||||
self,
|
||||
self.on_select_node).htmx(target=f"#{self._panel.get_ids().right}")
|
||||
|
||||
def render(self):
|
||||
nodes, edges = self._get_nodes_and_edges()
|
||||
vis_network = VisNetwork(self, nodes=nodes, edges=edges, _id="-vis", events_handlers={"select_node": self._command})
|
||||
return self._panel.set_main(vis_network)
|
||||
|
||||
def on_network_event(self, event_data: dict):
|
||||
parts = event_data["nodes"][0].split("#")
|
||||
graph_conf = HierarchicalCanvasGraphConf(
|
||||
nodes=nodes,
|
||||
edges=edges,
|
||||
events_handlers={
|
||||
"select_node": self._select_command
|
||||
}
|
||||
)
|
||||
canvas_graph = HierarchicalCanvasGraph(self, conf=graph_conf, _id="-canvas-graph")
|
||||
return self._panel.set_main(canvas_graph)
|
||||
|
||||
def on_select_node(self, event_data: dict):
|
||||
"""Handle node selection event from canvas graph.
|
||||
|
||||
Args:
|
||||
event_data: dict with keys: node_id, label, type, kind
|
||||
"""
|
||||
node_id = event_data.get("node_id")
|
||||
if not node_id:
|
||||
return None
|
||||
|
||||
# Parse full ID (session#instance_id)
|
||||
parts = node_id.split("#")
|
||||
session = parts[0]
|
||||
instance_id = "#".join(parts[1:])
|
||||
properties_def = {"Main": {"Id": "_id", "Parent Id": "_parent._id"},
|
||||
"State": {"_name": "_state._name", "*": "_state"},
|
||||
"Commands": {"*": "commands"},
|
||||
}
|
||||
|
||||
properties_def = {
|
||||
"Main": {"Id": "_id", "Parent Id": "_parent._id"},
|
||||
"State": {"_name": "_state._name", "*": "_state"},
|
||||
"Commands": {"*": "commands"},
|
||||
}
|
||||
|
||||
return self._panel.set_right(Properties(self,
|
||||
InstancesManager.get(session, instance_id),
|
||||
properties_def,
|
||||
_id="-properties"))
|
||||
|
||||
|
||||
def _get_instance_type(self, instance) -> str:
|
||||
"""Determine the instance type for visualization.
|
||||
|
||||
Args:
|
||||
instance: The instance object
|
||||
|
||||
Returns:
|
||||
str: One of 'root', 'single', 'unique', 'multiple'
|
||||
"""
|
||||
# Check if it's the RootInstance (special singleton)
|
||||
if instance.get_parent() is None and instance.get_id() == "mf":
|
||||
return 'root'
|
||||
elif isinstance(instance, SingleInstance):
|
||||
return 'single'
|
||||
elif isinstance(instance, UniqueInstance):
|
||||
return 'unique'
|
||||
elif isinstance(instance, MultipleInstance):
|
||||
return 'multiple'
|
||||
else:
|
||||
return 'multiple' # Default
|
||||
|
||||
def _get_nodes_and_edges(self):
|
||||
"""Build nodes and edges from current instances.
|
||||
|
||||
Returns:
|
||||
tuple: (nodes, edges) where nodes include id, label, type, kind
|
||||
"""
|
||||
instances = self._get_instances()
|
||||
nodes, edges = from_parent_child_list(
|
||||
instances,
|
||||
id_getter=lambda x: x.get_full_id(),
|
||||
label_getter=lambda x: f"{x.get_id()}",
|
||||
parent_getter=lambda x: x.get_parent_full_id()
|
||||
)
|
||||
for edge in edges:
|
||||
edge["color"] = "green"
|
||||
edge["arrows"] = {"to": {"enabled": False, "type": "circle"}}
|
||||
|
||||
for node in nodes:
|
||||
node["shape"] = "box"
|
||||
|
||||
|
||||
nodes = []
|
||||
edges = []
|
||||
existing_ids = set()
|
||||
|
||||
# Create nodes with type and kind information
|
||||
for instance in instances:
|
||||
node_id = instance.get_full_id()
|
||||
existing_ids.add(node_id)
|
||||
|
||||
nodes.append({
|
||||
"id": node_id,
|
||||
"label": instance.get_id(),
|
||||
"type": self._get_instance_type(instance),
|
||||
"kind": instance.__class__.__name__
|
||||
})
|
||||
|
||||
# Track nodes with parents
|
||||
nodes_with_parent = set()
|
||||
|
||||
# Create edges
|
||||
for instance in instances:
|
||||
node_id = instance.get_full_id()
|
||||
parent_id = instance.get_parent_full_id()
|
||||
|
||||
if parent_id is None or parent_id == "":
|
||||
continue
|
||||
|
||||
nodes_with_parent.add(node_id)
|
||||
|
||||
edges.append({
|
||||
"from": parent_id,
|
||||
"to": node_id
|
||||
})
|
||||
|
||||
# Create ghost node if parent not in existing instances
|
||||
if parent_id not in existing_ids:
|
||||
nodes.append({
|
||||
"id": parent_id,
|
||||
"label": f"Ghost: {parent_id}",
|
||||
"type": "multiple", # Default type for ghost nodes
|
||||
"kind": "Ghost"
|
||||
})
|
||||
existing_ids.add(parent_id)
|
||||
|
||||
return nodes, edges
|
||||
|
||||
|
||||
def _get_instances(self):
|
||||
return list(InstancesManager.instances.values())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user