1import ChartWrapper from "@/common/components/ChartWrapper";
2import { useMemo, useContext, memo } from "react";
3import { t } from "i18next";
4import { ThemeContext } from "@/common/contexts/Theme";
5import { PreferencesContext } from "@/common/contexts/Preferences";
6import { convertSpeed, getSpeedUnit, TIME_FORMAT_12H } from "@/common/utils/FormatUtil";
7import "./styles.sass";
8
9export const SpeedChart = memo(({ labels, data, dataKey, titleKey, color, onClick, failed, errors, compact = false }) => {
10 const [isDarkMode] = useContext(ThemeContext);
11 const [preferences] = useContext(PreferencesContext);
12 const speedUnit = getSpeedUnit(preferences);
13 const use12h = preferences?.timeFormat === TIME_FORMAT_12H;
14
15 const filteredData = useMemo(() => {
16 if (!data?.[dataKey] || !labels) return { labels: [], data: [], average: 0, failed: [], errors: [], isSingleDay: false };
17
18 const filtered = labels.map((label, index) => ({
19 label,
20 value: convertSpeed(data[dataKey][index], preferences),
21 isFailed: failed?.[index] || false,
22 error: errors?.[index] || null,
23 date: new Date(label)
24 }));
25
26 const validValues = filtered.filter(item => item.value !== null && item.value !== undefined && item.value > 0).map(item => item.value);
27 const average = validValues.length > 0
28 ? Math.round((validValues.reduce((a, b) => a + b, 0) / validValues.length) * 100) / 100
29 : 0;
30
31 const dates = filtered.map(item => new Date(item.label).toDateString());
32 const uniqueDates = [...new Set(dates)];
33 const isSingleDay = uniqueDates.length === 1;
34
35 return {
36 labels: filtered.map(item => item.label),
37 data: filtered.map(item => item.value),
38 failed: filtered.map(item => item.isFailed),
39 errors: filtered.map(item => item.error),
40 average,
41 isSingleDay
42 };
43 }, [labels, data, dataKey, failed, errors, preferences]);
44
45 const failedMarkerData = useMemo(() => {
46 return filteredData.labels.map((_, index) =>
47 filteredData.failed[index] ? 0 : null
48 );
49 }, [filteredData]);
50
51 const themeColors = useMemo(() => ({
52 gridColor: isDarkMode ? 'rgba(42, 52, 65, 0.6)' : 'rgba(203, 213, 225, 0.8)',
53 tickColor: isDarkMode ? 'hsl(215, 20%, 50%)' : 'hsl(215, 25%, 40%)',
54 tooltipBg: isDarkMode ? 'hsl(215, 28%, 10%)' : 'hsl(0, 0%, 100%)',
55 tooltipTitle: isDarkMode ? 'hsl(210, 40%, 96%)' : 'hsl(215, 25%, 20%)',
56 tooltipBody: isDarkMode ? 'hsl(215, 20%, 65%)' : 'hsl(215, 15%, 40%)',
57 tooltipBorder: isDarkMode ? 'hsl(215, 25%, 22%)' : 'hsl(215, 20%, 85%)'
58 }), [isDarkMode]);
59
60 const chartOptions = useMemo(() => ({
61 responsive: true,
62 maintainAspectRatio: false,
63 resizeDelay: 100,
64 animation: {
65 duration: 250,
66 easing: 'easeOutQuart'
67 },
68 animations: {
69 colors: false,
70 x: false
71 },
72 transitions: {
73 active: {
74 animation: {
75 duration: 100
76 }
77 },
78 resize: {
79 animation: {
80 duration: 0
81 }
82 }
83 },
84 plugins: {
85 tooltip: {
86 backgroundColor: themeColors.tooltipBg,
87 titleColor: themeColors.tooltipTitle,
88 bodyColor: themeColors.tooltipBody,
89 borderColor: themeColors.tooltipBorder,
90 borderWidth: 1,
91 padding: 14,
92 cornerRadius: 10,
93 displayColors: true,
94 boxPadding: 8,
95 filter: (item) => item.dataset.label !== t("statistics.failed_test"),
96 callbacks: {
97 title: (items) => {
98 if (items.length > 0) {
99 const date = new Date(filteredData.labels[items[0].dataIndex]);
100 return date.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' }) +
101 ' ' + date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: use12h });
102 }
103 return '';
104 },
105 label: (item) => {
106 if (item.dataset.label === t("statistics.failed_test")) {
107 const error = filteredData.errors[item.dataIndex];
108 return error ? `${t("statistics.failed_test")}: ${error}` : t("statistics.failed_test");
109 }
110 return `${item.dataset.label}: ${item.formattedValue} ${speedUnit}`;
111 },
112 afterBody: (items) => {
113 if (items.length > 0) {
114 const index = items[0].dataIndex;
115 if (filteredData.failed[index]) {
116 const error = filteredData.errors[index];
117 return error ? `\n⚠ ${t("statistics.failed_test")}: ${error}` : `\n⚠ ${t("statistics.failed_test")}`;
118 }
119 }
120 return '';
121 }
122 }
123 },
124 legend: {
125 position: "bottom",
126 labels: {
127 usePointStyle: true,
128 pointStyle: 'circle',
129 padding: 20,
130 color: themeColors.tickColor,
131 font: {
132 size: 12,
133 weight: 500
134 },
135 filter: (item) => item.text !== t("statistics.failed_test")
136 }
137 }
138 },
139 scales: {
140 x: {
141 reverse: false,
142 grid: {
143 color: themeColors.gridColor,
144 drawBorder: false
145 },
146 border: {
147 display: false
148 },
149 ticks: {
150 color: themeColors.tickColor,
151 maxTicksLimit: filteredData.isSingleDay ? 12 : 5,
152 callback: function(value, index) {
153 const date = new Date(filteredData.labels[index]);
154 if (filteredData.isSingleDay) {
155 return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: use12h });
156 }
157 return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' +
158 date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: use12h });
159 }
160 }
161 },
162 y: {
163 beginAtZero: true,
164 grid: {
165 color: themeColors.gridColor,
166 drawBorder: false
167 },
168 border: {
169 display: false
170 },
171 ticks: {
172 color: themeColors.tickColor,
173 stepSize: 100
174 }
175 }
176 },
177 interaction: {
178 intersect: false,
179 mode: 'index'
180 },
181 elements: {
182 line: {
183 tension: 0.35,
184 borderWidth: 2.5
185 },
186 point: {
187 radius: compact ? 0 : 3,
188 hoverRadius: compact ? 0 : 6,
189 hoverBorderWidth: 2
190 }
191 }
192 }), [themeColors, filteredData.labels, filteredData.errors, filteredData.failed, filteredData.isSingleDay, compact, speedUnit, use12h]);
193
194 const hasFailedTests = useMemo(() => failedMarkerData.some(v => v !== null), [failedMarkerData]);
195
196 const chartData = useMemo(() => ({
197 labels: filteredData.labels,
198 datasets: [
199 {
200 label: t(titleKey),
201 data: filteredData.data,
202 borderColor: color,
203 backgroundColor: (context) => {
204 const ctx = context.chart.ctx;
205 const gradient = ctx.createLinearGradient(0, 0, 0, context.chart.height);
206 gradient.addColorStop(0, color.replace('hsl', 'hsla').replace(')', ', 0.25)'));
207 gradient.addColorStop(1, color.replace('hsl', 'hsla').replace(')', ', 0.01)'));
208 return gradient;
209 },
210 fill: true,
211 pointBackgroundColor: color,
212 pointBorderColor: color,
213 pointRadius: compact ? 0 : 3,
214 pointHoverRadius: compact ? 0 : 5,
215 spanGaps: true,
216 order: 1
217 },
218 {
219 label: t("statistics.average"),
220 data: filteredData.labels.map(() => filteredData.average),
221 borderColor: 'hsl(330, 80%, 60%)',
222 backgroundColor: 'transparent',
223 borderWidth: 2,
224 borderDash: [6, 4],
225 pointRadius: 0,
226 pointHoverRadius: 0,
227 fill: false,
228 order: 3
229 },
230 ...(hasFailedTests ? [{
231 label: t("statistics.failed_test"),
232 data: failedMarkerData,
233 borderColor: 'transparent',
234 backgroundColor: 'hsl(0, 72%, 51%)',
235 pointBackgroundColor: 'hsl(0, 72%, 51%)',
236 pointBorderColor: 'hsl(0, 84%, 60%)',
237 pointBorderWidth: compact ? 1 : 2,
238 pointRadius: compact ? 3 : 6,
239 pointHoverRadius: compact ? 4 : 8,
240 pointStyle: 'crossRot',
241 showLine: false,
242 fill: false,
243 order: 0
244 }] : [])
245 ],
246 }), [filteredData, color, titleKey, compact, hasFailedTests, failedMarkerData]);
247
248 return (
249 <div className="chart-container" onClick={onClick}>
250 <div className="chart-header">
251 <h3 className="chart-title">{t(titleKey)} ({speedUnit})</h3>
252 </div>
253 <div className="chart-body">
254 <ChartWrapper type="line" data={chartData} options={chartOptions} />
255 </div>
256 </div>
257 );
258});