Update dependency graph: squares, all tasks, project clusters

- 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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-01-08 13:24:09 +01:00
parent 36b35e3cbe
commit 19f669f7d1
1 changed files with 159 additions and 45 deletions

View File

@ -594,10 +594,10 @@
</div>
<div id="dependency-graph"></div>
<div class="graph-legend">
<div class="legend-item"><span class="legend-dot" style="background:#22c55e"></span> Done</div>
<div class="legend-item"><span class="legend-dot" style="background:#3b82f6"></span> In Progress</div>
<div class="legend-item"><span class="legend-dot" style="background:#6b7280"></span> To Do</div>
<div class="legend-item"><span class="legend-dot" style="background:transparent;border:2px solid #94a3b8"></span> Border = Project</div>
<div class="legend-item"><span class="legend-dot" style="background:#22c55e;border-radius:2px"></span> Done</div>
<div class="legend-item"><span class="legend-dot" style="background:#3b82f6;border-radius:2px"></span> In Progress</div>
<div class="legend-item"><span class="legend-dot" style="background:#6b7280;border-radius:2px"></span> To Do</div>
<div class="legend-item"><span class="legend-dot" style="background:transparent;border:2px solid #94a3b8;border-radius:2px"></span> Border = Project</div>
</div>
<div class="graph-hint">Drag nodes to reposition • Double-click to unpin • Scroll to zoom • Click node for details</div>
</div>
@ -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 = '<div style="display:flex;align-items:center;justify-content:center;height:400px;color:#64748b;font-size:1rem;">No tasks with dependencies to display.</div>';
if (filteredTasks.length === 0) {
container.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:400px;color:#64748b;font-size:1rem;">No tasks to display.</div>';
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