Skip to content

Commit 8452a5d

Browse files
committed
Add radar chart for task status distribution visualization
- Create RadarChart component with D3.js for interactive radar visualization - Add TaskDistributionRadar wrapper with real GraphQL data integration - Include zoom controls (-, +, reset) matching existing pie chart pattern - Display task status with color-coded circles and dynamic count labels - Integrate radar chart into ListView and Analytics pages - Use clean, minimal styling with status-based colored data points
1 parent aa85673 commit 8452a5d

5 files changed

Lines changed: 464 additions & 2 deletions

File tree

packages/web/src/components/ListView.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
import { useQuery } from '@apollo/client';
3434
import { useAuth } from '../contexts/AuthContext';
3535
import { useGraph } from '../contexts/GraphContext';
36+
import { TaskDistributionRadar } from './TaskDistributionRadar';
3637
import { GET_WORK_ITEMS } from '../lib/queries';
3738
import { EditNodeModal } from './EditNodeModal';
3839
import { DeleteNodeModal } from './DeleteNodeModal';
@@ -1576,6 +1577,17 @@ export function ListView() {
15761577
</div>
15771578

15781579
</div>
1580+
1581+
{/* Fourth Row - Task Distribution Radar */}
1582+
<div className="mt-12">
1583+
<div className="flex flex-col items-center">
1584+
<h2 className="text-2xl font-bold text-white mb-4">Task Category Distribution</h2>
1585+
<div className="w-full">
1586+
<TaskDistributionRadar showLegend={false} />
1587+
</div>
1588+
</div>
1589+
</div>
1590+
15791591
</div>
15801592
);
15811593

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { useEffect, useRef } from 'react';
2+
import * as d3 from 'd3';
3+
4+
interface RadarDataPoint {
5+
axis: string;
6+
value: number;
7+
maxValue: number;
8+
color?: string;
9+
}
10+
11+
interface RadarChartProps {
12+
data: RadarDataPoint[];
13+
width?: number;
14+
height?: number;
15+
margin?: number;
16+
levels?: number;
17+
className?: string;
18+
}
19+
20+
export function RadarChart({
21+
data,
22+
width = 300,
23+
height = 300,
24+
margin = 50,
25+
levels = 5,
26+
className = ''
27+
}: RadarChartProps) {
28+
const svgRef = useRef<SVGSVGElement>(null);
29+
30+
useEffect(() => {
31+
if (!svgRef.current || !data.length) return;
32+
33+
const svg = d3.select(svgRef.current);
34+
svg.selectAll('*').remove();
35+
36+
const radius = Math.min(width - 2 * margin, height - 2 * margin) / 2;
37+
const centerX = width / 2;
38+
const centerY = height / 2;
39+
40+
const angleSlice = (Math.PI * 2) / data.length;
41+
42+
// Create the container group
43+
const g = svg.append('g')
44+
.attr('transform', `translate(${centerX},${centerY})`);
45+
46+
// Create the circular grid lines
47+
const levelFactor = radius / levels;
48+
49+
for (let level = 1; level <= levels; level++) {
50+
g.append('circle')
51+
.attr('r', levelFactor * level)
52+
.attr('fill', 'none')
53+
.attr('stroke', 'white')
54+
.attr('stroke-width', 1)
55+
.attr('opacity', 0.2);
56+
}
57+
58+
// Create the axis lines
59+
data.forEach((d, i) => {
60+
const angle = i * angleSlice;
61+
const x = Math.cos(angle - Math.PI / 2) * radius;
62+
const y = Math.sin(angle - Math.PI / 2) * radius;
63+
64+
g.append('line')
65+
.attr('x1', 0)
66+
.attr('y1', 0)
67+
.attr('x2', x)
68+
.attr('y2', y)
69+
.attr('stroke', 'white')
70+
.attr('stroke-width', 1)
71+
.attr('opacity', 0.3);
72+
73+
// Add axis labels with rounded rectangle background
74+
const labelX = Math.cos(angle - Math.PI / 2) * (radius + 30);
75+
const labelY = Math.sin(angle - Math.PI / 2) * (radius + 30);
76+
77+
// Create the label text first to measure its size
78+
const labelText = `${d.axis} (${d.value})`;
79+
const tempText = g.append('text')
80+
.attr('x', labelX)
81+
.attr('y', labelY)
82+
.attr('text-anchor', 'middle')
83+
.attr('dominant-baseline', 'middle')
84+
.attr('font-size', '13px')
85+
.attr('font-weight', '500')
86+
.attr('opacity', 0)
87+
.text(labelText);
88+
89+
// Get text dimensions
90+
const textBBox = (tempText.node() as SVGTextElement).getBBox();
91+
tempText.remove();
92+
93+
// Add rounded rectangle background
94+
const padding = 6;
95+
g.append('rect')
96+
.attr('x', labelX - textBBox.width / 2 - padding)
97+
.attr('y', labelY - textBBox.height / 2 - padding)
98+
.attr('width', textBBox.width + padding * 2)
99+
.attr('height', textBBox.height + padding * 2)
100+
.attr('rx', 6)
101+
.attr('ry', 6)
102+
.attr('fill', 'rgba(31, 41, 55, 0.9)')
103+
.attr('stroke', d.color || 'rgb(99, 102, 241)')
104+
.attr('stroke-width', 1);
105+
106+
// Add the actual text
107+
g.append('text')
108+
.attr('x', labelX)
109+
.attr('y', labelY)
110+
.attr('text-anchor', 'middle')
111+
.attr('dominant-baseline', 'middle')
112+
.attr('font-size', '13px')
113+
.attr('font-weight', '500')
114+
.attr('fill', 'white')
115+
.text(labelText);
116+
});
117+
118+
// Create the radar area
119+
const radarLine = d3.line<RadarDataPoint>()
120+
.x((d, i) => {
121+
const angle = i * angleSlice;
122+
const value = Math.max(0, d.value);
123+
const normalizedValue = d.maxValue > 0 ? value / d.maxValue : 0;
124+
return Math.cos(angle - Math.PI / 2) * (normalizedValue * radius);
125+
})
126+
.y((d, i) => {
127+
const angle = i * angleSlice;
128+
const value = Math.max(0, d.value);
129+
const normalizedValue = d.maxValue > 0 ? value / d.maxValue : 0;
130+
return Math.sin(angle - Math.PI / 2) * (normalizedValue * radius);
131+
})
132+
.curve(d3.curveLinearClosed);
133+
134+
// Add the radar area
135+
g.append('path')
136+
.datum(data)
137+
.attr('d', radarLine)
138+
.attr('fill', 'rgba(156, 163, 175, 0.1)')
139+
.attr('stroke', 'rgb(156, 163, 175)')
140+
.attr('stroke-width', 2);
141+
142+
// Add data points with status-based colors
143+
data.forEach((d, i) => {
144+
const angle = i * angleSlice;
145+
const value = Math.max(0, d.value);
146+
const normalizedValue = d.maxValue > 0 ? value / d.maxValue : 0;
147+
const x = Math.cos(angle - Math.PI / 2) * (normalizedValue * radius);
148+
const y = Math.sin(angle - Math.PI / 2) * (normalizedValue * radius);
149+
150+
const pointColor = d.color || 'rgb(99, 102, 241)';
151+
const strokeColor = d3.color(pointColor)?.darker(0.3)?.toString() || pointColor;
152+
153+
g.append('circle')
154+
.attr('cx', x)
155+
.attr('cy', y)
156+
.attr('r', 6)
157+
.attr('fill', pointColor)
158+
.attr('stroke', strokeColor)
159+
.attr('stroke-width', 2);
160+
});
161+
162+
// Add level labels with actual numbers
163+
for (let level = 1; level <= levels; level++) {
164+
const maxDataValue = Math.max(...data.map(d => d.maxValue), 1);
165+
const value = Math.round((level / levels) * maxDataValue);
166+
g.append('text')
167+
.attr('x', 5)
168+
.attr('y', -(levelFactor * level) + 3)
169+
.attr('font-size', '11px')
170+
.attr('font-weight', '400')
171+
.attr('fill', 'white')
172+
.attr('opacity', 0.7)
173+
.text(`${value}`);
174+
}
175+
176+
}, [data, width, height, margin, levels]);
177+
178+
return (
179+
<div className={className}>
180+
<svg
181+
ref={svgRef}
182+
width={width}
183+
height={height}
184+
style={{ background: 'transparent' }}
185+
/>
186+
</div>
187+
);
188+
}

0 commit comments

Comments
 (0)