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