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.Panel import Panel
from myfasthtml.controls.Properties import Properties from myfasthtml.controls.Properties import Properties
from myfasthtml.controls.VisNetwork import VisNetwork
from myfasthtml.core.commands import Command from myfasthtml.core.commands import Command
from myfasthtml.core.instances import SingleInstance, InstancesManager from myfasthtml.core.instances import SingleInstance, UniqueInstance, MultipleInstance, InstancesManager
from myfasthtml.core.vis_network_utils import from_parent_child_list
class InstancesDebugger(SingleInstance): class InstancesDebugger(SingleInstance):
def __init__(self, parent, _id=None): def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id) super().__init__(parent, _id=_id)
self._panel = Panel(self, _id="-panel") self._panel = Panel(self, _id="-panel")
self._command = Command("ShowInstance", self._select_command = Command("ShowInstance",
"Display selected Instance", "Display selected Instance",
self, 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): def render(self):
nodes, edges = self._get_nodes_and_edges() nodes, edges = self._get_nodes_and_edges()
vis_network = VisNetwork(self, nodes=nodes, edges=edges, _id="-vis", events_handlers={"select_node": self._command}) graph_conf = HierarchicalCanvasGraphConf(
return self._panel.set_main(vis_network) 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): def on_select_node(self, event_data: dict):
parts = event_data["nodes"][0].split("#") """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] session = parts[0]
instance_id = "#".join(parts[1:]) 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"}, "State": {"_name": "_state._name", "*": "_state"},
"Commands": {"*": "commands"}, "Commands": {"*": "commands"},
} }
return self._panel.set_right(Properties(self, return self._panel.set_right(Properties(self,
InstancesManager.get(session, instance_id), InstancesManager.get(session, instance_id),
properties_def, properties_def,
_id="-properties")) _id="-properties"))
def _get_nodes_and_edges(self): def _get_instance_type(self, instance) -> str:
instances = self._get_instances() """Determine the instance type for visualization.
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: Args:
node["shape"] = "box" 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 return nodes, edges