updated css. Added orientation. Saving orientation, position and scale in state

This commit is contained in:
2026-02-22 10:28:13 +01:00
parent 44691be30f
commit 8b8172231a
4 changed files with 345 additions and 97 deletions

View File

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

View File

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

View File

@@ -6,6 +6,8 @@ from typing import Optional
from fasthtml.components import Div
from fasthtml.xtend import Script
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.core.commands import Command
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance
@@ -30,20 +32,39 @@ class HierarchicalCanvasGraphConf:
class HierarchicalCanvasGraphState(DbObject):
"""Persistent state for HierarchicalCanvasGraph.
Only the collapsed state is persisted. Zoom, pan, and selection are ephemeral.
Persists collapsed nodes, view transform (zoom/pan), and layout orientation.
"""
def __init__(self, owner, save_state=True):
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 = []
# Persisted: zoom/pan transform
self.transform: dict = {"x": 0, "y": 0, "scale": 1}
# Persisted: layout orientation ('horizontal' or 'vertical')
self.layout_mode: str = 'horizontal'
# Not persisted: current selection (ephemeral)
self.ns_selected_id: Optional[str] = None
# Not persisted: zoom/pan transform (ephemeral)
self.ns_transform: dict = {"x": 0, "y": 0, "scale": 1}
class Commands(BaseCommands):
"""Commands for HierarchicalCanvasGraph internal state management."""
def update_view_state(self):
"""Update view transform and layout mode.
This command is called internally by the JS to persist view state changes.
"""
return Command(
"UpdateViewState",
"Update view transform and layout mode",
self._owner,
self._owner._handle_update_view_state
).htmx(target=f"#{self._id}", swap='none')
class HierarchicalCanvasGraph(MultipleInstance):
@@ -65,7 +86,7 @@ class HierarchicalCanvasGraph(MultipleInstance):
- 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.
@@ -77,10 +98,11 @@ class HierarchicalCanvasGraph(MultipleInstance):
super().__init__(parent, _id=_id)
self.conf = conf
self._state = HierarchicalCanvasGraphState(self)
self.commands = Commands(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.
@@ -88,7 +110,7 @@ class HierarchicalCanvasGraph(MultipleInstance):
HierarchicalCanvasGraphState: The state object
"""
return self._state
def get_selected_id(self) -> Optional[str]:
"""Get the currently selected node ID.
@@ -96,7 +118,7 @@ class HierarchicalCanvasGraph(MultipleInstance):
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.
@@ -105,7 +127,7 @@ class HierarchicalCanvasGraph(MultipleInstance):
"""
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.
@@ -122,10 +144,29 @@ class HierarchicalCanvasGraph(MultipleInstance):
else:
collapsed_set.add(node_id)
logger.debug(f"toggle_node: collapsed {node_id}")
self._state.collapsed = list(collapsed_set)
return self
def _handle_update_view_state(self, event_data: dict):
"""Internal handler to update view state from client.
Args:
event_data: Dictionary with 'transform' and/or 'layout_mode' keys
Returns:
str: Empty string (no UI update needed)
"""
if 'transform' in event_data:
self._state.transform = event_data['transform']
logger.debug(f"Transform updated: {self._state.transform}")
if 'layout_mode' in event_data:
self._state.layout_mode = event_data['layout_mode']
logger.debug(f"Layout mode updated: {self._state.layout_mode}")
return ""
def _prepare_options(self) -> dict:
"""Prepare JavaScript options object.
@@ -134,17 +175,24 @@ class HierarchicalCanvasGraph(MultipleInstance):
"""
# Convert event handlers to HTMX options
events = {}
# Add internal handler for view state persistence
events['_internal_update_state'] = self.commands.update_view_state().ajax_htmx_options()
# Add user-provided event handlers
if self.conf.events_handlers:
for event_name, command in self.conf.events_handlers.items():
events[event_name] = command.ajax_htmx_options()
return {
"nodes": self.conf.nodes,
"edges": self.conf.edges,
"collapsed": self._state.collapsed,
"events": events
"nodes": self.conf.nodes,
"edges": self.conf.edges,
"collapsed": self._state.collapsed,
"transform": self._state.transform,
"layout_mode": self._state.layout_mode,
"events": events
}
def render(self):
"""Render the HierarchicalCanvasGraph control.
@@ -153,14 +201,14 @@ class HierarchicalCanvasGraph(MultipleInstance):
"""
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() {{
@@ -171,11 +219,11 @@ class HierarchicalCanvasGraph(MultipleInstance):
}}
}})();
"""),
id=self._id,
cls="mf-hierarchical-canvas-graph"
)
def __ft__(self):
"""FastHTML magic method for rendering.

View File

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