New hierarchical component, used for InstancesDebugger.py

This commit is contained in:
2026-02-21 23:53:05 +01:00
parent 9a25591edf
commit 44691be30f
9 changed files with 1789 additions and 32 deletions

View File

@@ -0,0 +1,779 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>InstancesDebugger — Canvas Prototype v2</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d1117;
--surface: #161b22;
--surface2: #1c2128;
--border: #30363d;
--text: #e6edf3;
--muted: #7d8590;
--accent: #388bfd;
--selected: #f0883e;
--match: #e3b341;
}
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, 'Segoe UI', system-ui, sans-serif;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Toolbar ─────────────────────────────────────── */
#toolbar {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0 14px;
height: 44px;
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
user-select: none;
}
.app-icon { font-size: 16px; color: var(--accent); margin-right: 2px; }
#title {
font-size: 13px;
font-weight: 600;
color: var(--text);
margin-right: 2px;
flex-shrink: 0;
}
.tb-sep {
width: 1px;
height: 20px;
background: var(--border);
margin: 0 4px;
flex-shrink: 0;
}
.tb-btn {
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
color: var(--muted);
padding: 4px 8px;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
transition: color .12s, background .12s, border-color .12s;
white-space: nowrap;
}
.tb-btn:hover { color: var(--text); background: var(--surface2); border-color: var(--border); }
.tb-btn svg { flex-shrink: 0; }
#search-wrapper {
position: relative;
flex: 1;
max-width: 240px;
}
.search-icon {
position: absolute;
left: 9px; top: 50%;
transform: translateY(-50%);
color: var(--muted);
pointer-events: none;
}
#search {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 5px 10px 5px 30px;
color: var(--text);
font-size: 12px;
outline: none;
transition: border-color .15s;
}
#search:focus { border-color: var(--accent); }
#search::placeholder { color: var(--muted); }
#match-count {
position: absolute;
right: 9px; top: 50%;
transform: translateY(-50%);
font-size: 10px;
color: var(--muted);
}
#zoom-display {
font-size: 11px;
color: var(--muted);
font-variant-numeric: tabular-nums;
min-width: 36px;
text-align: right;
}
#hint {
font-size: 11px;
color: var(--muted);
margin-left: auto;
}
/* ── Main ────────────────────────────────────────── */
#main { display: flex; flex: 1; overflow: hidden; }
#canvas-container {
flex: 1;
position: relative;
overflow: hidden;
cursor: grab;
}
#canvas-container.panning { cursor: grabbing; }
#canvas-container.hovering { cursor: pointer; }
#graph-canvas { position: absolute; top: 0; left: 0; display: block; }
/* ── Legend ──────────────────────────────────────── */
#legend {
position: absolute;
bottom: 14px; left: 14px;
background: rgba(22, 27, 34, 0.92);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 13px;
font-size: 11px;
color: var(--muted);
pointer-events: none;
backdrop-filter: blur(4px);
}
.leg { display: flex; align-items: center; gap: 7px; margin-bottom: 5px; }
.leg:last-child { margin-bottom: 0; }
.leg-dot { width: 9px; height: 9px; border-radius: 2px; flex-shrink: 0; }
/* ── Properties panel ────────────────────────────── */
#props-panel {
width: 270px;
background: var(--surface);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
flex-shrink: 0;
transition: width .2s;
}
#props-panel.empty { align-items: center; justify-content: center; }
#props-empty { color: var(--muted); font-size: 13px; }
#props-scroll { overflow-y: auto; flex: 1; }
.ph {
padding: 11px 14px 8px;
background: var(--bg);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.ph-label { font-size: 13px; font-weight: 600; font-family: monospace; }
.ph-kind {
display: inline-block;
margin-top: 5px;
font-size: 10px; font-weight: 500;
padding: 2px 8px;
border-radius: 10px;
color: #fff;
}
.ps { /* props section */
padding: 4px 14px;
font-size: 10px; font-weight: 700;
text-transform: uppercase; letter-spacing: .07em;
color: var(--accent);
background: rgba(56, 139, 253, 0.07);
border-bottom: 1px solid var(--border);
}
.pr { /* props row */
display: flex;
padding: 4px 14px;
border-bottom: 1px solid rgba(48, 54, 61, 0.6);
font-size: 12px; font-family: monospace;
}
.pk { color: var(--muted); flex: 0 0 88px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding-right: 6px; }
.pv { color: var(--text); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
</style>
</head>
<body>
<div id="toolbar">
<span class="app-icon"></span>
<span id="title">InstancesDebugger</span>
<div class="tb-sep"></div>
<!-- Expand all -->
<button class="tb-btn" id="expand-all-btn" title="Expand all nodes">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M2 4l5 5 5-5"/>
<path d="M2 8l5 5 5-5"/>
</svg>
Expand
</button>
<!-- Collapse all -->
<button class="tb-btn" id="collapse-all-btn" title="Collapse all nodes">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M2 6l5-5 5 5"/>
<path d="M2 10l5-5 5 5"/>
</svg>
Collapse
</button>
<div class="tb-sep"></div>
<div id="search-wrapper">
<svg class="search-icon" width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="7" cy="7" r="5"/><path d="m11 11 3 3"/>
</svg>
<input id="search" type="text" placeholder="Filter instances…" autocomplete="off" spellcheck="false">
<span id="match-count"></span>
</div>
<div class="tb-sep"></div>
<button class="tb-btn" id="fit-btn" title="Fit all nodes">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M1 5V1h4M15 5V1h-4M1 11v4h4M15 11v4h-4"/>
<rect x="4" y="4" width="8" height="8" rx="1"/>
</svg>
Fit
</button>
<span id="zoom-display">100%</span>
<span id="hint">Wheel · Drag · Click</span>
</div>
<div id="main">
<div id="canvas-container">
<canvas id="graph-canvas"></canvas>
<div id="legend">
<div class="leg"><div class="leg-dot" style="background:#2563eb"></div>RootInstance</div>
<div class="leg"><div class="leg-dot" style="background:#7c3aed"></div>SingleInstance</div>
<div class="leg"><div class="leg-dot" style="background:#047857"></div>MultipleInstance</div>
<div class="leg"><div class="leg-dot" style="background:#b45309"></div>UniqueInstance</div>
</div>
</div>
<div id="props-panel" class="empty">
<span id="props-empty">Click a node to inspect</span>
</div>
</div>
<script>
// ═══════════════════════════════════════════════════════════
// 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' },
];
const EDGES = [
{ from: 'app', to: 'layout' },
{ from: 'app', to: 'instances_debugger' },
{ from: 'app', to: 'data_grid_manager' },
{ from: 'app', to: 'auth_proxy' },
{ from: 'layout', to: 'left_panel' },
{ from: 'layout', to: 'right_panel' },
{ from: 'instances_debugger', to: 'dbg_panel' },
{ from: 'dbg_panel', to: 'canvas_graph' },
{ from: 'data_grid_manager', to: 'my_grid' },
{ from: 'my_grid', to: 'grid_toolbar' },
{ from: 'my_grid', to: 'grid_search' },
];
const DETAILS = {
app: { Main: { Id: 'app', 'Parent Id': '—' }, State: { _name: 'AppState' } },
layout: { Main: { Id: 'layout', 'Parent Id': 'app' }, State: { _name: 'LayoutState', left_open: 'true', right_open: 'true' } },
left_panel: { Main: { Id: 'left_panel', 'Parent Id': 'layout' }, State: { _name: 'PanelState', width: '240px' } },
right_panel: { Main: { Id: 'right_panel', 'Parent Id': 'layout' }, State: { _name: 'PanelState', width: '320px' } },
instances_debugger: { Main: { Id: 'instances_debugger', 'Parent Id': 'app' }, State: { _name: 'InstancesDebuggerState' }, Commands: { ShowInstance: 'on_show_node'} },
dbg_panel: { Main: { Id: 'dbg#panel', 'Parent Id': 'instances_debugger' }, State: { _name: 'PanelState', width: '280px' } },
canvas_graph: { Main: { Id: 'dbg#canvas_graph', 'Parent Id': 'dbg#panel' }, State: { _name: 'CanvasGraphState', nodes: '12', edges: '11' } },
data_grid_manager: { Main: { Id: 'data_grid_manager', 'Parent Id': 'app' }, State: { _name: 'DataGridsManagerState', grids: '1' } },
my_grid: { Main: { Id: 'my_grid', 'Parent Id': 'data_grid_manager' }, State: { _name: 'DataGridState', rows: '42', cols: '5', page: '0' }, Commands: { DeleteRow: 'on_delete', AddRow: 'on_add' } },
grid_toolbar: { Main: { Id: 'my_grid#toolbar', 'Parent Id': 'my_grid' }, State: { _name: 'ToolbarState' } },
grid_search: { Main: { Id: 'my_grid#search', 'Parent Id': 'my_grid' }, State: { _name: 'SearchState', query: '' } },
auth_proxy: { Main: { Id: 'auth_proxy', 'Parent Id': 'app' }, State: { _name: 'AuthProxyState', user: 'admin@example.com', roles: 'admin' } },
};
// ═══════════════════════════════════════════════════════════
// 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 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; }
// ═══════════════════════════════════════════════════════════
// Collapse state
// ═══════════════════════════════════════════════════════════
const collapsed = new Set();
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));
}
// ═══════════════════════════════════════════════════════════
// Layout engine (Reingold-Tilford simplified)
// ═══════════════════════════════════════════════════════════
function computeLayout(nodes, edges) {
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);
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]);
}
const pos = {};
for (const n of nodes) pos[n.id] = { x: 0, y: (depth[n.id] || 0) * LEVEL_H };
let slot = 0;
function dfs(id) {
const children = cm[id] || [];
if (children.length === 0) { pos[id].x = slot++ * (NODE_W + LEAF_GAP); return; }
for (const c of children) dfs(c);
const xs = children.map(c => pos[c].x);
pos[id].x = (Math.min(...xs) + Math.max(...xs)) / 2;
}
for (const r of roots) dfs(r);
return pos;
}
let pos = {};
function recomputeLayout() {
const vn = visNodes();
pos = computeLayout(vn, visEdges(vn));
}
recomputeLayout();
// ═══════════════════════════════════════════════════════════
// Canvas
// ═══════════════════════════════════════════════════════════
const canvas = document.getElementById('graph-canvas');
const ctx = canvas.getContext('2d');
const container = document.getElementById('canvas-container');
const zoomEl = document.getElementById('zoom-display');
let transform = { x: 0, y: 0, scale: 1 };
let selectedId = null;
let filterQuery = '';
// ── Dot grid background (Figma-style, fixed screen-space dots) ──
function drawDotGrid() {
const spacing = 24;
const ox = ((transform.x % spacing) + spacing) % spacing;
const oy = ((transform.y % spacing) + spacing) % spacing;
ctx.fillStyle = 'rgba(125,133,144,0.12)';
for (let x = ox - spacing; x < canvas.width + spacing; x += spacing)
for (let y = oy - spacing; y < canvas.height + spacing; y += spacing) {
ctx.beginPath();
ctx.arc(x, y, 0.9, 0, Math.PI * 2);
ctx.fill();
}
}
// ── Main draw ────────────────────────────────────────────
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;
// Update match count display
const matchCountEl = document.getElementById('match-count');
matchCountEl.textContent = matchIds ? `${matchIds.size}` : '';
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 x1 = p1.x, y1 = p1.y + NODE_H / 2;
const x2 = p2.x, y2 = p2.y - NODE_H / 2;
const cy = (y1 + y2) / 2;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.bezierCurveTo(x1, cy, x2, cy, 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 isSel = node.id === selectedId;
const isMatch = matchIds !== null && matchIds.has(node.id);
const isDim = matchIds !== null && !matchIds.has(node.id);
drawNode(node, p.x, p.y, isSel, isMatch, isDim);
}
ctx.restore();
// Update zoom indicator
zoomEl.textContent = `${Math.round(transform.scale * 100)}%`;
}
// ── Node renderer ────────────────────────────────────────
function drawNode(node, cx, cy, isSel, isMatch, isDim) {
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 — dark card
ctx.beginPath();
ctx.roundRect(x, y, NODE_W, NODE_H, r);
ctx.fillStyle = isSel ? '#2a1f0f' : '#1c2128';
ctx.fill();
ctx.shadowBlur = 0;
// Left color strip (clipped)
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 (top-right, small pill)
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';
// Truncate kind if needed
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
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 (if has children)
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();
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;
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;
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;
transform.y = (canvas.height - (minY + maxY) * scale) / 2;
draw();
}
// ═══════════════════════════════════════════════════════════
// Hit testing — returns { node, isToggle }
// ═══════════════════════════════════════════════════════════
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;
if (Math.abs(wx - p.x) <= NODE_W / 2 && Math.abs(wy - p.y) <= NODE_H / 2) {
const isToggle = hasChildren(n.id) && wx >= p.x + NODE_W / 2 - CHEV_ZONE;
return { node: n, isToggle };
}
}
return null;
}
// ═══════════════════════════════════════════════════════════
// Properties panel
// ═══════════════════════════════════════════════════════════
const propsPanel = document.getElementById('props-panel');
function showProperties(node) {
const details = DETAILS[node.id] || {};
const color = TYPE_COLOR[node.type] || '#334155';
let html = `
<div class="ph">
<div class="ph-label">${node.label}</div>
<span class="ph-kind" style="background:${color}">${node.kind}</span>
</div>
<div id="props-scroll">`;
for (const [section, rows] of Object.entries(details)) {
html += `<div class="ps">${section}</div>`;
for (const [k, v] of Object.entries(rows))
html += `<div class="pr"><span class="pk" title="${k}">${k}</span><span class="pv" title="${v}">${v}</span></div>`;
}
html += '</div>';
propsPanel.innerHTML = html;
propsPanel.classList.remove('empty');
}
function clearProperties() {
propsPanel.innerHTML = '<span id="props-empty">Click a node to inspect</span>';
propsPanel.classList.add('empty');
}
// ═══════════════════════════════════════════════════════════
// 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 };
container.classList.add('panning');
container.classList.remove('hovering');
});
window.addEventListener('mousemove', e => {
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();
return;
}
// Hover cursor
const rect = canvas.getBoundingClientRect();
const hit = hitTest(e.clientX - rect.left, e.clientY - rect.top);
container.classList.toggle('hovering', !!hit);
});
window.addEventListener('mouseup', e => {
if (!isPanning) return;
isPanning = false;
container.classList.remove('panning');
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();
if (selectedId && !visNodes().find(n => n.id === selectedId)) {
selectedId = null; clearProperties();
}
} else {
selectedId = hit.node.id;
showProperties(hit.node);
}
} else {
selectedId = null;
clearProperties();
}
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 ? 1.12 : 1 / 1.12;
const ns = Math.max(0.12, Math.min(3.5, 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 });
// ─── Search ──────────────────────────────────────────────
document.getElementById('search').addEventListener('input', e => {
filterQuery = e.target.value; draw();
});
// ─── Fit ─────────────────────────────────────────────────
document.getElementById('fit-btn').addEventListener('click', fitAll);
// ─── Expand / Collapse all ───────────────────────────────
document.getElementById('expand-all-btn').addEventListener('click', () => {
collapsed.clear(); recomputeLayout(); draw();
});
document.getElementById('collapse-all-btn').addEventListener('click', () => {
for (const n of NODES) if (hasChildren(n.id)) collapsed.add(n.id);
if (selectedId && !visNodes().find(n => n.id === selectedId)) {
selectedId = null; clearProperties();
}
recomputeLayout(); draw();
});
// ─── Resize (zoom stable) ───────────────────────────────
new ResizeObserver(() => {
canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
draw();
}).observe(container);
// ─── Init ────────────────────────────────────────────────
canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
setTimeout(fitAll, 30);
</script>
</body>
</html>

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

View 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);
}

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

View File

@@ -1,52 +1,129 @@
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",
self._select_command = Command("ShowInstance",
"Display selected Instance",
self,
self.on_network_event).htmx(target=f"#{self._panel.get_ids().right}")
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)
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_network_event(self, event_data: dict):
parts = event_data["nodes"][0].split("#")
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"},
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_nodes_and_edges(self):
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"}}
def _get_instance_type(self, instance) -> str:
"""Determine the instance type for visualization.
for node in nodes:
node["shape"] = "box"
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 = []
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