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