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. * 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 */ /* Main control wrapper */
.mf-hierarchical-canvas-graph { .mf-hierarchical-canvas-graph {
display: flex; display: flex;
@@ -19,7 +48,7 @@
flex: 1; flex: 1;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
background: #0d1117; background: var(--hcg-bg-main);
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
@@ -47,8 +76,8 @@
position: absolute; position: absolute;
top: 12px; top: 12px;
left: 12px; left: 12px;
background: rgba(22, 27, 34, 0.92); background: var(--hcg-bg-button);
border: 1px solid #30363d; border: 1px solid var(--hcg-border);
border-radius: 8px; border-radius: 8px;
padding: 6px; padding: 6px;
display: flex; display: flex;
@@ -65,10 +94,10 @@
right: 12px; right: 12px;
width: 32px; width: 32px;
height: 32px; height: 32px;
background: rgba(22, 27, 34, 0.92); background: var(--hcg-bg-button);
border: 1px solid #30363d; border: 1px solid var(--hcg-border);
border-radius: 6px; border-radius: 6px;
color: #7d8590; color: var(--hcg-text-muted);
font-size: 16px; font-size: 16px;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
@@ -82,8 +111,8 @@
} }
.mf-hcg-toggle-btn:hover { .mf-hcg-toggle-btn:hover {
color: #e6edf3; color: var(--hcg-text-primary);
background: #1c2128; background: color-mix(in oklab, var(--hcg-bg-main) 90%, var(--hcg-text-primary) 10%);
} }
/* Optional: loading state */ /* Optional: loading state */
@@ -93,7 +122,7 @@
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
color: #7d8590; color: var(--hcg-text-muted);
font-size: 14px; font-size: 14px;
font-family: system-ui, sans-serif; font-family: system-ui, sans-serif;
} }

View File

@@ -23,8 +23,8 @@
* @param {Array<Object>} options.nodes - Array of node objects with properties: * @param {Array<Object>} options.nodes - Array of node objects with properties:
* @param {string} options.nodes[].id - Unique node identifier * @param {string} options.nodes[].id - Unique node identifier
* @param {string} options.nodes[].label - Display label * @param {string} options.nodes[].label - Display label
* @param {string} options.nodes[].type - Node type (root|single|unique|multiple) * @param {string} options.nodes[].kind - Instance kind (root|single|unique|multiple)
* @param {string} options.nodes[].kind - Node kind/class name * @param {string} options.nodes[].type - Class type/name
* @param {Array<Object>} options.edges - Array of edge objects with properties: * @param {Array<Object>} options.edges - Array of edge objects with properties:
* @param {string} options.edges[].from - Source node ID * @param {string} options.edges[].from - Source node ID
* @param {string} options.edges[].to - Target node ID * @param {string} options.edges[].to - Target node ID
@@ -38,8 +38,8 @@
* @example * @example
* initHierarchicalCanvasGraph('graph-container', { * initHierarchicalCanvasGraph('graph-container', {
* nodes: [ * nodes: [
* { id: 'root', label: 'Root', type: 'root', kind: 'RootInstance' }, * { id: 'root', label: 'Root', kind: 'root', type: 'RootInstance' },
* { id: 'child', label: 'Child', type: 'single', kind: 'MyComponent' } * { id: 'child', label: 'Child', kind: 'single', type: 'MyComponent' }
* ], * ],
* edges: [{ from: 'root', to: 'child' }], * edges: [{ from: 'root', to: 'child' }],
* collapsed: [], * collapsed: [],
@@ -104,11 +104,27 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
return layoutMode === 'vertical' ? VERTICAL_MODE_SPACING : HORIZONTAL_MODE_SPACING; return layoutMode === 'vertical' ? VERTICAL_MODE_SPACING : HORIZONTAL_MODE_SPACING;
} }
const TYPE_COLOR = { // Color mapping based on instance kind (read from CSS variables for DaisyUI theme compatibility)
root: '#2563eb', const computedStyle = getComputedStyle(document.documentElement);
single: '#7c3aed', const KIND_COLOR = {
multiple: '#047857', root: computedStyle.getPropertyValue('--hcg-color-root').trim() || '#2563eb',
unique: '#b45309', 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 || []); const collapsed = new Set(options.collapsed || []);
let selectedId = null; let selectedId = null;
let filterQuery = ''; let filterQuery = '';
let transform = { x: 0, y: 0, scale: 1 }; let transform = options.transform || { x: 0, y: 0, scale: 1 };
let pos = {}; let pos = {};
let layoutMode = 'horizontal'; // 'horizontal' | 'vertical' let layoutMode = options.layout_mode || 'horizontal'; // 'horizontal' | 'vertical'
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// Visibility & Layout // Visibility & Layout
@@ -230,11 +246,21 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.id = `${containerId}_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); container.appendChild(canvas);
const ctx = canvas.getContext('2d'); 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 // Layout toggle button overlay
const toggleBtn = document.createElement('button'); const toggleBtn = document.createElement('button');
toggleBtn.className = 'mf-hcg-toggle-btn'; toggleBtn.className = 'mf-hcg-toggle-btn';
@@ -249,12 +275,28 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
// Recompute layout with new spacing // Recompute layout with new spacing
recomputeLayout(); recomputeLayout();
fitAll(); fitAll();
// Save layout mode change
saveViewState();
}); });
container.appendChild(toggleBtn); container.appendChild(toggleBtn);
function resize() { function resize() {
canvas.width = container.clientWidth; const ratio = window.devicePixelRatio || 1;
canvas.height = container.clientHeight;
// 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(); draw();
} }
@@ -264,9 +306,9 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
function drawDotGrid() { function drawDotGrid() {
const ox = ((transform.x % DOT_GRID_SPACING) + DOT_GRID_SPACING) % DOT_GRID_SPACING; 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; const oy = ((transform.y % DOT_GRID_SPACING) + DOT_GRID_SPACING) % DOT_GRID_SPACING;
ctx.fillStyle = 'rgba(125,133,144,0.12)'; ctx.fillStyle = UI_COLORS.dotGrid;
for (let x = ox - DOT_GRID_SPACING; x < canvas.width + DOT_GRID_SPACING; x += DOT_GRID_SPACING) { for (let x = ox - DOT_GRID_SPACING; x < logicalWidth + DOT_GRID_SPACING; x += DOT_GRID_SPACING) {
for (let y = oy - DOT_GRID_SPACING; y < canvas.height + DOT_GRID_SPACING; y += DOT_GRID_SPACING) { for (let y = oy - DOT_GRID_SPACING; y < logicalHeight + DOT_GRID_SPACING; y += DOT_GRID_SPACING) {
ctx.beginPath(); ctx.beginPath();
ctx.arc(x, y, DOT_GRID_RADIUS, 0, Math.PI * 2); ctx.arc(x, y, DOT_GRID_RADIUS, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
@@ -275,13 +317,13 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
} }
function draw() { function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, logicalWidth, logicalHeight);
drawDotGrid(); drawDotGrid();
const q = filterQuery.trim().toLowerCase(); const q = filterQuery.trim().toLowerCase();
const matchIds = q const matchIds = q
? new Set(NODES.filter(n => ? 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)) ).map(n => n.id))
: null; : null;
@@ -319,7 +361,7 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
ctx.bezierCurveTo(cx, y1, cx, y2, x2, y2); 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.lineWidth = 1.5;
ctx.stroke(); ctx.stroke();
} }
@@ -332,27 +374,27 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
const isSel = node.id === selectedId; const isSel = node.id === selectedId;
const isMatch = matchIds !== null && matchIds.has(node.id); const isMatch = matchIds !== null && matchIds.has(node.id);
const isDim = 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(); 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 // Nodes always keep same dimensions and horizontal text
const hw = NODE_W / 2, hh = NODE_H / 2, r = 6; const hw = NODE_W / 2, hh = NODE_H / 2, r = 6;
const x = cx - hw, y = cy - hh; 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; ctx.globalAlpha = isDim ? 0.15 : 1;
// Glow for selected // Glow for selected
if (isSel) { ctx.shadowColor = '#f0883e'; ctx.shadowBlur = 16; } if (isSel) { ctx.shadowColor = UI_COLORS.nodeGlow; ctx.shadowBlur = 16; }
// Background // Background
ctx.beginPath(); ctx.beginPath();
ctx.roundRect(x, y, NODE_W, NODE_H, r); 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.fill();
ctx.shadowBlur = 0; ctx.shadowBlur = 0;
@@ -369,10 +411,10 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
ctx.beginPath(); ctx.beginPath();
ctx.roundRect(x, y, NODE_W, NODE_H, r); ctx.roundRect(x, y, NODE_W, NODE_H, r);
if (isSel) { if (isSel) {
ctx.strokeStyle = '#f0883e'; ctx.strokeStyle = UI_COLORS.nodeBorderSel;
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
} else if (isMatch) { } else if (isMatch) {
ctx.strokeStyle = '#e3b341'; ctx.strokeStyle = UI_COLORS.nodeBorderMatch;
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
} else { } else {
ctx.strokeStyle = `${color}44`; ctx.strokeStyle = `${color}44`;
@@ -380,37 +422,45 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
} }
ctx.stroke(); ctx.stroke();
// Kind badge // Type badge (class name) - with dynamic font size for sharp rendering at all zoom levels
const kindText = node.kind; const kindText = node.type;
ctx.font = '9px system-ui'; 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 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 chevSpace = hasChildren(node.id) ? CHEV_ZONE : 8;
const badgeX = x + NODE_W - chevSpace - badgeW - 2; const badgeX = (x + NODE_W - chevSpace - badgeW / zoomLevel - 2) * zoomLevel;
const badgeY = y + (NODE_H - 14) / 2; const badgeY = (y + (NODE_H - 14) / 2) * zoomLevel;
ctx.beginPath(); ctx.beginPath();
ctx.roundRect(badgeX, badgeY, badgeW, 14, 3); ctx.roundRect(badgeX, badgeY, badgeW, 14 * zoomLevel, 3 * zoomLevel);
ctx.fillStyle = `${color}22`; ctx.fillStyle = `${color}22`;
ctx.fill(); ctx.fill();
ctx.fillStyle = color; ctx.fillStyle = color;
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
let kLabel = kindText; 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 += '…'; 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) // Label (always horizontal) - with dynamic font size for sharp rendering at all zoom levels
ctx.font = `${isSel ? 500 : 400} 12px monospace`; const labelFontSize = 12 * zoomLevel;
ctx.fillStyle = isDim ? 'rgba(125,133,144,0.5)' : '#e6edf3'; 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.textAlign = 'left';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
const labelX = x + 12; const labelX = (x + 12) * zoomLevel;
const labelMaxW = badgeX - labelX - 6; const labelMaxW = (badgeX / zoomLevel - (x + 12) - 6) * zoomLevel;
let label = node.label; let label = node.label;
while (label.length > 3 && ctx.measureText(label).width > labelMaxW) label = label.slice(0, -1); while (label.length > 3 && ctx.measureText(label).width > labelMaxW) label = label.slice(0, -1);
if (label !== node.label) label += '…'; 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) // Chevron toggle (same position in both modes)
if (hasChildren(node.id)) { if (hasChildren(node.id)) {
@@ -459,13 +509,27 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
const maxX = Math.max(...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 minY = Math.min(...ys) - NODE_H / 2 - FIT_PADDING;
const maxY = Math.max(...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.scale = scale;
transform.x = (canvas.width - (minX + maxX) * scale) / 2; transform.x = (logicalWidth - (minX + maxX) * scale) / 2;
transform.y = (canvas.height - (minY + maxY) * scale) / 2; transform.y = (logicalHeight - (minY + maxY) * scale) / 2;
draw(); 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 // Hit testing
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
@@ -499,7 +563,6 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
console.warn(`HierarchicalCanvasGraph: No handler for event "${eventName}"`); console.warn(`HierarchicalCanvasGraph: No handler for event "${eventName}"`);
return; return;
} }
htmx.ajax('POST', handler.url, { htmx.ajax('POST', handler.url, {
values: { event_data: JSON.stringify(eventData) }, values: { event_data: JSON.stringify(eventData) },
target: handler.target || 'body', target: handler.target || 'body',
@@ -507,6 +570,13 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
}); });
} }
function saveViewState() {
postEvent('_internal_update_state', {
transform: transform,
layout_mode: layoutMode
});
}
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// Interaction // Interaction
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
@@ -520,16 +590,47 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
panOrigin = { x: e.clientX, y: e.clientY }; panOrigin = { x: e.clientX, y: e.clientY };
tfAtStart = { ...transform }; tfAtStart = { ...transform };
canvas.style.cursor = 'grabbing'; canvas.style.cursor = 'grabbing';
hideTooltip();
}); });
window.addEventListener('mousemove', e => { window.addEventListener('mousemove', e => {
if (!isPanning) return; if (isPanning) {
const dx = e.clientX - panOrigin.x; const dx = e.clientX - panOrigin.x;
const dy = e.clientY - panOrigin.y; const dy = e.clientY - panOrigin.y;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) didMove = true; if (Math.abs(dx) > 3 || Math.abs(dy) > 3) didMove = true;
transform.x = tfAtStart.x + dx; transform.x = tfAtStart.x + dx;
transform.y = tfAtStart.y + dy; transform.y = tfAtStart.y + dy;
draw(); 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 => { window.addEventListener('mouseup', e => {
@@ -564,19 +665,24 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
postEvent('select_node', { postEvent('select_node', {
node_id: hit.node.id, node_id: hit.node.id,
label: hit.node.label, label: hit.node.label,
type: hit.node.type, kind: hit.node.kind,
kind: hit.node.kind type: hit.node.type
}); });
} }
} else { } else {
selectedId = null; selectedId = null;
} }
draw(); draw();
} else {
// Panning occurred - save view state
saveViewState();
} }
}); });
let zoomTimeout = null;
canvas.addEventListener('wheel', e => { canvas.addEventListener('wheel', e => {
e.preventDefault(); e.preventDefault();
hideTooltip();
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left; const mx = e.clientX - rect.left;
const my = e.clientY - rect.top; const my = e.clientY - rect.top;
@@ -586,8 +692,16 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
transform.y = my - (my - transform.y) * (ns / transform.scale); transform.y = my - (my - transform.y) * (ns / transform.scale);
transform.scale = ns; transform.scale = ns;
draw(); draw();
// Debounce save to avoid too many requests during continuous zoom
clearTimeout(zoomTimeout);
zoomTimeout = setTimeout(saveViewState, 500);
}, { passive: false }); }, { passive: false });
canvas.addEventListener('mouseleave', () => {
hideTooltip();
});
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// Resize observer (stable zoom on resize) // Resize observer (stable zoom on resize)
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
@@ -613,5 +727,12 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
recomputeLayout(); recomputeLayout();
resize(); 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.components import Div
from fasthtml.xtend import Script 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.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance from myfasthtml.core.instances import MultipleInstance
@@ -30,7 +32,7 @@ class HierarchicalCanvasGraphConf:
class HierarchicalCanvasGraphState(DbObject): class HierarchicalCanvasGraphState(DbObject):
"""Persistent state for HierarchicalCanvasGraph. """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): def __init__(self, owner, save_state=True):
@@ -39,11 +41,30 @@ class HierarchicalCanvasGraphState(DbObject):
# Persisted: set of collapsed node IDs (stored as list for JSON serialization) # Persisted: set of collapsed node IDs (stored as list for JSON serialization)
self.collapsed: list = [] 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) # Not persisted: current selection (ephemeral)
self.ns_selected_id: Optional[str] = None 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): class HierarchicalCanvasGraph(MultipleInstance):
@@ -77,6 +98,7 @@ class HierarchicalCanvasGraph(MultipleInstance):
super().__init__(parent, _id=_id) super().__init__(parent, _id=_id)
self.conf = conf self.conf = conf
self._state = HierarchicalCanvasGraphState(self) self._state = HierarchicalCanvasGraphState(self)
self.commands = Commands(self)
logger.debug(f"HierarchicalCanvasGraph created with id={self._id}, " logger.debug(f"HierarchicalCanvasGraph created with id={self._id}, "
f"nodes={len(conf.nodes)}, edges={len(conf.edges)}") f"nodes={len(conf.nodes)}, edges={len(conf.edges)}")
@@ -126,6 +148,25 @@ class HierarchicalCanvasGraph(MultipleInstance):
self._state.collapsed = list(collapsed_set) self._state.collapsed = list(collapsed_set)
return self 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: def _prepare_options(self) -> dict:
"""Prepare JavaScript options object. """Prepare JavaScript options object.
@@ -134,15 +175,22 @@ class HierarchicalCanvasGraph(MultipleInstance):
""" """
# Convert event handlers to HTMX options # Convert event handlers to HTMX options
events = {} 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: if self.conf.events_handlers:
for event_name, command in self.conf.events_handlers.items(): for event_name, command in self.conf.events_handlers.items():
events[event_name] = command.ajax_htmx_options() events[event_name] = command.ajax_htmx_options()
return { return {
"nodes": self.conf.nodes, "nodes": self.conf.nodes,
"edges": self.conf.edges, "edges": self.conf.edges,
"collapsed": self._state.collapsed, "collapsed": self._state.collapsed,
"events": events "transform": self._state.transform,
"layout_mode": self._state.layout_mode,
"events": events
} }
def render(self): def render(self):

View File

@@ -1,3 +1,5 @@
from dataclasses import dataclass
from myfasthtml.controls.HierarchicalCanvasGraph import HierarchicalCanvasGraph, HierarchicalCanvasGraphConf 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
@@ -5,9 +7,22 @@ from myfasthtml.core.commands import Command
from myfasthtml.core.instances import SingleInstance, UniqueInstance, MultipleInstance, InstancesManager 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): class InstancesDebugger(SingleInstance):
def __init__(self, parent, _id=None): def __init__(self, parent, conf: InstancesDebuggerConf = None, _id=None):
super().__init__(parent, _id=_id) super().__init__(parent, _id=_id)
self.conf = conf if conf is not None else InstancesDebuggerConf()
self._panel = Panel(self, _id="-panel") self._panel = Panel(self, _id="-panel")
self._select_command = Command("ShowInstance", self._select_command = Command("ShowInstance",
"Display selected Instance", "Display selected Instance",
@@ -30,7 +45,7 @@ class InstancesDebugger(SingleInstance):
"""Handle node selection event from canvas graph. """Handle node selection event from canvas graph.
Args: 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") node_id = event_data.get("node_id")
if not node_id: if not node_id:
@@ -52,8 +67,8 @@ class InstancesDebugger(SingleInstance):
properties_def, properties_def,
_id="-properties")) _id="-properties"))
def _get_instance_type(self, instance) -> str: def _get_instance_kind(self, instance) -> str:
"""Determine the instance type for visualization. """Determine the instance kind for visualization.
Args: Args:
instance: The instance object instance: The instance object
@@ -77,7 +92,7 @@ class InstancesDebugger(SingleInstance):
"""Build nodes and edges from current instances. """Build nodes and edges from current instances.
Returns: 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() instances = self._get_instances()
@@ -85,7 +100,7 @@ class InstancesDebugger(SingleInstance):
edges = [] edges = []
existing_ids = set() existing_ids = set()
# Create nodes with type and kind information # Create nodes with kind (instance kind) and type (class name)
for instance in instances: for instance in instances:
node_id = instance.get_full_id() node_id = instance.get_full_id()
existing_ids.add(node_id) existing_ids.add(node_id)
@@ -93,8 +108,8 @@ class InstancesDebugger(SingleInstance):
nodes.append({ nodes.append({
"id": node_id, "id": node_id,
"label": instance.get_id(), "label": instance.get_id(),
"type": self._get_instance_type(instance), "kind": self._get_instance_kind(instance),
"kind": instance.__class__.__name__ "type": instance.__class__.__name__
}) })
# Track nodes with parents # Track nodes with parents
@@ -120,13 +135,48 @@ class InstancesDebugger(SingleInstance):
nodes.append({ nodes.append({
"id": parent_id, "id": parent_id,
"label": f"Ghost: {parent_id}", "label": f"Ghost: {parent_id}",
"type": "multiple", # Default type for ghost nodes "kind": "multiple", # Default kind for ghost nodes
"kind": "Ghost" "type": "Ghost"
}) })
existing_ids.add(parent_id) 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 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): def _get_instances(self):
return list(InstancesManager.instances.values()) return list(InstancesManager.instances.values())