From 19f669f7d13a9d23c818843d0ebf33caa3966f72 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 8 Jan 2026 13:24:09 +0100 Subject: [PATCH] Update dependency graph: squares, all tasks, project clusters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change nodes from circles to squares (rounded corners) - Show ALL tasks, not just those with dependencies - Group tasks by project using cluster force simulation - Add project name labels hovering above each cluster - Labels show project name and task count - Dynamic height based on number of projects - Legend updated to show square shapes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/aggregator/web/index.html | 204 ++++++++++++++++++++++++++-------- 1 file changed, 159 insertions(+), 45 deletions(-) diff --git a/src/aggregator/web/index.html b/src/aggregator/web/index.html index 7b03150..b22ed38 100644 --- a/src/aggregator/web/index.html +++ b/src/aggregator/web/index.html @@ -594,10 +594,10 @@
-
Done
-
In Progress
-
To Do
-
Border = Project
+
Done
+
In Progress
+
To Do
+
Border = Project
Drag nodes to reposition • Double-click to unpin • Scroll to zoom • Click node for details
@@ -1369,7 +1369,7 @@ const container = document.getElementById('dependency-graph'); container.innerHTML = ''; - // Filter tasks + // Filter tasks - show ALL tasks, not just those with dependencies let filteredTasks = selectedProject ? tasks.filter(t => t.projectName === selectedProject) : tasks; @@ -1381,32 +1381,47 @@ }); } - // Get tasks with dependencies - const taskIds = new Set(filteredTasks.map(t => t.id)); - const tasksWithDeps = filteredTasks.filter(t => - (t.dependencies && t.dependencies.length > 0) || - filteredTasks.some(other => other.dependencies && other.dependencies.includes(t.id)) - ); - - if (tasksWithDeps.length === 0) { - container.innerHTML = '
No tasks with dependencies to display.
'; + if (filteredTasks.length === 0) { + container.innerHTML = '
No tasks to display.
'; return; } - // Build nodes and links - const nodes = tasksWithDeps.map(t => ({ - id: t.id, - title: t.title, - status: t.status, - projectName: t.projectName, - projectColor: t.projectColor, - projectPath: t.projectPath, - priority: t.priority - })); + // Group tasks by project + const projectGroups = new Map(); + filteredTasks.forEach(t => { + if (!projectGroups.has(t.projectName)) { + projectGroups.set(t.projectName, { + name: t.projectName, + color: t.projectColor, + tasks: [] + }); + } + projectGroups.get(t.projectName).tasks.push(t); + }); + // Create cluster centers for each project + const projectList = Array.from(projectGroups.values()); + const numProjects = projectList.length; + + // Build nodes with cluster assignment + const nodes = filteredTasks.map(t => { + const projectIndex = projectList.findIndex(p => p.name === t.projectName); + return { + id: t.id, + title: t.title, + status: t.status, + projectName: t.projectName, + projectColor: t.projectColor, + projectPath: t.projectPath, + priority: t.priority, + cluster: projectIndex + }; + }); + + // Build links (dependencies) const nodeIds = new Set(nodes.map(n => n.id)); const links = []; - tasksWithDeps.forEach(t => { + filteredTasks.forEach(t => { if (t.dependencies) { t.dependencies.forEach(depId => { if (nodeIds.has(depId)) { @@ -1418,7 +1433,8 @@ // Set up SVG const width = container.clientWidth || 800; - const height = 500; + const height = Math.max(600, numProjects * 150); + container.style.minHeight = height + 'px'; const svg = d3.select(container) .append('svg') @@ -1436,7 +1452,7 @@ svg.append('defs').append('marker') .attr('id', 'arrowhead') .attr('viewBox', '-0 -5 10 10') - .attr('refX', 25) + .attr('refX', 22) .attr('refY', 0) .attr('orient', 'auto') .attr('markerWidth', 6) @@ -1445,13 +1461,39 @@ .attr('d', 'M 0,-5 L 10,0 L 0,5') .attr('fill', '#64748b'); + // Calculate cluster centers in a grid layout + const cols = Math.ceil(Math.sqrt(numProjects)); + const rows = Math.ceil(numProjects / cols); + const clusterCenters = projectList.map((p, i) => { + const col = i % cols; + const row = Math.floor(i / cols); + return { + x: (col + 0.5) * (width / cols), + y: (row + 0.5) * (height / rows) + 30 + }; + }); + + // Custom cluster force to group nodes by project + function clusterForce(alpha) { + for (const node of nodes) { + const center = clusterCenters[node.cluster]; + if (center) { + node.vx -= (node.x - center.x) * alpha * 0.1; + node.vy -= (node.y - center.y) * alpha * 0.1; + } + } + } + // Create force simulation if (graphSimulation) graphSimulation.stop(); graphSimulation = d3.forceSimulation(nodes) - .force('link', d3.forceLink(links).id(d => d.id).distance(150)) - .force('charge', d3.forceManyBody().strength(-400)) - .force('center', d3.forceCenter(width / 2, height / 2)) - .force('collision', d3.forceCollide().radius(60)); + .force('link', d3.forceLink(links).id(d => d.id).distance(80).strength(0.5)) + .force('charge', d3.forceManyBody().strength(-200)) + .force('collision', d3.forceCollide().radius(45)) + .force('cluster', clusterForce); + + // Draw project group backgrounds and labels + const projectLabels = g.append('g').attr('class', 'project-labels'); // Draw links const link = g.append('g') @@ -1473,17 +1515,28 @@ .on('drag', dragged) .on('end', dragended)); - // Node circles - node.append('circle') - .attr('r', 20) + // Node squares (rect instead of circle) + const nodeSize = 32; + node.append('rect') + .attr('x', -nodeSize / 2) + .attr('y', -nodeSize / 2) + .attr('width', nodeSize) + .attr('height', nodeSize) + .attr('rx', 4) + .attr('ry', 4) .attr('fill', d => getStatusColor(d.status)) .attr('stroke', d => d.projectColor) .attr('stroke-width', 3); - // High priority indicator + // High priority indicator (outer square) node.filter(d => d.priority === 'high') - .append('circle') - .attr('r', 24) + .append('rect') + .attr('x', -nodeSize / 2 - 4) + .attr('y', -nodeSize / 2 - 4) + .attr('width', nodeSize + 8) + .attr('height', nodeSize + 8) + .attr('rx', 6) + .attr('ry', 6) .attr('fill', 'none') .attr('stroke', '#ef4444') .attr('stroke-width', 2) @@ -1501,10 +1554,10 @@ // Title below node node.append('text') .attr('text-anchor', 'middle') - .attr('dy', '35px') + .attr('dy', '28px') .attr('fill', '#94a3b8') - .attr('font-size', '11px') - .text(d => d.title.length > 20 ? d.title.slice(0, 18) + '...' : d.title); + .attr('font-size', '9px') + .text(d => d.title.length > 15 ? d.title.slice(0, 13) + '...' : d.title); // Click to show task details node.on('click', (event, d) => { @@ -1520,14 +1573,16 @@ // Hover effects node.on('mouseenter', function(event, d) { - d3.select(this).select('circle').transition().duration(150).attr('r', 25); + d3.select(this).selectAll('rect').transition().duration(150) + .attr('transform', 'scale(1.15)'); // Highlight connected links link.attr('stroke', l => (l.source.id === d.id || l.target.id === d.id) ? '#3b82f6' : '#64748b') .attr('stroke-width', l => (l.source.id === d.id || l.target.id === d.id) ? 3 : 2); }); node.on('mouseleave', function(event, d) { - d3.select(this).select('circle').transition().duration(150).attr('r', 20); + d3.select(this).selectAll('rect').transition().duration(150) + .attr('transform', 'scale(1)'); link.attr('stroke', '#64748b').attr('stroke-width', 2); }); @@ -1539,8 +1594,67 @@ .attr('x2', d => d.target.x) .attr('y2', d => d.target.y); node.attr('transform', d => `translate(${d.x},${d.y})`); + + // Update project labels to center of their clusters + updateProjectLabels(); }); + function updateProjectLabels() { + // Calculate centroid of each project's nodes + const centroids = projectList.map((project, i) => { + const projectNodes = nodes.filter(n => n.cluster === i); + if (projectNodes.length === 0) return null; + const cx = d3.mean(projectNodes, d => d.x); + const cy = d3.mean(projectNodes, d => d.y); + const minY = d3.min(projectNodes, d => d.y); + return { name: project.name, color: project.color, x: cx, y: minY - 35, count: projectNodes.length }; + }).filter(c => c !== null); + + // Update or create labels + const labels = projectLabels.selectAll('g.project-label') + .data(centroids, d => d.name); + + const labelsEnter = labels.enter() + .append('g') + .attr('class', 'project-label'); + + // Background rect for label + labelsEnter.append('rect') + .attr('rx', 4) + .attr('ry', 4) + .attr('fill', d => d.color) + .attr('opacity', 0.9); + + // Label text + labelsEnter.append('text') + .attr('text-anchor', 'middle') + .attr('fill', 'white') + .attr('font-size', '12px') + .attr('font-weight', 'bold') + .attr('dy', '0.35em'); + + // Merge and update + const labelsMerged = labelsEnter.merge(labels); + + labelsMerged.attr('transform', d => `translate(${d.x},${d.y})`); + + labelsMerged.select('text') + .text(d => `${d.name} (${d.count})`); + + // Size background to fit text + labelsMerged.each(function(d) { + const text = d3.select(this).select('text'); + const bbox = text.node().getBBox(); + d3.select(this).select('rect') + .attr('x', bbox.x - 8) + .attr('y', bbox.y - 4) + .attr('width', bbox.width + 16) + .attr('height', bbox.height + 8); + }); + + labels.exit().remove(); + } + // Drag functions function dragstarted(event, d) { if (!event.active) graphSimulation.alphaTarget(0.3).restart(); @@ -1558,16 +1672,16 @@ // Keep pinned after drag } - // Initial zoom to fit + // Initial zoom to fit after simulation settles setTimeout(() => { const bounds = g.node().getBBox(); if (bounds.width > 0) { - const scale = Math.min(0.9, Math.min(width / (bounds.width + 100), height / (bounds.height + 100))); + const scale = Math.min(0.85, Math.min(width / (bounds.width + 100), height / (bounds.height + 100))); const tx = width / 2 - (bounds.x + bounds.width / 2) * scale; const ty = height / 2 - (bounds.y + bounds.height / 2) * scale; svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale)); } - }, 500); + }, 1000); } // Initial connection