Skip to content

Commit c2c71e0

Browse files
pfillion42claude
andcommitted
refactor: replace bar chart with SVG line chart for usage stats
Switch UsageChart from CSS bar chart to SVG line chart with area fill, data points with tooltips, grid lines, and axis labels. Better suited for visualizing temporal trends. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d7720b5 commit c2c71e0

2 files changed

Lines changed: 177 additions & 83 deletions

File tree

client/src/components/UsageChart.tsx

Lines changed: 169 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ interface UsageChartProps {
55
accesses: UsageDataPoint[];
66
}
77

8+
const CHART_HEIGHT = 140;
9+
const CHART_PADDING = { top: 10, right: 10, bottom: 30, left: 40 };
10+
811
export function UsageChart({ creations, accesses }: UsageChartProps) {
912
// Fusionner toutes les dates uniques et trier par ordre ASC
1013
const allDates = Array.from(new Set([
@@ -23,7 +26,7 @@ export function UsageChart({ creations, accesses }: UsageChartProps) {
2326
...accesses.map(a => a.count),
2427
);
2528

26-
// Limiter a 30 barres visibles (les plus recentes)
29+
// Limiter a 30 points visibles (les plus recents)
2730
const visibleDates = allDates.slice(-30);
2831

2932
if (visibleDates.length === 0) {
@@ -34,112 +37,201 @@ export function UsageChart({ creations, accesses }: UsageChartProps) {
3437
);
3538
}
3639

40+
const drawWidth = 100 - CHART_PADDING.left - CHART_PADDING.right;
41+
const drawHeight = CHART_HEIGHT - CHART_PADDING.top - CHART_PADDING.bottom;
42+
43+
// Calculer les positions X et Y pour chaque point
44+
function getX(i: number): number {
45+
if (visibleDates.length === 1) return CHART_PADDING.left + drawWidth / 2;
46+
return CHART_PADDING.left + (i / (visibleDates.length - 1)) * drawWidth;
47+
}
48+
49+
function getY(count: number): number {
50+
return CHART_PADDING.top + drawHeight - (count / maxCount) * drawHeight;
51+
}
52+
53+
// Construire les paths SVG pour les 2 series
54+
function buildPath(dataMap: Map<string, number>): string {
55+
return visibleDates.map((date, i) => {
56+
const count = dataMap.get(date) || 0;
57+
const x = getX(i);
58+
const y = getY(count);
59+
return `${i === 0 ? 'M' : 'L'} ${x} ${y}`;
60+
}).join(' ');
61+
}
62+
63+
// Construire le path de l'aire sous la courbe
64+
function buildAreaPath(dataMap: Map<string, number>): string {
65+
const linePath = buildPath(dataMap);
66+
const lastX = getX(visibleDates.length - 1);
67+
const firstX = getX(0);
68+
const baseY = CHART_PADDING.top + drawHeight;
69+
return `${linePath} L ${lastX} ${baseY} L ${firstX} ${baseY} Z`;
70+
}
71+
72+
const creationPath = buildPath(creationMap);
73+
const accessPath = buildPath(accessMap);
74+
const creationAreaPath = buildAreaPath(creationMap);
75+
const accessAreaPath = buildAreaPath(accessMap);
76+
77+
// Graduations Y (3-4 niveaux)
78+
const yTicks: number[] = [];
79+
const step = Math.ceil(maxCount / 3);
80+
for (let v = 0; v <= maxCount; v += step) {
81+
yTicks.push(v);
82+
}
83+
if (!yTicks.includes(maxCount)) yTicks.push(maxCount);
84+
85+
// Labels X (afficher max ~6 labels pour eviter le chevauchement)
86+
const labelStep = Math.max(1, Math.floor(visibleDates.length / 6));
87+
3788
return (
3889
<div>
3990
{/* Legende */}
4091
<div data-testid="usage-legend" style={{
4192
display: 'flex',
4293
gap: '16px',
43-
marginBottom: '16px',
94+
marginBottom: '12px',
4495
fontSize: '12px',
4596
color: 'var(--text-secondary)',
4697
}}>
4798
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
4899
<div style={{
49-
width: '12px',
50-
height: '12px',
100+
width: '16px',
101+
height: '3px',
51102
borderRadius: '2px',
52-
background: 'var(--accent-gradient, var(--accent-primary))',
103+
background: 'var(--accent-primary)',
53104
}} />
54105
<span>Creations</span>
55106
</div>
56107
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
57108
<div style={{
58-
width: '12px',
59-
height: '12px',
109+
width: '16px',
110+
height: '3px',
60111
borderRadius: '2px',
61112
backgroundColor: 'var(--info)',
62113
}} />
63114
<span>Acces</span>
64115
</div>
65116
</div>
66117

67-
{/* Barres */}
68-
<div style={{
69-
display: 'flex',
70-
alignItems: 'flex-end',
71-
gap: '4px',
72-
height: '120px',
73-
overflow: 'hidden',
74-
}}>
75-
{visibleDates.map(date => {
118+
{/* Line chart SVG */}
119+
<svg
120+
data-testid="usage-line-chart"
121+
viewBox={`0 0 100 ${CHART_HEIGHT}`}
122+
preserveAspectRatio="none"
123+
style={{ width: '100%', height: `${CHART_HEIGHT}px` }}
124+
>
125+
{/* Lignes de grille horizontales */}
126+
{yTicks.map(v => {
127+
const y = getY(v);
128+
return (
129+
<g key={`tick-${v}`}>
130+
<line
131+
x1={CHART_PADDING.left}
132+
y1={y}
133+
x2={CHART_PADDING.left + drawWidth}
134+
y2={y}
135+
stroke="var(--border-default)"
136+
strokeWidth="0.2"
137+
strokeDasharray="1,1"
138+
/>
139+
<text
140+
x={CHART_PADDING.left - 2}
141+
y={y + 1.2}
142+
textAnchor="end"
143+
fontSize="3.5"
144+
fill="var(--text-muted)"
145+
>
146+
{v}
147+
</text>
148+
</g>
149+
);
150+
})}
151+
152+
{/* Aire sous les courbes (fond transparent) */}
153+
<path
154+
d={creationAreaPath}
155+
fill="var(--accent-primary)"
156+
opacity="0.1"
157+
/>
158+
<path
159+
d={accessAreaPath}
160+
fill="var(--info)"
161+
opacity="0.08"
162+
/>
163+
164+
{/* Ligne creations */}
165+
<path
166+
data-testid="line-creation"
167+
d={creationPath}
168+
fill="none"
169+
stroke="var(--accent-primary)"
170+
strokeWidth="0.6"
171+
strokeLinecap="round"
172+
strokeLinejoin="round"
173+
/>
174+
175+
{/* Ligne acces */}
176+
<path
177+
data-testid="line-access"
178+
d={accessPath}
179+
fill="none"
180+
stroke="var(--info)"
181+
strokeWidth="0.6"
182+
strokeLinecap="round"
183+
strokeLinejoin="round"
184+
/>
185+
186+
{/* Points sur les courbes */}
187+
{visibleDates.map((date, i) => {
76188
const creationCount = creationMap.get(date) || 0;
77189
const accessCount = accessMap.get(date) || 0;
78-
const creationHeight = (creationCount / maxCount) * 100;
79-
const accessHeight = (accessCount / maxCount) * 100;
190+
return (
191+
<g key={date}>
192+
{creationCount > 0 && (
193+
<circle
194+
data-testid="point-creation"
195+
cx={getX(i)}
196+
cy={getY(creationCount)}
197+
r="0.8"
198+
fill="var(--accent-primary)"
199+
>
200+
<title>{`${date} - Creations: ${creationCount}`}</title>
201+
</circle>
202+
)}
203+
{accessCount > 0 && (
204+
<circle
205+
data-testid="point-access"
206+
cx={getX(i)}
207+
cy={getY(accessCount)}
208+
r="0.8"
209+
fill="var(--info)"
210+
>
211+
<title>{`${date} - Acces: ${accessCount}`}</title>
212+
</circle>
213+
)}
214+
</g>
215+
);
216+
})}
80217

218+
{/* Labels X */}
219+
{visibleDates.map((date, i) => {
220+
if (i % labelStep !== 0 && i !== visibleDates.length - 1) return null;
81221
return (
82-
<div key={date} style={{
83-
display: 'flex',
84-
flexDirection: 'column',
85-
alignItems: 'center',
86-
flex: 1,
87-
minWidth: 0,
88-
height: '100%',
89-
justifyContent: 'flex-end',
90-
}}>
91-
<div style={{
92-
display: 'flex',
93-
gap: '1px',
94-
alignItems: 'flex-end',
95-
width: '100%',
96-
height: '100%',
97-
}}>
98-
{/* Barre creation */}
99-
<div
100-
data-testid="bar-creation"
101-
title={`Creations: ${creationCount}`}
102-
style={{
103-
flex: 1,
104-
height: `${Math.max(creationHeight, creationCount > 0 ? 4 : 0)}%`,
105-
background: 'var(--accent-gradient, var(--accent-primary))',
106-
borderRadius: '2px 2px 0 0',
107-
minHeight: creationCount > 0 ? '4px' : '0',
108-
transition: 'height 0.3s ease',
109-
}}
110-
/>
111-
{/* Barre acces */}
112-
<div
113-
data-testid="bar-access"
114-
title={`Acces: ${accessCount}`}
115-
style={{
116-
flex: 1,
117-
height: `${Math.max(accessHeight, accessCount > 0 ? 4 : 0)}%`,
118-
backgroundColor: 'var(--info)',
119-
borderRadius: '2px 2px 0 0',
120-
minHeight: accessCount > 0 ? '4px' : '0',
121-
opacity: 0.8,
122-
transition: 'height 0.3s ease',
123-
}}
124-
/>
125-
</div>
126-
{/* Label date (abrege) */}
127-
<div style={{
128-
fontSize: '9px',
129-
color: 'var(--text-muted)',
130-
marginTop: '4px',
131-
textAlign: 'center',
132-
overflow: 'hidden',
133-
textOverflow: 'ellipsis',
134-
whiteSpace: 'nowrap',
135-
maxWidth: '100%',
136-
}}>
137-
{date.length > 7 ? date.slice(5) : date}
138-
</div>
139-
</div>
222+
<text
223+
key={`label-${date}`}
224+
x={getX(i)}
225+
y={CHART_HEIGHT - 5}
226+
textAnchor="middle"
227+
fontSize="3"
228+
fill="var(--text-muted)"
229+
>
230+
{date.length > 7 ? date.slice(5) : date}
231+
</text>
140232
);
141233
})}
142-
</div>
234+
</svg>
143235
</div>
144236
);
145237
}

client/tests/UsageChart.test.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,18 @@ const MOCK_ACCESSES = [
1313
];
1414

1515
describe('UsageChart', () => {
16-
it('affiche les barres de creation', () => {
16+
it('affiche la ligne de creation', () => {
1717
render(<UsageChart creations={MOCK_CREATIONS} accesses={MOCK_ACCESSES} />);
18-
const bars = screen.getAllByTestId('bar-creation');
19-
expect(bars.length).toBe(2);
18+
const line = screen.getByTestId('line-creation');
19+
expect(line).toBeDefined();
20+
expect(line.getAttribute('d')).toBeTruthy();
2021
});
2122

22-
it('affiche les barres d\'acces', () => {
23+
it('affiche la ligne d\'acces', () => {
2324
render(<UsageChart creations={MOCK_CREATIONS} accesses={MOCK_ACCESSES} />);
24-
const bars = screen.getAllByTestId('bar-access');
25-
expect(bars.length).toBe(2);
25+
const line = screen.getByTestId('line-access');
26+
expect(line).toBeDefined();
27+
expect(line.getAttribute('d')).toBeTruthy();
2628
});
2729

2830
it('affiche la legende avec les 2 series', () => {

0 commit comments

Comments
 (0)