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:
parent
36b35e3cbe
commit
19f669f7d1
|
|
@ -594,10 +594,10 @@
|
||||||
</div>
|
</div>
|
||||||
<div id="dependency-graph"></div>
|
<div id="dependency-graph"></div>
|
||||||
<div class="graph-legend">
|
<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:#22c55e;border-radius:2px"></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:#3b82f6;border-radius:2px"></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:#6b7280;border-radius:2px"></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:transparent;border:2px solid #94a3b8;border-radius:2px"></span> Border = Project</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="graph-hint">Drag nodes to reposition • Double-click to unpin • Scroll to zoom • Click node for details</div>
|
<div class="graph-hint">Drag nodes to reposition • Double-click to unpin • Scroll to zoom • Click node for details</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1369,7 +1369,7 @@
|
||||||
const container = document.getElementById('dependency-graph');
|
const container = document.getElementById('dependency-graph');
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
|
||||||
// Filter tasks
|
// Filter tasks - show ALL tasks, not just those with dependencies
|
||||||
let filteredTasks = selectedProject
|
let filteredTasks = selectedProject
|
||||||
? tasks.filter(t => t.projectName === selectedProject)
|
? tasks.filter(t => t.projectName === selectedProject)
|
||||||
: tasks;
|
: tasks;
|
||||||
|
|
@ -1381,32 +1381,47 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get tasks with dependencies
|
if (filteredTasks.length === 0) {
|
||||||
const taskIds = new Set(filteredTasks.map(t => t.id));
|
container.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:400px;color:#64748b;font-size:1rem;">No tasks to display.</div>';
|
||||||
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>';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build nodes and links
|
// Group tasks by project
|
||||||
const nodes = tasksWithDeps.map(t => ({
|
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,
|
id: t.id,
|
||||||
title: t.title,
|
title: t.title,
|
||||||
status: t.status,
|
status: t.status,
|
||||||
projectName: t.projectName,
|
projectName: t.projectName,
|
||||||
projectColor: t.projectColor,
|
projectColor: t.projectColor,
|
||||||
projectPath: t.projectPath,
|
projectPath: t.projectPath,
|
||||||
priority: t.priority
|
priority: t.priority,
|
||||||
}));
|
cluster: projectIndex
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build links (dependencies)
|
||||||
const nodeIds = new Set(nodes.map(n => n.id));
|
const nodeIds = new Set(nodes.map(n => n.id));
|
||||||
const links = [];
|
const links = [];
|
||||||
tasksWithDeps.forEach(t => {
|
filteredTasks.forEach(t => {
|
||||||
if (t.dependencies) {
|
if (t.dependencies) {
|
||||||
t.dependencies.forEach(depId => {
|
t.dependencies.forEach(depId => {
|
||||||
if (nodeIds.has(depId)) {
|
if (nodeIds.has(depId)) {
|
||||||
|
|
@ -1418,7 +1433,8 @@
|
||||||
|
|
||||||
// Set up SVG
|
// Set up SVG
|
||||||
const width = container.clientWidth || 800;
|
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)
|
const svg = d3.select(container)
|
||||||
.append('svg')
|
.append('svg')
|
||||||
|
|
@ -1436,7 +1452,7 @@
|
||||||
svg.append('defs').append('marker')
|
svg.append('defs').append('marker')
|
||||||
.attr('id', 'arrowhead')
|
.attr('id', 'arrowhead')
|
||||||
.attr('viewBox', '-0 -5 10 10')
|
.attr('viewBox', '-0 -5 10 10')
|
||||||
.attr('refX', 25)
|
.attr('refX', 22)
|
||||||
.attr('refY', 0)
|
.attr('refY', 0)
|
||||||
.attr('orient', 'auto')
|
.attr('orient', 'auto')
|
||||||
.attr('markerWidth', 6)
|
.attr('markerWidth', 6)
|
||||||
|
|
@ -1445,13 +1461,39 @@
|
||||||
.attr('d', 'M 0,-5 L 10,0 L 0,5')
|
.attr('d', 'M 0,-5 L 10,0 L 0,5')
|
||||||
.attr('fill', '#64748b');
|
.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
|
// Create force simulation
|
||||||
if (graphSimulation) graphSimulation.stop();
|
if (graphSimulation) graphSimulation.stop();
|
||||||
graphSimulation = d3.forceSimulation(nodes)
|
graphSimulation = d3.forceSimulation(nodes)
|
||||||
.force('link', d3.forceLink(links).id(d => d.id).distance(150))
|
.force('link', d3.forceLink(links).id(d => d.id).distance(80).strength(0.5))
|
||||||
.force('charge', d3.forceManyBody().strength(-400))
|
.force('charge', d3.forceManyBody().strength(-200))
|
||||||
.force('center', d3.forceCenter(width / 2, height / 2))
|
.force('collision', d3.forceCollide().radius(45))
|
||||||
.force('collision', d3.forceCollide().radius(60));
|
.force('cluster', clusterForce);
|
||||||
|
|
||||||
|
// Draw project group backgrounds and labels
|
||||||
|
const projectLabels = g.append('g').attr('class', 'project-labels');
|
||||||
|
|
||||||
// Draw links
|
// Draw links
|
||||||
const link = g.append('g')
|
const link = g.append('g')
|
||||||
|
|
@ -1473,17 +1515,28 @@
|
||||||
.on('drag', dragged)
|
.on('drag', dragged)
|
||||||
.on('end', dragended));
|
.on('end', dragended));
|
||||||
|
|
||||||
// Node circles
|
// Node squares (rect instead of circle)
|
||||||
node.append('circle')
|
const nodeSize = 32;
|
||||||
.attr('r', 20)
|
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('fill', d => getStatusColor(d.status))
|
||||||
.attr('stroke', d => d.projectColor)
|
.attr('stroke', d => d.projectColor)
|
||||||
.attr('stroke-width', 3);
|
.attr('stroke-width', 3);
|
||||||
|
|
||||||
// High priority indicator
|
// High priority indicator (outer square)
|
||||||
node.filter(d => d.priority === 'high')
|
node.filter(d => d.priority === 'high')
|
||||||
.append('circle')
|
.append('rect')
|
||||||
.attr('r', 24)
|
.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('fill', 'none')
|
||||||
.attr('stroke', '#ef4444')
|
.attr('stroke', '#ef4444')
|
||||||
.attr('stroke-width', 2)
|
.attr('stroke-width', 2)
|
||||||
|
|
@ -1501,10 +1554,10 @@
|
||||||
// Title below node
|
// Title below node
|
||||||
node.append('text')
|
node.append('text')
|
||||||
.attr('text-anchor', 'middle')
|
.attr('text-anchor', 'middle')
|
||||||
.attr('dy', '35px')
|
.attr('dy', '28px')
|
||||||
.attr('fill', '#94a3b8')
|
.attr('fill', '#94a3b8')
|
||||||
.attr('font-size', '11px')
|
.attr('font-size', '9px')
|
||||||
.text(d => d.title.length > 20 ? d.title.slice(0, 18) + '...' : d.title);
|
.text(d => d.title.length > 15 ? d.title.slice(0, 13) + '...' : d.title);
|
||||||
|
|
||||||
// Click to show task details
|
// Click to show task details
|
||||||
node.on('click', (event, d) => {
|
node.on('click', (event, d) => {
|
||||||
|
|
@ -1520,14 +1573,16 @@
|
||||||
|
|
||||||
// Hover effects
|
// Hover effects
|
||||||
node.on('mouseenter', function(event, d) {
|
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
|
// Highlight connected links
|
||||||
link.attr('stroke', l => (l.source.id === d.id || l.target.id === d.id) ? '#3b82f6' : '#64748b')
|
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);
|
.attr('stroke-width', l => (l.source.id === d.id || l.target.id === d.id) ? 3 : 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
node.on('mouseleave', function(event, d) {
|
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);
|
link.attr('stroke', '#64748b').attr('stroke-width', 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1539,8 +1594,67 @@
|
||||||
.attr('x2', d => d.target.x)
|
.attr('x2', d => d.target.x)
|
||||||
.attr('y2', d => d.target.y);
|
.attr('y2', d => d.target.y);
|
||||||
node.attr('transform', d => `translate(${d.x},${d.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
|
// Drag functions
|
||||||
function dragstarted(event, d) {
|
function dragstarted(event, d) {
|
||||||
if (!event.active) graphSimulation.alphaTarget(0.3).restart();
|
if (!event.active) graphSimulation.alphaTarget(0.3).restart();
|
||||||
|
|
@ -1558,16 +1672,16 @@
|
||||||
// Keep pinned after drag
|
// Keep pinned after drag
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial zoom to fit
|
// Initial zoom to fit after simulation settles
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const bounds = g.node().getBBox();
|
const bounds = g.node().getBBox();
|
||||||
if (bounds.width > 0) {
|
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 tx = width / 2 - (bounds.x + bounds.width / 2) * scale;
|
||||||
const ty = height / 2 - (bounds.y + bounds.height / 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));
|
svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial connection
|
// Initial connection
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue