Skip to content

Commit 96ed315

Browse files
committed
Enhance: Implement hierarchical radial graph layout with improved edge bundling
- Add layer-based radial positioning for node types (OUTCOME→EPIC→MILESTONE→FEATURE→TASK→BUG→IDEA) - Implement edge bundling system to prevent overlapping connections between same nodes - Simplify edge curves from cubic to quadratic bezier for smoother appearance - Enhance collision detection with dynamic radius based on node dimensions - Adjust force simulation strengths for clearer visual hierarchy - Reduce edge opacity and width for less visual clutter
1 parent 6380900 commit 96ed315

1 file changed

Lines changed: 114 additions & 140 deletions

File tree

packages/web/src/components/InteractiveGraphVisualization.tsx

Lines changed: 114 additions & 140 deletions
Original file line numberDiff line numberDiff line change
@@ -1802,70 +1802,61 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap
18021802
simulation.alpha(0).stop();
18031803
}
18041804

1805+
const getNodeLayer = (node: any) => {
1806+
const typeHierarchy: Record<string, number> = {
1807+
'OUTCOME': 0,
1808+
'EPIC': 1,
1809+
'MILESTONE': 2,
1810+
'FEATURE': 3,
1811+
'TASK': 4,
1812+
'BUG': 5,
1813+
'IDEA': 6
1814+
};
1815+
return typeHierarchy[node.type] || 4;
1816+
};
1817+
18051818
simulation
18061819
.force('link', d3.forceLink(validatedEdges)
18071820
.id((d: any) => d.id)
18081821
.distance((d: any) => {
1809-
const minDistance = Math.min(width, height) * 0.4; // 40% of screen size for minimum
1810-
const maxDistance = Math.min(width, height) * 0.6; // 60% of screen size for maximum
1811-
1812-
// Calculate current distance between nodes
1813-
const sourceNode = d.source;
1814-
const targetNode = d.target;
1815-
const currentDistance = Math.sqrt(
1816-
Math.pow(targetNode.x - sourceNode.x, 2) +
1817-
Math.pow(targetNode.y - sourceNode.y, 2)
1818-
);
1819-
1820-
// If current distance exceeds maximum, return maximum to create pulling force
1821-
if (currentDistance > maxDistance) {
1822-
return maxDistance;
1823-
}
1824-
1825-
// Otherwise use minimum as preferred distance
1826-
return minDistance;
1827-
})
1828-
.strength((d: any) => {
1829-
// Calculate current distance between nodes
1830-
const sourceNode = d.source;
1831-
const targetNode = d.target;
1832-
const currentDistance = Math.sqrt(
1833-
Math.pow(targetNode.x - sourceNode.x, 2) +
1834-
Math.pow(targetNode.y - sourceNode.y, 2)
1835-
);
1836-
1837-
const maxDistance = Math.min(width, height) * 0.6;
1838-
1839-
// Stronger force when edge is too long to create pulling effect
1840-
if (currentDistance > maxDistance) {
1841-
return 0.8; // Strong pulling force
1842-
}
1843-
1844-
// Normal strength otherwise
1845-
return 0.3;
1822+
const sourceLayer = getNodeLayer(d.source);
1823+
const targetLayer = getNodeLayer(d.target);
1824+
const layerDiff = Math.abs(sourceLayer - targetLayer);
1825+
return layerDiff > 0 ? 250 : 350;
18461826
})
1827+
.strength(0.3)
18471828
)
18481829
.force('charge', d3.forceManyBody()
1849-
.strength(-100) // Simple, consistent repulsion for all nodes
1850-
.distanceMax(200) // Reasonable influence range
1830+
.strength((d: any) => {
1831+
const layer = getNodeLayer(d);
1832+
return layer === 1 ? -1200 : -900;
1833+
})
1834+
.distanceMax(700)
18511835
)
1852-
.force('center', d3.forceCenter(centerX, centerY).strength(0.01)) // Minimal centering
1853-
.force('x', d3.forceX(centerX).strength(0.002)) // Extremely weak horizontal centering for maximum width
1854-
.force('y', d3.forceY(centerY).strength(0.002)) // Extremely weak vertical centering for maximum height
1855-
.force('collision', d3.forceCollide(90) // Sufficient collision radius to prevent overlap
1856-
.strength(0.7) // Moderate collision prevention
1857-
.iterations(2) // Fewer iterations for stability
1836+
.force('center', d3.forceCenter(centerX, centerY).strength(0.02))
1837+
.force('radial', d3.forceRadial((d: any) => {
1838+
const layer = getNodeLayer(d);
1839+
return layer * 180 + 100;
1840+
}, centerX, centerY).strength(0.15))
1841+
.force('x', d3.forceX(centerX).strength(0.005))
1842+
.force('y', d3.forceY(centerY).strength(0.005))
1843+
.force('collision', d3.forceCollide()
1844+
.radius((d: any) => {
1845+
const dims = getNodeDimensions(d);
1846+
return Math.max(dims.width, dims.height) / 2 + 40;
1847+
})
1848+
.strength(1)
1849+
.iterations(4)
18581850
)
1859-
// Add hierarchical attraction forces (Epic->Milestone, Feature->Task, etc.)
18601851
.force('hierarchy', d3.forceLink()
18611852
.id((d: any) => d.id)
18621853
.links(createHierarchicalLinks(nodes))
1863-
.distance((d: any) => d.distance || 250) // Much larger hierarchical distance
1864-
.strength((d: any) => d.strength || 0.05) // Very weak hierarchical strength
1854+
.distance((d: any) => d.distance || 400)
1855+
.strength((d: any) => d.strength || 0.02)
18651856
)
1866-
.alphaTarget(0.05) // Lower alpha target for calmer simulation
1867-
.alphaDecay(0.015) // Slightly slower decay for better collision resolution
1868-
.velocityDecay(0.4); // Add velocity decay for smoother movement
1857+
.alphaTarget(0.02)
1858+
.alphaDecay(0.025)
1859+
.velocityDecay(0.6)
18691860

18701861
// Filter edges based on visible nodes for performance
18711862
// Temporarily show ALL edges for debugging
@@ -1888,7 +1879,22 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap
18881879
}
18891880
};
18901881

1891-
// Helper: Calculate smooth curved path directly between node edges
1882+
// Helper: Count edges between same nodes for bundle routing
1883+
const edgeBundleMap = new Map<string, number>();
1884+
const edgeIndexMap = new Map<string, number>();
1885+
visibleEdges.forEach((edge: WorkItemEdge) => {
1886+
const sourceId = typeof edge.source === 'string' ? edge.source : (edge.source as any).id;
1887+
const targetId = typeof edge.target === 'string' ? edge.target : (edge.target as any).id;
1888+
const key1 = `${sourceId}-${targetId}`;
1889+
const key2 = `${targetId}-${sourceId}`;
1890+
const key = key1 < key2 ? key1 : key2;
1891+
1892+
const currentCount = edgeBundleMap.get(key) || 0;
1893+
edgeIndexMap.set(edge.id, currentCount);
1894+
edgeBundleMap.set(key, currentCount + 1);
1895+
});
1896+
1897+
// Helper: Calculate smooth curved path with anti-crossing routing
18921898
const linkArc = (d: any) => {
18931899
const dx = d.target.x - d.source.x;
18941900
const dy = d.target.y - d.source.y;
@@ -1916,49 +1922,39 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap
19161922
const tx = targetHandle.x;
19171923
const ty = targetHandle.y;
19181924

1925+
const bundleKey1 = `${d.source.id}-${d.target.id}`;
1926+
const bundleKey2 = `${d.target.id}-${d.source.id}`;
1927+
const bundleKey = bundleKey1 < bundleKey2 ? bundleKey1 : bundleKey2;
1928+
const bundleCount = edgeBundleMap.get(bundleKey) || 1;
1929+
const edgeIndex = edgeIndexMap.get(d.id) || 0;
1930+
19191931
const edgeHash = (d.source.id + d.target.id + (d.type || '')).split('').reduce((a: number, b: string) => {
19201932
a = ((a << 5) - a) + b.charCodeAt(0);
19211933
return a & a;
19221934
}, 0);
1923-
const curveOffset = ((Math.abs(edgeHash) % 60) - 30) * 3.5;
19241935

1925-
const distance = Math.sqrt(dx * dx + dy * dy);
1926-
const controlPointDistance = Math.min(distance * 0.6, 150);
1927-
1928-
let controlX1 = sx;
1929-
let controlY1 = sy;
1930-
let controlX2 = tx;
1931-
let controlY2 = ty;
1932-
1933-
if (sourceHandleSide === 'right') {
1934-
controlX1 = sx + controlPointDistance;
1935-
controlY1 = sy + curveOffset;
1936-
} else if (sourceHandleSide === 'left') {
1937-
controlX1 = sx - controlPointDistance;
1938-
controlY1 = sy + curveOffset;
1939-
} else if (sourceHandleSide === 'bottom') {
1940-
controlX1 = sx + curveOffset;
1941-
controlY1 = sy + controlPointDistance;
1942-
} else if (sourceHandleSide === 'top') {
1943-
controlX1 = sx + curveOffset;
1944-
controlY1 = sy - controlPointDistance;
1936+
let curveOffset = 0;
1937+
if (bundleCount > 1) {
1938+
const spreadRange = bundleCount * 50;
1939+
const step = spreadRange / (bundleCount - 1);
1940+
curveOffset = (edgeIndex * step) - (spreadRange / 2);
1941+
} else {
1942+
curveOffset = ((Math.abs(edgeHash) % 80) - 40) * 2.5;
19451943
}
19461944

1947-
if (targetHandleSide === 'right') {
1948-
controlX2 = tx + controlPointDistance;
1949-
controlY2 = ty - curveOffset;
1950-
} else if (targetHandleSide === 'left') {
1951-
controlX2 = tx - controlPointDistance;
1952-
controlY2 = ty - curveOffset;
1953-
} else if (targetHandleSide === 'bottom') {
1954-
controlX2 = tx - curveOffset;
1955-
controlY2 = ty + controlPointDistance;
1956-
} else if (targetHandleSide === 'top') {
1957-
controlX2 = tx - curveOffset;
1958-
controlY2 = ty - controlPointDistance;
1959-
}
1945+
const distance = Math.sqrt(dx * dx + dy * dy);
1946+
const controlPointDistance = Math.min(distance * 0.7, 200);
1947+
1948+
const angle = Math.atan2(dy, dx);
1949+
const perpAngle = angle + Math.PI / 2;
1950+
1951+
const midX = (sx + tx) / 2;
1952+
const midY = (sy + ty) / 2;
1953+
1954+
const controlX1 = midX + Math.cos(perpAngle) * curveOffset;
1955+
const controlY1 = midY + Math.sin(perpAngle) * curveOffset;
19601956

1961-
return `M${sx},${sy} C${controlX1},${controlY1} ${controlX2},${controlY2} ${tx},${ty}`;
1957+
return `M${sx},${sy} Q${controlX1},${controlY1} ${tx},${ty}`;
19621958
};
19631959

19641960
// Create edges FIRST (so they render under nodes)
@@ -1990,19 +1986,19 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap
19901986
return config.hexColor;
19911987
})
19921988
.attr('stroke-width', (d: WorkItemEdge) => {
1993-
// Much thicker for active dialog
19941989
if (editingEdge && editingEdge.edge && editingEdge.edge.id === d.id) {
19951990
return 12;
19961991
}
1997-
return 3;
1992+
return 2.5;
19981993
})
19991994
.attr('stroke-opacity', (d: WorkItemEdge) => {
2000-
// Full opacity for active dialog
20011995
if (editingEdge && editingEdge.edge && editingEdge.edge.id === d.id) {
20021996
return 1;
20031997
}
2004-
return 0.9;
1998+
return 0.75;
20051999
})
2000+
.attr('stroke-linecap', 'round')
2001+
.attr('stroke-linejoin', 'round')
20062002
.style('filter', (d: WorkItemEdge) => {
20072003
// Apply pulsing glow to edges with active dialogs
20082004
if (editingEdge && editingEdge.edge && editingEdge.edge.id === d.id) {
@@ -3550,64 +3546,42 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap
35503546
const tx = targetHandle.x;
35513547
const ty = targetHandle.y;
35523548

3549+
const bundleKey1 = `${d.source.id}-${d.target.id}`;
3550+
const bundleKey2 = `${d.target.id}-${d.source.id}`;
3551+
const bundleKey = bundleKey1 < bundleKey2 ? bundleKey1 : bundleKey2;
3552+
const bundleCount = edgeBundleMap.get(bundleKey) || 1;
3553+
const edgeIndex = edgeIndexMap.get(d.id) || 0;
3554+
35533555
const edgeHash = (d.source.id + d.target.id + (d.type || '')).split('').reduce((a: number, b: string) => {
35543556
a = ((a << 5) - a) + b.charCodeAt(0);
35553557
return a & a;
35563558
}, 0);
3557-
const curveOffset = ((Math.abs(edgeHash) % 60) - 30) * 3.5;
35583559

3559-
const distance = Math.sqrt(dx * dx + dy * dy);
3560-
const controlPointDistance = Math.min(distance * 0.6, 150);
3561-
3562-
let controlX1 = sx;
3563-
let controlY1 = sy;
3564-
let controlX2 = tx;
3565-
let controlY2 = ty;
3566-
3567-
if (sourceHandleSide === 'right') {
3568-
controlX1 = sx + controlPointDistance;
3569-
controlY1 = sy + curveOffset;
3570-
} else if (sourceHandleSide === 'left') {
3571-
controlX1 = sx - controlPointDistance;
3572-
controlY1 = sy + curveOffset;
3573-
} else if (sourceHandleSide === 'bottom') {
3574-
controlX1 = sx + curveOffset;
3575-
controlY1 = sy + controlPointDistance;
3576-
} else if (sourceHandleSide === 'top') {
3577-
controlX1 = sx + curveOffset;
3578-
controlY1 = sy - controlPointDistance;
3560+
let curveOffset = 0;
3561+
if (bundleCount > 1) {
3562+
const spreadRange = bundleCount * 50;
3563+
const step = spreadRange / (bundleCount - 1);
3564+
curveOffset = (edgeIndex * step) - (spreadRange / 2);
3565+
} else {
3566+
curveOffset = ((Math.abs(edgeHash) % 80) - 40) * 2.5;
35793567
}
35803568

3581-
if (targetHandleSide === 'right') {
3582-
controlX2 = tx + controlPointDistance;
3583-
controlY2 = ty - curveOffset;
3584-
} else if (targetHandleSide === 'left') {
3585-
controlX2 = tx - controlPointDistance;
3586-
controlY2 = ty - curveOffset;
3587-
} else if (targetHandleSide === 'bottom') {
3588-
controlX2 = tx - curveOffset;
3589-
controlY2 = ty + controlPointDistance;
3590-
} else if (targetHandleSide === 'top') {
3591-
controlX2 = tx - curveOffset;
3592-
controlY2 = ty - controlPointDistance;
3593-
}
3569+
const distance = Math.sqrt(dx * dx + dy * dy);
3570+
const angle = Math.atan2(dy, dx);
3571+
const perpAngle = angle + Math.PI / 2;
3572+
3573+
const midX = (sx + tx) / 2;
3574+
const midY = (sy + ty) / 2;
3575+
3576+
const controlX = midX + Math.cos(perpAngle) * curveOffset;
3577+
const controlY = midY + Math.sin(perpAngle) * curveOffset;
35943578

35953579
const t = 0.5;
3596-
const curveX = Math.pow(1 - t, 3) * sx +
3597-
3 * Math.pow(1 - t, 2) * t * controlX1 +
3598-
3 * (1 - t) * Math.pow(t, 2) * controlX2 +
3599-
Math.pow(t, 3) * tx;
3600-
const curveY = Math.pow(1 - t, 3) * sy +
3601-
3 * Math.pow(1 - t, 2) * t * controlY1 +
3602-
3 * (1 - t) * Math.pow(t, 2) * controlY2 +
3603-
Math.pow(t, 3) * ty;
3604-
3605-
const tangentX = 3 * Math.pow(1 - t, 2) * (controlX1 - sx) +
3606-
6 * (1 - t) * t * (controlX2 - controlX1) +
3607-
3 * Math.pow(t, 2) * (tx - controlX2);
3608-
const tangentY = 3 * Math.pow(1 - t, 2) * (controlY1 - sy) +
3609-
6 * (1 - t) * t * (controlY2 - controlY1) +
3610-
3 * Math.pow(t, 2) * (ty - controlY2);
3580+
const curveX = Math.pow(1 - t, 2) * sx + 2 * (1 - t) * t * controlX + Math.pow(t, 2) * tx;
3581+
const curveY = Math.pow(1 - t, 2) * sy + 2 * (1 - t) * t * controlY + Math.pow(t, 2) * ty;
3582+
3583+
const tangentX = 2 * (1 - t) * (controlX - sx) + 2 * t * (tx - controlX);
3584+
const tangentY = 2 * (1 - t) * (controlY - sy) + 2 * t * (ty - controlY);
36113585

36123586
const tangentLength = Math.sqrt(tangentX * tangentX + tangentY * tangentY);
36133587
const normalX = -tangentY / tangentLength;

0 commit comments

Comments
 (0)