Compare commits
2 Commits
44691be30f
...
0686103a8f
| Author | SHA1 | Date | |
|---|---|---|---|
| 0686103a8f | |||
| 8b8172231a |
@@ -274,18 +274,18 @@
|
|||||||
// Data
|
// Data
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
const NODES = [
|
const NODES = [
|
||||||
{ id: 'app', label: 'app', type: 'root', kind: 'RootInstance' },
|
{ id: 'app', label: 'app', type: 'root', kind: 'RootInstance', description: 'Main application root' },
|
||||||
{ id: 'layout', label: 'layout', type: 'single', kind: 'Layout' },
|
{ id: 'layout', label: 'layout', type: 'single', kind: 'Layout', description: 'Main layout with 2 panels' },
|
||||||
{ id: 'left_panel', label: 'left_panel', type: 'multiple', kind: 'Panel' },
|
{ id: 'left_panel', label: 'left_panel', type: 'multiple', kind: 'Panel' },
|
||||||
{ id: 'right_panel', label: 'right_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: 'instances_debugger', label: 'instances_debugger', type: 'single', kind: 'InstancesDebugger', description: 'Debug tool for instances' },
|
||||||
{ id: 'dbg_panel', label: 'dbg#panel', type: 'multiple', kind: 'Panel' },
|
{ id: 'dbg_panel', label: 'dbg#panel', type: 'multiple', kind: 'Panel' },
|
||||||
{ id: 'canvas_graph', label: 'dbg#canvas_graph', type: 'multiple', kind: 'CanvasGraph' },
|
{ id: 'canvas_graph', label: 'dbg#canvas_graph', type: 'multiple', kind: 'CanvasGraph', description: 'Canvas-based graph view' },
|
||||||
{ id: 'data_grid_manager', label: 'data_grid_manager', type: 'single', kind: 'DataGridsManager' },
|
{ id: 'data_grid_manager', label: 'data_grid_manager', type: 'single', kind: 'DataGridsManager', description: 'Manages all data grids' },
|
||||||
{ id: 'my_grid', label: 'my_grid', type: 'multiple', kind: 'DataGrid' },
|
{ id: 'my_grid', label: 'my_grid', type: 'multiple', kind: 'DataGrid', description: '42 rows × 5 columns' },
|
||||||
{ id: 'grid_toolbar', label: 'my_grid#toolbar', type: 'multiple', kind: 'Toolbar' },
|
{ id: 'grid_toolbar', label: 'my_grid#toolbar', type: 'multiple', kind: 'Toolbar' },
|
||||||
{ id: 'grid_search', label: 'my_grid#search', type: 'multiple', kind: 'Search' },
|
{ id: 'grid_search', label: 'my_grid#search', type: 'multiple', kind: 'Search' },
|
||||||
{ id: 'auth_proxy', label: 'auth_proxy', type: 'unique', kind: 'AuthProxy' },
|
{ id: 'auth_proxy', label: 'auth_proxy', type: 'unique', kind: 'AuthProxy', description: 'User authentication proxy' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const EDGES = [
|
const EDGES = [
|
||||||
@@ -321,10 +321,15 @@ const DETAILS = {
|
|||||||
// Constants
|
// Constants
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
const NODE_W = 178;
|
const NODE_W = 178;
|
||||||
const NODE_H = 36;
|
const NODE_H_SMALL = 36; // Without description
|
||||||
const LEVEL_H = 84; // vertical distance between levels
|
const NODE_H_LARGE = 54; // With description
|
||||||
const LEAF_GAP = 22; // horizontal gap between leaf slots
|
const LEVEL_H = 96; // Vertical distance between levels
|
||||||
const CHEV_ZONE = 26; // rightmost px = toggle hit zone
|
const LEAF_GAP = 22; // Horizontal gap between leaf slots
|
||||||
|
const CHEV_ZONE = 26; // Rightmost px = toggle hit zone
|
||||||
|
|
||||||
|
function getNodeHeight(node) {
|
||||||
|
return node.description ? NODE_H_LARGE : NODE_H_SMALL;
|
||||||
|
}
|
||||||
|
|
||||||
const TYPE_COLOR = {
|
const TYPE_COLOR = {
|
||||||
root: '#2563eb',
|
root: '#2563eb',
|
||||||
@@ -469,8 +474,12 @@ function draw() {
|
|||||||
const p1 = pos[edge.from], p2 = pos[edge.to];
|
const p1 = pos[edge.from], p2 = pos[edge.to];
|
||||||
if (!p1 || !p2) continue;
|
if (!p1 || !p2) continue;
|
||||||
const dimmed = matchIds && !matchIds.has(edge.from) && !matchIds.has(edge.to);
|
const dimmed = matchIds && !matchIds.has(edge.from) && !matchIds.has(edge.to);
|
||||||
const x1 = p1.x, y1 = p1.y + NODE_H / 2;
|
const node1 = NODES.find(n => n.id === edge.from);
|
||||||
const x2 = p2.x, y2 = p2.y - NODE_H / 2;
|
const node2 = NODES.find(n => n.id === edge.to);
|
||||||
|
const h1 = node1 ? getNodeHeight(node1) : NODE_H_SMALL;
|
||||||
|
const h2 = node2 ? getNodeHeight(node2) : NODE_H_SMALL;
|
||||||
|
const x1 = p1.x, y1 = p1.y + h1 / 2;
|
||||||
|
const x2 = p2.x, y2 = p2.y - h2 / 2;
|
||||||
const cy = (y1 + y2) / 2;
|
const cy = (y1 + y2) / 2;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(x1, y1);
|
ctx.moveTo(x1, y1);
|
||||||
@@ -498,7 +507,8 @@ function draw() {
|
|||||||
|
|
||||||
// ── Node renderer ────────────────────────────────────────
|
// ── Node renderer ────────────────────────────────────────
|
||||||
function drawNode(node, cx, cy, isSel, isMatch, isDim) {
|
function drawNode(node, cx, cy, isSel, isMatch, isDim) {
|
||||||
const hw = NODE_W / 2, hh = NODE_H / 2, r = 6;
|
const nodeH = getNodeHeight(node);
|
||||||
|
const hw = NODE_W / 2, hh = nodeH / 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 = TYPE_COLOR[node.type] || '#334155';
|
||||||
|
|
||||||
@@ -509,7 +519,7 @@ function drawNode(node, cx, cy, isSel, isMatch, isDim) {
|
|||||||
|
|
||||||
// Background — dark card
|
// Background — dark card
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.roundRect(x, y, NODE_W, NODE_H, r);
|
ctx.roundRect(x, y, NODE_W, nodeH, r);
|
||||||
ctx.fillStyle = isSel ? '#2a1f0f' : '#1c2128';
|
ctx.fillStyle = isSel ? '#2a1f0f' : '#1c2128';
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.shadowBlur = 0;
|
ctx.shadowBlur = 0;
|
||||||
@@ -517,15 +527,15 @@ function drawNode(node, cx, cy, isSel, isMatch, isDim) {
|
|||||||
// Left color strip (clipped)
|
// Left color strip (clipped)
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.roundRect(x, y, NODE_W, NODE_H, r);
|
ctx.roundRect(x, y, NODE_W, nodeH, r);
|
||||||
ctx.clip();
|
ctx.clip();
|
||||||
ctx.fillStyle = color;
|
ctx.fillStyle = color;
|
||||||
ctx.fillRect(x, y, 4, NODE_H);
|
ctx.fillRect(x, y, 4, nodeH);
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|
||||||
// Border
|
// Border
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.roundRect(x, y, NODE_W, NODE_H, r);
|
ctx.roundRect(x, y, NODE_W, nodeH, r);
|
||||||
if (isSel) {
|
if (isSel) {
|
||||||
ctx.strokeStyle = '#f0883e';
|
ctx.strokeStyle = '#f0883e';
|
||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 1.5;
|
||||||
@@ -545,7 +555,7 @@ function drawNode(node, cx, cy, isSel, isMatch, isDim) {
|
|||||||
const badgeW = Math.min(rawW + 8, 66);
|
const badgeW = Math.min(rawW + 8, 66);
|
||||||
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 - 2;
|
||||||
const badgeY = y + (NODE_H - 14) / 2;
|
const badgeY = y + (nodeH - 14) / 2;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.roundRect(badgeX, badgeY, badgeW, 14, 3);
|
ctx.roundRect(badgeX, badgeY, badgeW, 14, 3);
|
||||||
ctx.fillStyle = `${color}22`;
|
ctx.fillStyle = `${color}22`;
|
||||||
@@ -559,7 +569,7 @@ function drawNode(node, cx, cy, isSel, isMatch, isDim) {
|
|||||||
if (kLabel !== kindText) kLabel += '…';
|
if (kLabel !== kindText) kLabel += '…';
|
||||||
ctx.fillText(kLabel, badgeX + badgeW / 2, badgeY + 7);
|
ctx.fillText(kLabel, badgeX + badgeW / 2, badgeY + 7);
|
||||||
|
|
||||||
// Label
|
// Label (centered if no description, top if description)
|
||||||
ctx.font = `${isSel ? 500 : 400} 12px monospace`;
|
ctx.font = `${isSel ? 500 : 400} 12px monospace`;
|
||||||
ctx.fillStyle = isDim ? 'rgba(125,133,144,0.5)' : '#e6edf3';
|
ctx.fillStyle = isDim ? 'rgba(125,133,144,0.5)' : '#e6edf3';
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
@@ -569,7 +579,18 @@ function drawNode(node, cx, cy, isSel, isMatch, isDim) {
|
|||||||
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);
|
const labelY = node.description ? cy - 9 : cy;
|
||||||
|
ctx.fillText(label, labelX, labelY);
|
||||||
|
|
||||||
|
// Description (bottom line, only if present)
|
||||||
|
if (node.description) {
|
||||||
|
ctx.font = '9px system-ui';
|
||||||
|
ctx.fillStyle = isDim ? 'rgba(125,133,144,0.3)' : 'rgba(125,133,144,0.7)';
|
||||||
|
let desc = node.description;
|
||||||
|
while (desc.length > 3 && ctx.measureText(desc).width > labelMaxW) desc = desc.slice(0, -1);
|
||||||
|
if (desc !== node.description) desc += '…';
|
||||||
|
ctx.fillText(desc, labelX, cy + 8);
|
||||||
|
}
|
||||||
|
|
||||||
// Chevron toggle (if has children)
|
// Chevron toggle (if has children)
|
||||||
if (hasChildren(node.id)) {
|
if (hasChildren(node.id)) {
|
||||||
@@ -606,13 +627,18 @@ function drawChevron(ctx, cx, cy, pointDown, color) {
|
|||||||
function fitAll() {
|
function fitAll() {
|
||||||
const vn = visNodes();
|
const vn = visNodes();
|
||||||
if (vn.length === 0) return;
|
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 pad = 48;
|
||||||
const minX = Math.min(...xs) - NODE_W / 2 - pad;
|
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||||||
const maxX = Math.max(...xs) + NODE_W / 2 + pad;
|
for (const n of vn) {
|
||||||
const minY = Math.min(...ys) - NODE_H / 2 - pad;
|
const p = pos[n.id];
|
||||||
const maxY = Math.max(...ys) + NODE_H / 2 + pad;
|
if (!p) continue;
|
||||||
|
const h = getNodeHeight(n);
|
||||||
|
minX = Math.min(minX, p.x - NODE_W / 2);
|
||||||
|
maxX = Math.max(maxX, p.x + NODE_W / 2);
|
||||||
|
minY = Math.min(minY, p.y - h / 2);
|
||||||
|
maxY = Math.max(maxY, p.y + h / 2);
|
||||||
|
}
|
||||||
|
minX -= pad; maxX += pad; minY -= pad; maxY += pad;
|
||||||
const scale = Math.min(canvas.width / (maxX - minX), canvas.height / (maxY - minY), 1.5);
|
const scale = Math.min(canvas.width / (maxX - minX), canvas.height / (maxY - minY), 1.5);
|
||||||
transform.scale = scale;
|
transform.scale = scale;
|
||||||
transform.x = (canvas.width - (minX + maxX) * scale) / 2;
|
transform.x = (canvas.width - (minX + maxX) * scale) / 2;
|
||||||
@@ -631,7 +657,8 @@ function hitTest(sx, sy) {
|
|||||||
const n = vn[i];
|
const n = vn[i];
|
||||||
const p = pos[n.id];
|
const p = pos[n.id];
|
||||||
if (!p) continue;
|
if (!p) continue;
|
||||||
if (Math.abs(wx - p.x) <= NODE_W / 2 && Math.abs(wy - p.y) <= NODE_H / 2) {
|
const nodeH = getNodeHeight(n);
|
||||||
|
if (Math.abs(wx - p.x) <= NODE_W / 2 && Math.abs(wy - p.y) <= nodeH / 2) {
|
||||||
const isToggle = hasChildren(n.id) && wx >= p.x + NODE_W / 2 - CHEV_ZONE;
|
const isToggle = hasChildren(n.id) && wx >= p.x + NODE_W / 2 - CHEV_ZONE;
|
||||||
return { node: n, isToggle };
|
return { node: n, isToggle };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: [],
|
||||||
@@ -68,14 +68,21 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
|
|||||||
const NODES = options.nodes || [];
|
const NODES = options.nodes || [];
|
||||||
const EDGES = options.edges || [];
|
const EDGES = options.edges || [];
|
||||||
const EVENTS = options.events || {};
|
const EVENTS = options.events || {};
|
||||||
|
// filtered_nodes: null = no filter, [] = filter but no matches, [ids] = filter with matches
|
||||||
|
const FILTERED_NODES = options.filtered_nodes === null ? null : new Set(options.filtered_nodes);
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// Visual Constants
|
// Visual Constants
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
const NODE_W = 178; // Node width in pixels
|
const NODE_W = 178; // Node width in pixels
|
||||||
const NODE_H = 36; // Node height in pixels
|
const NODE_H_SMALL = 36; // Node height without description
|
||||||
|
const NODE_H_LARGE = 54; // Node height with description
|
||||||
const CHEV_ZONE = 26; // Toggle button hit area width (rightmost px of node)
|
const CHEV_ZONE = 26; // Toggle button hit area width (rightmost px of node)
|
||||||
|
|
||||||
|
function getNodeHeight(node) {
|
||||||
|
return node.description ? NODE_H_LARGE : NODE_H_SMALL;
|
||||||
|
}
|
||||||
|
|
||||||
const TOGGLE_BTN_SIZE = 32; // Toggle button dimensions
|
const TOGGLE_BTN_SIZE = 32; // Toggle button dimensions
|
||||||
const TOGGLE_BTN_POS = 12; // Toggle button offset from corner
|
const TOGGLE_BTN_POS = 12; // Toggle button offset from corner
|
||||||
|
|
||||||
@@ -97,18 +104,34 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
|
|||||||
|
|
||||||
const VERTICAL_MODE_SPACING = {
|
const VERTICAL_MODE_SPACING = {
|
||||||
levelGap: 220, // horizontal distance between parent-child (after swap)
|
levelGap: 220, // horizontal distance between parent-child (after swap)
|
||||||
siblingGap: 14 // gap between siblings (in addition to NODE_H)
|
siblingGap: 14 // gap between siblings (in addition to NODE_H_LARGE)
|
||||||
};
|
};
|
||||||
|
|
||||||
function getSpacing() {
|
function getSpacing() {
|
||||||
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 +156,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
|
||||||
@@ -151,6 +174,24 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
|
|||||||
return hidden;
|
return hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDescendants(nodeId) {
|
||||||
|
/**
|
||||||
|
* Get all descendant node IDs for a given node.
|
||||||
|
* Used to highlight descendants when a node is selected.
|
||||||
|
*/
|
||||||
|
const descendants = new Set();
|
||||||
|
function addDesc(id) {
|
||||||
|
for (const child of childMap[id] || []) {
|
||||||
|
if (!descendants.has(child)) {
|
||||||
|
descendants.add(child);
|
||||||
|
addDesc(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addDesc(nodeId);
|
||||||
|
return descendants;
|
||||||
|
}
|
||||||
|
|
||||||
function visNodes() {
|
function visNodes() {
|
||||||
const h = getHiddenSet();
|
const h = getHiddenSet();
|
||||||
return NODES.filter(n => !h.has(n.id));
|
return NODES.filter(n => !h.has(n.id));
|
||||||
@@ -187,16 +228,34 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
|
|||||||
const positions = {};
|
const positions = {};
|
||||||
for (const n of nodes) positions[n.id] = { x: 0, y: (depth[n.id] || 0) * spacing.levelGap };
|
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)
|
// Create node map for quick access by ID
|
||||||
const siblingStride = layoutMode === 'vertical'
|
const nodeMap = {};
|
||||||
? NODE_H + spacing.siblingGap // Vertical: nodes stack by height
|
for (const n of nodes) nodeMap[n.id] = n;
|
||||||
: NODE_W + spacing.siblingGap; // Horizontal: nodes spread by width
|
|
||||||
|
// Sibling stride for horizontal mode
|
||||||
|
const siblingStride = NODE_W + spacing.siblingGap;
|
||||||
|
|
||||||
// DFS to assign x (sibling spacing)
|
// DFS to assign x (sibling spacing)
|
||||||
let slot = 0;
|
let slot = 0;
|
||||||
|
let currentX = 0; // For dynamic spacing in vertical mode
|
||||||
|
|
||||||
function dfs(id) {
|
function dfs(id) {
|
||||||
const children = cm[id] || [];
|
const children = cm[id] || [];
|
||||||
if (children.length === 0) { positions[id].x = slot++ * siblingStride; return; }
|
if (children.length === 0) {
|
||||||
|
// Leaf node: assign x position based on layout mode
|
||||||
|
if (layoutMode === 'vertical') {
|
||||||
|
// Dynamic spacing based on actual node height
|
||||||
|
const node = nodeMap[id];
|
||||||
|
const h = getNodeHeight(node);
|
||||||
|
positions[id].x = currentX + h / 2; // Center of the node
|
||||||
|
currentX += h + spacing.siblingGap; // Move to next position
|
||||||
|
} else {
|
||||||
|
// Horizontal mode: constant spacing
|
||||||
|
positions[id].x = slot++ * siblingStride;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Non-leaf: recurse children, then center between them
|
||||||
for (const c of children) dfs(c);
|
for (const c of children) dfs(c);
|
||||||
const xs = children.map(c => positions[c].x);
|
const xs = children.map(c => positions[c].x);
|
||||||
positions[id].x = (Math.min(...xs) + Math.max(...xs)) / 2;
|
positions[id].x = (Math.min(...xs) + Math.max(...xs)) / 2;
|
||||||
@@ -230,11 +289,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 +318,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 +349,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,15 +360,17 @@ 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();
|
// Calculate matchIds based on filter state:
|
||||||
const matchIds = q
|
// - FILTERED_NODES === null: no filter active → matchIds = null (nothing dimmed)
|
||||||
? new Set(NODES.filter(n =>
|
// - FILTERED_NODES.size === 0: filter active, no matches → matchIds = empty Set (everything dimmed)
|
||||||
n.label.toLowerCase().includes(q) || n.kind.toLowerCase().includes(q)
|
// - FILTERED_NODES.size > 0: filter active with matches → matchIds = FILTERED_NODES (dim non-matches)
|
||||||
).map(n => n.id))
|
const matchIds = FILTERED_NODES === null ? null : FILTERED_NODES;
|
||||||
: null;
|
|
||||||
|
// Get descendants of selected node for highlighting
|
||||||
|
const descendantIds = selectedId ? getDescendants(selectedId) : new Set();
|
||||||
|
|
||||||
const vn = visNodes();
|
const vn = visNodes();
|
||||||
|
|
||||||
@@ -296,6 +383,13 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
|
|||||||
const p1 = pos[edge.from], p2 = pos[edge.to];
|
const p1 = pos[edge.from], p2 = pos[edge.to];
|
||||||
if (!p1 || !p2) continue;
|
if (!p1 || !p2) continue;
|
||||||
const dimmed = matchIds && !matchIds.has(edge.from) && !matchIds.has(edge.to);
|
const dimmed = matchIds && !matchIds.has(edge.from) && !matchIds.has(edge.to);
|
||||||
|
const isHighlighted = selectedId && (edge.from === selectedId || descendantIds.has(edge.from));
|
||||||
|
|
||||||
|
// Get dynamic heights for source and target nodes
|
||||||
|
const node1 = NODES.find(n => n.id === edge.from);
|
||||||
|
const node2 = NODES.find(n => n.id === edge.to);
|
||||||
|
const h1 = node1 ? getNodeHeight(node1) : NODE_H_SMALL;
|
||||||
|
const h2 = node2 ? getNodeHeight(node2) : NODE_H_SMALL;
|
||||||
|
|
||||||
const tp1 = transformPos(p1);
|
const tp1 = transformPos(p1);
|
||||||
const tp2 = transformPos(p2);
|
const tp2 = transformPos(p2);
|
||||||
@@ -303,8 +397,8 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
|
|||||||
let x1, y1, x2, y2, cx, cy;
|
let x1, y1, x2, y2, cx, cy;
|
||||||
if (layoutMode === 'horizontal') {
|
if (layoutMode === 'horizontal') {
|
||||||
// Horizontal: edges go from bottom of parent to top of child
|
// Horizontal: edges go from bottom of parent to top of child
|
||||||
x1 = tp1.x; y1 = tp1.y + NODE_H / 2;
|
x1 = tp1.x; y1 = tp1.y + h1 / 2;
|
||||||
x2 = tp2.x; y2 = tp2.y - NODE_H / 2;
|
x2 = tp2.x; y2 = tp2.y - h2 / 2;
|
||||||
cy = (y1 + y2) / 2;
|
cy = (y1 + y2) / 2;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(x1, y1);
|
ctx.moveTo(x1, y1);
|
||||||
@@ -319,8 +413,16 @@ 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)';
|
if (isHighlighted) {
|
||||||
|
ctx.strokeStyle = UI_COLORS.nodeBorderSel;
|
||||||
|
ctx.lineWidth = 2.5;
|
||||||
|
} else if (dimmed) {
|
||||||
|
ctx.strokeStyle = UI_COLORS.edgeDimmed;
|
||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 1.5;
|
||||||
|
} else {
|
||||||
|
ctx.strokeStyle = UI_COLORS.edge;
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
}
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,49 +432,52 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
|
|||||||
if (!p) continue;
|
if (!p) continue;
|
||||||
const tp = transformPos(p);
|
const tp = transformPos(p);
|
||||||
const isSel = node.id === selectedId;
|
const isSel = node.id === selectedId;
|
||||||
|
const isDesc = descendantIds.has(node.id);
|
||||||
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, isDesc, isMatch, isDim, transform.scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawNode(node, cx, cy, isSel, isMatch, isDim) {
|
function drawNode(node, cx, cy, isSel, isDesc, isMatch, isDim, zoomLevel) {
|
||||||
// Nodes always keep same dimensions and horizontal text
|
// Nodes have dynamic height (with or without description)
|
||||||
const hw = NODE_W / 2, hh = NODE_H / 2, r = 6;
|
const nodeH = getNodeHeight(node);
|
||||||
|
const hw = NODE_W / 2, hh = nodeH / 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, nodeH, 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;
|
||||||
|
|
||||||
// Left color strip (always on left, regardless of mode)
|
// Left color strip (always on left, regardless of mode)
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.roundRect(x, y, NODE_W, NODE_H, r);
|
ctx.roundRect(x, y, NODE_W, nodeH, r);
|
||||||
ctx.clip();
|
ctx.clip();
|
||||||
ctx.fillStyle = color;
|
ctx.fillStyle = color;
|
||||||
ctx.fillRect(x, y, 4, NODE_H);
|
ctx.fillRect(x, y, 4, nodeH);
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|
||||||
// Border
|
// Border
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.roundRect(x, y, NODE_W, NODE_H, r);
|
ctx.roundRect(x, y, NODE_W, nodeH, r);
|
||||||
if (isSel) {
|
if (isSel || isDesc) {
|
||||||
ctx.strokeStyle = '#f0883e';
|
// Selected node or descendant: orange border
|
||||||
|
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 +485,57 @@ 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 + (nodeH - 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 (centered if no description, top if description) - 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);
|
const labelY = node.description ? (cy - 9) * zoomLevel : cy * zoomLevel;
|
||||||
|
ctx.fillText(label, Math.round(labelX), Math.round(labelY));
|
||||||
|
|
||||||
|
// Description (bottom line, only if present)
|
||||||
|
if (node.description) {
|
||||||
|
const descFontSize = 9 * zoomLevel;
|
||||||
|
ctx.font = `${descFontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", "Inter", "Roboto", sans-serif`;
|
||||||
|
ctx.fillStyle = isDim ? 'rgba(125,133,144,0.3)' : 'rgba(125,133,144,0.7)';
|
||||||
|
let desc = node.description;
|
||||||
|
while (desc.length > 3 && ctx.measureText(desc).width > labelMaxW) desc = desc.slice(0, -1);
|
||||||
|
if (desc !== node.description) desc += '…';
|
||||||
|
ctx.fillText(desc, Math.round(labelX), Math.round((cy + 8) * 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)) {
|
||||||
@@ -449,23 +574,47 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
|
|||||||
const vn = visNodes();
|
const vn = visNodes();
|
||||||
if (vn.length === 0) return;
|
if (vn.length === 0) return;
|
||||||
|
|
||||||
// Get transformed positions
|
// Calculate bounds using dynamic node heights
|
||||||
const tps = vn.map(n => pos[n.id] ? transformPos(pos[n.id]) : null).filter(p => p !== null);
|
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||||||
if (tps.length === 0) return;
|
for (const n of vn) {
|
||||||
|
const p = pos[n.id];
|
||||||
|
if (!p) continue;
|
||||||
|
const tp = transformPos(p);
|
||||||
|
const h = getNodeHeight(n);
|
||||||
|
minX = Math.min(minX, tp.x - NODE_W / 2);
|
||||||
|
maxX = Math.max(maxX, tp.x + NODE_W / 2);
|
||||||
|
minY = Math.min(minY, tp.y - h / 2);
|
||||||
|
maxY = Math.max(maxY, tp.y + h / 2);
|
||||||
|
}
|
||||||
|
|
||||||
const xs = tps.map(p => p.x);
|
if (!isFinite(minX)) return;
|
||||||
const ys = tps.map(p => p.y);
|
|
||||||
const minX = Math.min(...xs) - NODE_W / 2 - FIT_PADDING;
|
minX -= FIT_PADDING;
|
||||||
const maxX = Math.max(...xs) + NODE_W / 2 + FIT_PADDING;
|
maxX += FIT_PADDING;
|
||||||
const minY = Math.min(...ys) - NODE_H / 2 - FIT_PADDING;
|
minY -= FIT_PADDING;
|
||||||
const maxY = Math.max(...ys) + NODE_H / 2 + FIT_PADDING;
|
maxY += 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
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
@@ -479,12 +628,30 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
|
|||||||
const p = pos[n.id];
|
const p = pos[n.id];
|
||||||
if (!p) continue;
|
if (!p) continue;
|
||||||
const tp = transformPos(p);
|
const tp = transformPos(p);
|
||||||
|
const nodeH = getNodeHeight(n);
|
||||||
|
|
||||||
// Nodes keep same dimensions in both modes
|
// Nodes have dynamic height based on description
|
||||||
if (Math.abs(wx - tp.x) <= NODE_W / 2 && Math.abs(wy - tp.y) <= NODE_H / 2) {
|
if (Math.abs(wx - tp.x) <= NODE_W / 2 && Math.abs(wy - tp.y) <= nodeH / 2) {
|
||||||
// Toggle zone always on the right side of node
|
const hw = NODE_W / 2, hh = nodeH / 2;
|
||||||
|
const x = tp.x - hw, y = tp.y - hh;
|
||||||
|
|
||||||
|
// Check border (left strip) - 4px wide
|
||||||
|
const isBorder = wx >= x && wx <= x + 4;
|
||||||
|
|
||||||
|
// Check toggle zone (chevron on right)
|
||||||
const isToggle = hasChildren(n.id) && wx >= tp.x + NODE_W / 2 - CHEV_ZONE;
|
const isToggle = hasChildren(n.id) && wx >= tp.x + NODE_W / 2 - CHEV_ZONE;
|
||||||
return { node: n, isToggle };
|
|
||||||
|
// Check badge (type badge) - approximate zone (right side, excluding toggle)
|
||||||
|
// Badge is positioned at: x + NODE_W - chevSpace - badgeW - 2
|
||||||
|
// For hit testing, we use a simplified zone: last ~70px before toggle area
|
||||||
|
const chevSpace = hasChildren(n.id) ? CHEV_ZONE : 8;
|
||||||
|
const badgeZoneStart = x + NODE_W - chevSpace - 70;
|
||||||
|
const badgeZoneEnd = x + NODE_W - chevSpace - 2;
|
||||||
|
const badgeY = y + (nodeH - 14) / 2;
|
||||||
|
const isBadge = !isToggle && wx >= badgeZoneStart && wx <= badgeZoneEnd
|
||||||
|
&& wy >= badgeY && wy <= badgeY + 14;
|
||||||
|
|
||||||
|
return { node: n, isToggle, isBadge, isBorder };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -499,14 +666,20 @@ 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: eventData, // Send data as separate fields (default command engine behavior)
|
||||||
target: handler.target || 'body',
|
target: handler.target || 'body',
|
||||||
swap: handler.swap || 'none'
|
swap: handler.swap || 'none'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function saveViewState() {
|
||||||
|
postEvent('_internal_update_state', {
|
||||||
|
transform: transform,
|
||||||
|
layout_mode: layoutMode
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// Interaction
|
// Interaction
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
@@ -520,16 +693,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 => {
|
||||||
@@ -542,11 +746,25 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
|
|||||||
const hit = hitTest(e.clientX - rect.left, e.clientY - rect.top);
|
const hit = hitTest(e.clientX - rect.left, e.clientY - rect.top);
|
||||||
if (hit) {
|
if (hit) {
|
||||||
if (hit.isToggle) {
|
if (hit.isToggle) {
|
||||||
|
// Save screen position of clicked node before layout change
|
||||||
|
const oldPos = pos[hit.node.id];
|
||||||
|
const oldTp = transformPos(oldPos);
|
||||||
|
const screenX = oldTp.x * transform.scale + transform.x;
|
||||||
|
const screenY = oldTp.y * transform.scale + transform.y;
|
||||||
|
|
||||||
// Toggle collapse
|
// Toggle collapse
|
||||||
if (collapsed.has(hit.node.id)) collapsed.delete(hit.node.id);
|
if (collapsed.has(hit.node.id)) collapsed.delete(hit.node.id);
|
||||||
else collapsed.add(hit.node.id);
|
else collapsed.add(hit.node.id);
|
||||||
recomputeLayout();
|
recomputeLayout();
|
||||||
|
|
||||||
|
// Adjust transform to keep clicked node at same screen position
|
||||||
|
const newPos = pos[hit.node.id];
|
||||||
|
if (newPos) {
|
||||||
|
const newTp = transformPos(newPos);
|
||||||
|
transform.x = screenX - newTp.x * transform.scale;
|
||||||
|
transform.y = screenY - newTp.y * transform.scale;
|
||||||
|
}
|
||||||
|
|
||||||
// Post toggle_node event
|
// Post toggle_node event
|
||||||
postEvent('toggle_node', {
|
postEvent('toggle_node', {
|
||||||
node_id: hit.node.id,
|
node_id: hit.node.id,
|
||||||
@@ -557,6 +775,12 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
|
|||||||
if (selectedId && !visNodes().find(n => n.id === selectedId)) {
|
if (selectedId && !visNodes().find(n => n.id === selectedId)) {
|
||||||
selectedId = null;
|
selectedId = null;
|
||||||
}
|
}
|
||||||
|
} else if (hit.isBadge) {
|
||||||
|
// Badge click: filter by type
|
||||||
|
postEvent('_internal_filter_by_type', { query_param: 'type', value: hit.node.type });
|
||||||
|
} else if (hit.isBorder) {
|
||||||
|
// Border click: filter by kind
|
||||||
|
postEvent('_internal_filter_by_kind', { query_param: 'kind', value: hit.node.kind });
|
||||||
} else {
|
} else {
|
||||||
selectedId = hit.node.id;
|
selectedId = hit.node.id;
|
||||||
|
|
||||||
@@ -564,19 +788,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 +815,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 +850,12 @@ function initHierarchicalCanvasGraph(containerId, options = {}) {
|
|||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
recomputeLayout();
|
recomputeLayout();
|
||||||
resize();
|
resize();
|
||||||
|
|
||||||
|
// 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);
|
setTimeout(fitAll, 30);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ from myfasthtml.controls.BaseCommands import BaseCommands
|
|||||||
from myfasthtml.controls.CycleStateControl import CycleStateControl
|
from myfasthtml.controls.CycleStateControl import CycleStateControl
|
||||||
from myfasthtml.controls.DataGridColumnsManager import DataGridColumnsManager
|
from myfasthtml.controls.DataGridColumnsManager import DataGridColumnsManager
|
||||||
from myfasthtml.controls.DataGridFormattingEditor import DataGridFormattingEditor
|
from myfasthtml.controls.DataGridFormattingEditor import DataGridFormattingEditor
|
||||||
from myfasthtml.controls.DataGridQuery import DataGridQuery, DG_QUERY_FILTER
|
|
||||||
from myfasthtml.controls.DslEditor import DslEditorConf
|
from myfasthtml.controls.DslEditor import DslEditorConf
|
||||||
from myfasthtml.controls.IconsHelper import IconsHelper
|
from myfasthtml.controls.IconsHelper import IconsHelper
|
||||||
from myfasthtml.controls.Keyboard import Keyboard
|
from myfasthtml.controls.Keyboard import Keyboard
|
||||||
from myfasthtml.controls.Mouse import Mouse
|
from myfasthtml.controls.Mouse import Mouse
|
||||||
from myfasthtml.controls.Panel import Panel, PanelConf
|
from myfasthtml.controls.Panel import Panel, PanelConf
|
||||||
|
from myfasthtml.controls.Query import Query, QUERY_FILTER
|
||||||
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \
|
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \
|
||||||
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState
|
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState
|
||||||
from myfasthtml.controls.helpers import mk, column_type_defaults
|
from myfasthtml.controls.helpers import mk, column_type_defaults
|
||||||
@@ -142,7 +142,7 @@ class Commands(BaseCommands):
|
|||||||
return Command("Filter",
|
return Command("Filter",
|
||||||
"Filter Grid",
|
"Filter Grid",
|
||||||
self._owner,
|
self._owner,
|
||||||
self._owner.filter
|
self._owner.filter,
|
||||||
)
|
)
|
||||||
|
|
||||||
def change_selection_mode(self):
|
def change_selection_mode(self):
|
||||||
@@ -212,8 +212,8 @@ class DataGrid(MultipleInstance):
|
|||||||
self.bind_command("ToggleColumnsManager", self._panel.commands.toggle_side("right"))
|
self.bind_command("ToggleColumnsManager", self._panel.commands.toggle_side("right"))
|
||||||
self.bind_command("ToggleFormattingEditor", self._panel.commands.toggle_side("right"))
|
self.bind_command("ToggleFormattingEditor", self._panel.commands.toggle_side("right"))
|
||||||
|
|
||||||
# add DataGridQuery
|
# add Query
|
||||||
self._datagrid_filter = DataGridQuery(self)
|
self._datagrid_filter = Query(self)
|
||||||
self._datagrid_filter.bind_command("QueryChanged", self.commands.filter())
|
self._datagrid_filter.bind_command("QueryChanged", self.commands.filter())
|
||||||
self._datagrid_filter.bind_command("CancelQuery", self.commands.filter())
|
self._datagrid_filter.bind_command("CancelQuery", self.commands.filter())
|
||||||
self._datagrid_filter.bind_command("ChangeFilterType", self.commands.filter())
|
self._datagrid_filter.bind_command("ChangeFilterType", self.commands.filter())
|
||||||
@@ -295,7 +295,7 @@ class DataGrid(MultipleInstance):
|
|||||||
for col_id, values in self._state.filtered.items():
|
for col_id, values in self._state.filtered.items():
|
||||||
if col_id == FILTER_INPUT_CID:
|
if col_id == FILTER_INPUT_CID:
|
||||||
if values is not None:
|
if values is not None:
|
||||||
if self._datagrid_filter.get_query_type() == DG_QUERY_FILTER:
|
if self._datagrid_filter.get_query_type() == QUERY_FILTER:
|
||||||
visible_columns = [c.col_id for c in self._state.columns if c.visible and c.col_id in df.columns]
|
visible_columns = [c.col_id for c in self._state.columns if c.visible and c.col_id in df.columns]
|
||||||
df = df[df[visible_columns].map(lambda x: values.lower() in str(x).lower()).any(axis=1)]
|
df = df[df[visible_columns].map(lambda x: values.lower() in str(x).lower()).any(axis=1)]
|
||||||
else:
|
else:
|
||||||
@@ -632,6 +632,9 @@ class DataGrid(MultipleInstance):
|
|||||||
def get_state(self):
|
def get_state(self):
|
||||||
return self._state
|
return self._state
|
||||||
|
|
||||||
|
def get_description(self) -> str:
|
||||||
|
return self.get_table_name()
|
||||||
|
|
||||||
def get_settings(self):
|
def get_settings(self):
|
||||||
return self._settings
|
return self._settings
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ 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.controls.Query import Query, QueryConf
|
||||||
|
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 +33,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 +42,48 @@ 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'
|
||||||
|
|
||||||
|
# Persisted: filter state
|
||||||
|
self.filter_text: Optional[str] = None # Text search filter
|
||||||
|
self.filter_type: Optional[str] = None # Type filter (badge click)
|
||||||
|
self.filter_kind: Optional[str] = None # Kind filter (border click)
|
||||||
|
|
||||||
# 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')
|
||||||
|
|
||||||
|
def apply_filter(self):
|
||||||
|
"""Apply current filter and update the graph display.
|
||||||
|
|
||||||
|
This command is called when the filter changes (search text, type, or kind).
|
||||||
|
"""
|
||||||
|
return Command(
|
||||||
|
"ApplyFilter",
|
||||||
|
"Apply filter to graph",
|
||||||
|
self._owner,
|
||||||
|
self._owner._handle_apply_filter,
|
||||||
|
key="#{id}-apply-filter",
|
||||||
|
).htmx(target=f"#{self._id}")
|
||||||
|
|
||||||
|
|
||||||
class HierarchicalCanvasGraph(MultipleInstance):
|
class HierarchicalCanvasGraph(MultipleInstance):
|
||||||
@@ -77,6 +117,12 @@ 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)
|
||||||
|
|
||||||
|
# Add Query component for filtering
|
||||||
|
self._query = Query(self, QueryConf(placeholder="Filter instances..."), _id="-query")
|
||||||
|
self._query.bind_command("QueryChanged", self.commands.apply_filter())
|
||||||
|
self._query.bind_command("CancelQuery", self.commands.apply_filter())
|
||||||
|
|
||||||
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 +172,102 @@ 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, transform=None, layout_mode=None):
|
||||||
|
"""Internal handler to update view state from client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
transform: Optional dict with zoom/pan transform state
|
||||||
|
layout_mode: Optional string with layout orientation
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Empty string (no UI update needed)
|
||||||
|
"""
|
||||||
|
if transform is not None:
|
||||||
|
self._state.transform = transform
|
||||||
|
logger.debug(f"Transform updated: {self._state.transform}")
|
||||||
|
|
||||||
|
if layout_mode is not None:
|
||||||
|
self._state.layout_mode = layout_mode
|
||||||
|
logger.debug(f"Layout mode updated: {self._state.layout_mode}")
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _handle_apply_filter(self, query_param="text", value=None):
|
||||||
|
"""Internal handler to apply filter and re-render the graph.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query_param: Type of filter - "text", "type", or "kind"
|
||||||
|
value: The filter value (type name or kind name). Toggles off if same value clicked again.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
self: For HTMX to render the updated graph
|
||||||
|
"""
|
||||||
|
# Save old values to detect toggle
|
||||||
|
old_filter_type = self._state.filter_type
|
||||||
|
old_filter_kind = self._state.filter_kind
|
||||||
|
|
||||||
|
# Reset all filters
|
||||||
|
self._state.filter_text = None
|
||||||
|
self._state.filter_type = None
|
||||||
|
self._state.filter_kind = None
|
||||||
|
|
||||||
|
# Apply the requested filter
|
||||||
|
if query_param == "text":
|
||||||
|
# Text filter from Query component
|
||||||
|
self._state.filter_text = self._query.get_query()
|
||||||
|
|
||||||
|
elif query_param == "type":
|
||||||
|
# Type filter from badge click - toggle if same type clicked again
|
||||||
|
if old_filter_type != value:
|
||||||
|
self._state.filter_type = value
|
||||||
|
|
||||||
|
elif query_param == "kind":
|
||||||
|
# Kind filter from border click - toggle if same kind clicked again
|
||||||
|
if old_filter_kind != value:
|
||||||
|
self._state.filter_kind = value
|
||||||
|
|
||||||
|
logger.debug(f"Applying filter: query_param={query_param}, value={value}, "
|
||||||
|
f"text={self._state.filter_text}, type={self._state.filter_type}, kind={self._state.filter_kind}")
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
def _calculate_filtered_nodes(self) -> Optional[list[str]]:
|
||||||
|
"""Calculate which node IDs match the current filter criteria.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optional[list[str]]:
|
||||||
|
- None: No filter is active (all nodes visible, nothing dimmed)
|
||||||
|
- []: Filter active but no matches (all nodes dimmed)
|
||||||
|
- [ids]: Filter active with matches (only these nodes visible)
|
||||||
|
"""
|
||||||
|
# If no filters are active, return None (no filtering)
|
||||||
|
if not self._state.filter_text and not self._state.filter_type and not self._state.filter_kind:
|
||||||
|
return None
|
||||||
|
|
||||||
|
filtered_ids = []
|
||||||
|
for node in self.conf.nodes:
|
||||||
|
matches = True
|
||||||
|
|
||||||
|
# Check text filter (searches in id, label, type, kind)
|
||||||
|
if self._state.filter_text:
|
||||||
|
search_text = self._state.filter_text.lower()
|
||||||
|
searchable = f"{node.get('id', '')} {node.get('label', '')} {node.get('type', '')} {node.get('kind', '')}".lower()
|
||||||
|
if search_text not in searchable:
|
||||||
|
matches = False
|
||||||
|
|
||||||
|
# Check type filter
|
||||||
|
if self._state.filter_type and node.get('type') != self._state.filter_type:
|
||||||
|
matches = False
|
||||||
|
|
||||||
|
# Check kind filter
|
||||||
|
if self._state.filter_kind and node.get('kind') != self._state.filter_kind:
|
||||||
|
matches = False
|
||||||
|
|
||||||
|
if matches:
|
||||||
|
filtered_ids.append(node['id'])
|
||||||
|
|
||||||
|
return filtered_ids
|
||||||
|
|
||||||
def _prepare_options(self) -> dict:
|
def _prepare_options(self) -> dict:
|
||||||
"""Prepare JavaScript options object.
|
"""Prepare JavaScript options object.
|
||||||
|
|
||||||
@@ -134,14 +276,29 @@ 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 internal handlers for filtering by type and kind (badge/border clicks)
|
||||||
|
events['_internal_filter_by_type'] = self.commands.apply_filter().ajax_htmx_options()
|
||||||
|
events['_internal_filter_by_kind'] = self.commands.apply_filter().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()
|
||||||
|
|
||||||
|
# Calculate filtered nodes
|
||||||
|
filtered_nodes = self._calculate_filtered_nodes()
|
||||||
|
|
||||||
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,
|
||||||
|
"transform": self._state.transform,
|
||||||
|
"layout_mode": self._state.layout_mode,
|
||||||
|
"filtered_nodes": filtered_nodes,
|
||||||
"events": events
|
"events": events
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +312,9 @@ class HierarchicalCanvasGraph(MultipleInstance):
|
|||||||
options_json = json.dumps(options, indent=2)
|
options_json = json.dumps(options, indent=2)
|
||||||
|
|
||||||
return Div(
|
return Div(
|
||||||
|
# Query filter bar
|
||||||
|
self._query,
|
||||||
|
|
||||||
# Canvas element (sized by JS to fill container)
|
# Canvas element (sized by JS to fill container)
|
||||||
Div(
|
Div(
|
||||||
id=f"{self._id}_container",
|
id=f"{self._id}_container",
|
||||||
|
|||||||
@@ -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,9 @@ 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__,
|
||||||
|
"description": instance.get_description()
|
||||||
})
|
})
|
||||||
|
|
||||||
# Track nodes with parents
|
# Track nodes with parents
|
||||||
@@ -120,13 +136,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())
|
||||||
|
|
||||||
|
|||||||
109
src/myfasthtml/controls/Query.py
Normal file
109
src/myfasthtml/controls/Query.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fasthtml.components import *
|
||||||
|
|
||||||
|
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||||
|
from myfasthtml.controls.helpers import mk
|
||||||
|
from myfasthtml.core.commands import Command
|
||||||
|
from myfasthtml.core.dbmanager import DbObject
|
||||||
|
from myfasthtml.core.instances import MultipleInstance
|
||||||
|
from myfasthtml.icons.fluent import brain_circuit20_regular
|
||||||
|
from myfasthtml.icons.fluent_p1 import filter20_regular, search20_regular
|
||||||
|
from myfasthtml.icons.fluent_p2 import dismiss_circle20_regular
|
||||||
|
|
||||||
|
logger = logging.getLogger("Query")
|
||||||
|
|
||||||
|
QUERY_FILTER = "filter"
|
||||||
|
QUERY_SEARCH = "search"
|
||||||
|
QUERY_AI = "ai"
|
||||||
|
|
||||||
|
query_type = {
|
||||||
|
QUERY_FILTER: filter20_regular,
|
||||||
|
QUERY_SEARCH: search20_regular,
|
||||||
|
QUERY_AI: brain_circuit20_regular
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class QueryConf:
|
||||||
|
"""Configuration for Query control.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
placeholder: Placeholder text for the search input
|
||||||
|
"""
|
||||||
|
placeholder: str = "Search..."
|
||||||
|
|
||||||
|
|
||||||
|
class QueryState(DbObject):
|
||||||
|
def __init__(self, owner):
|
||||||
|
with self.initializing():
|
||||||
|
super().__init__(owner)
|
||||||
|
self.filter_type: str = "filter"
|
||||||
|
self.query: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Commands(BaseCommands):
|
||||||
|
def change_filter_type(self):
|
||||||
|
return Command("ChangeFilterType",
|
||||||
|
"Change filter type",
|
||||||
|
self._owner,
|
||||||
|
self._owner.change_query_type).htmx(target=f"#{self._id}")
|
||||||
|
|
||||||
|
def on_filter_changed(self):
|
||||||
|
return Command("QueryChanged",
|
||||||
|
"Query changed",
|
||||||
|
self._owner,
|
||||||
|
self._owner.query_changed).htmx(target=None) # prevent focus loss when typing
|
||||||
|
|
||||||
|
def on_cancel_query(self):
|
||||||
|
return Command("CancelQuery",
|
||||||
|
"Cancel query",
|
||||||
|
self._owner,
|
||||||
|
self._owner.query_changed,
|
||||||
|
kwargs={"query": ""}
|
||||||
|
).htmx(target=f"#{self._id}")
|
||||||
|
|
||||||
|
|
||||||
|
class Query(MultipleInstance):
|
||||||
|
def __init__(self, parent, conf: Optional[QueryConf] = None, _id=None):
|
||||||
|
super().__init__(parent, _id=_id)
|
||||||
|
self.conf = conf or QueryConf()
|
||||||
|
self.commands = Commands(self)
|
||||||
|
self._state = QueryState(self)
|
||||||
|
|
||||||
|
def get_query(self):
|
||||||
|
return self._state.query
|
||||||
|
|
||||||
|
def get_query_type(self):
|
||||||
|
return self._state.filter_type
|
||||||
|
|
||||||
|
def change_query_type(self):
|
||||||
|
keys = list(query_type.keys()) # ["filter", "search", "ai"]
|
||||||
|
current_idx = keys.index(self._state.filter_type)
|
||||||
|
self._state.filter_type = keys[(current_idx + 1) % len(keys)]
|
||||||
|
return self
|
||||||
|
|
||||||
|
def query_changed(self, query):
|
||||||
|
logger.debug(f"query_changed {query=}")
|
||||||
|
self._state.query = query.strip() if query is not None else None
|
||||||
|
return self # needed anyway to allow oob swap
|
||||||
|
|
||||||
|
def render(self):
|
||||||
|
return Div(
|
||||||
|
mk.label(
|
||||||
|
Input(name="query",
|
||||||
|
value=self._state.query if self._state.query is not None else "",
|
||||||
|
placeholder=self.conf.placeholder,
|
||||||
|
**self.commands.on_filter_changed().get_htmx_params(values_encode="json")),
|
||||||
|
icon=mk.icon(query_type[self._state.filter_type], command=self.commands.change_filter_type()),
|
||||||
|
cls="input input-xs flex gap-3"
|
||||||
|
),
|
||||||
|
mk.icon(dismiss_circle20_regular, size=24, command=self.commands.on_cancel_query()),
|
||||||
|
cls="flex",
|
||||||
|
id=self._id
|
||||||
|
)
|
||||||
|
|
||||||
|
def __ft__(self):
|
||||||
|
return self.render()
|
||||||
@@ -77,7 +77,8 @@ class Command:
|
|||||||
|
|
||||||
return key
|
return key
|
||||||
|
|
||||||
def __init__(self, name,
|
def __init__(self,
|
||||||
|
name,
|
||||||
description,
|
description,
|
||||||
owner=None,
|
owner=None,
|
||||||
callback=None,
|
callback=None,
|
||||||
@@ -102,10 +103,10 @@ class Command:
|
|||||||
if auto_register:
|
if auto_register:
|
||||||
if self._key is not None:
|
if self._key is not None:
|
||||||
if self._key in CommandsManager.commands_by_key:
|
if self._key in CommandsManager.commands_by_key:
|
||||||
#logger.debug(f"Command {self.name} with key={self._key} will not be registered.")
|
# logger.debug(f"Command {self.name} with key={self._key} will not be registered.")
|
||||||
self.id = CommandsManager.commands_by_key[self._key].id
|
self.id = CommandsManager.commands_by_key[self._key].id
|
||||||
else:
|
else:
|
||||||
#logger.debug(f"Command {self.name} with key={self._key} will be registered.")
|
# logger.debug(f"Command {self.name} with key={self._key} will be registered.")
|
||||||
CommandsManager.register(self)
|
CommandsManager.register(self)
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Command {self.name} has no key, it will not be registered.")
|
logger.warning(f"Command {self.name} has no key, it will not be registered.")
|
||||||
@@ -170,7 +171,10 @@ class Command:
|
|||||||
|
|
||||||
# Set the hx-swap-oob attribute on all elements returned by the callback
|
# Set the hx-swap-oob attribute on all elements returned by the callback
|
||||||
if self._htmx_extra[AUTO_SWAP_OOB]:
|
if self._htmx_extra[AUTO_SWAP_OOB]:
|
||||||
for r in all_ret[1:]:
|
for index, r in enumerate(all_ret[1:]):
|
||||||
|
if hasattr(r, "__ft__"):
|
||||||
|
r = r.__ft__()
|
||||||
|
all_ret[index + 1] = r
|
||||||
if (hasattr(r, 'attrs')
|
if (hasattr(r, 'attrs')
|
||||||
and "hx-swap-oob" not in r.attrs
|
and "hx-swap-oob" not in r.attrs
|
||||||
and r.get("id", None) is not None):
|
and r.get("id", None) is not None):
|
||||||
|
|||||||
@@ -106,6 +106,9 @@ class BaseInstance:
|
|||||||
def get_full_id(self) -> str:
|
def get_full_id(self) -> str:
|
||||||
return f"{InstancesManager.get_session_id(self._session)}#{self._id}"
|
return f"{InstancesManager.get_session_id(self._session)}#{self._id}"
|
||||||
|
|
||||||
|
def get_description(self) -> str:
|
||||||
|
pass
|
||||||
|
|
||||||
def get_parent_full_id(self) -> Optional[str]:
|
def get_parent_full_id(self) -> Optional[str]:
|
||||||
parent = self.get_parent()
|
parent = self.get_parent()
|
||||||
return parent.get_full_id() if parent else None
|
return parent.get_full_id() if parent else None
|
||||||
@@ -118,8 +121,21 @@ class BaseInstance:
|
|||||||
command: Command name or Command instance to bind to
|
command: Command name or Command instance to bind to
|
||||||
command_to_bind: Command to execute when the main command is triggered
|
command_to_bind: Command to execute when the main command is triggered
|
||||||
when: "before" or "after" - when to execute the bound command (default: "after")
|
when: "before" or "after" - when to execute the bound command (default: "after")
|
||||||
|
|
||||||
|
Note:
|
||||||
|
Duplicate bindings are automatically prevented using two mechanisms:
|
||||||
|
1. Check if the same binding already exists
|
||||||
"""
|
"""
|
||||||
command_name = command.name if hasattr(command, "name") else command
|
command_name = command.name if hasattr(command, "name") else command
|
||||||
|
|
||||||
|
# Protection 1: Check if this binding already exists to prevent duplicates
|
||||||
|
existing_bindings = self._bound_commands.get(command_name, [])
|
||||||
|
for existing in existing_bindings:
|
||||||
|
if existing.command.name == command_to_bind.name and existing.when == when:
|
||||||
|
# Binding already exists, don't add it again
|
||||||
|
return
|
||||||
|
|
||||||
|
# Add new binding
|
||||||
bound = BoundCommand(command=command_to_bind, when=when)
|
bound = BoundCommand(command=command_to_bind, when=when)
|
||||||
self._bound_commands.setdefault(command_name, []).append(bound)
|
self._bound_commands.setdefault(command_name, []).append(bound)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user