Skip to content

Commit 4ea1cc4

Browse files
committed
feat: enhance chart synchronization and hover functionality
1 parent c104dbe commit 4ea1cc4

2 files changed

Lines changed: 117 additions & 47 deletions

File tree

src/components/ChartContainer.jsx

Lines changed: 114 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ ChartJS.register(
2626
zoomPlugin
2727
);
2828

29-
const ChartWrapper = ({ data, options, chartId, onRegisterChart, onSyncHover }) => {
29+
const ChartWrapper = ({ data, options, chartId, onRegisterChart, onSyncHover, syncRef }) => {
3030
const chartRef = useRef(null);
3131

3232
const handleChartRef = useCallback((ref) => {
@@ -38,17 +38,38 @@ const ChartWrapper = ({ data, options, chartId, onRegisterChart, onSyncHover })
3838

3939
const enhancedOptions = {
4040
...options,
41-
onHover: (event, activeElements) => {
42-
if (activeElements.length > 0 && chartRef.current) {
43-
const { datasetIndex, index } = activeElements[0];
41+
onHover: (event, activeElements) => {
42+
if (syncRef?.current) return;
43+
if (activeElements.length > 0 && chartRef.current) {
44+
// 找到距离鼠标最近的数据点
45+
let closestElement = activeElements[0];
46+
let minDistance = Infinity;
47+
48+
const canvasRect = chartRef.current.canvas.getBoundingClientRect();
49+
const mouseX = event.native ? event.native.clientX - canvasRect.left : event.x;
50+
51+
activeElements.forEach(element => {
52+
const { datasetIndex, index } = element;
4453
const dataset = chartRef.current.data.datasets[datasetIndex];
4554
const point = dataset.data[index];
46-
const step = point.x;
47-
onSyncHover(step, chartId);
48-
} else {
49-
onSyncHover(null, chartId);
50-
}
51-
},
55+
const pixelX = chartRef.current.scales.x.getPixelForValue(point.x);
56+
const distance = Math.abs(mouseX - pixelX);
57+
58+
if (distance < minDistance) {
59+
minDistance = distance;
60+
closestElement = element;
61+
}
62+
});
63+
64+
const { datasetIndex, index } = closestElement;
65+
const dataset = chartRef.current.data.datasets[datasetIndex];
66+
const point = dataset.data[index];
67+
const step = point.x;
68+
onSyncHover(step, chartId);
69+
} else {
70+
onSyncHover(null, chartId);
71+
}
72+
},
5273
events: ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove'],
5374
};
5475

@@ -74,30 +95,62 @@ export default function ChartContainer({
7495
onMaxStepChange
7596
}) {
7697
const chartRefs = useRef(new Map());
98+
const syncLockRef = useRef(false);
7799
const registerChart = useCallback((id, inst) => {
78100
chartRefs.current.set(id, inst);
79101
}, []);
80102

81103
const syncHoverToAllCharts = useCallback((step, sourceId) => {
104+
if (syncLockRef.current) return;
105+
syncLockRef.current = true;
82106
chartRefs.current.forEach((chart, id) => {
83-
if (!chart) return;
107+
if (!chart || !chart.data || !chart.data.datasets) return;
84108
if (step === null) {
85109
chart.setActiveElements([]);
86-
chart.tooltip.setActiveElements([]);
87-
chart.update('none');
110+
chart.tooltip.setActiveElements([], { x: 0, y: 0 });
111+
chart.draw();
88112
} else if (id !== sourceId) {
89113
const activeElements = [];
114+
const seen = new Set(); // 防止重复添加相同的数据点
90115
chart.data.datasets.forEach((dataset, datasetIndex) => {
91-
const idx = dataset.data.findIndex(p => p.x === step);
92-
if (idx !== -1) {
93-
activeElements.push({ datasetIndex, index: idx });
116+
if (!dataset || !dataset.data || !Array.isArray(dataset.data)) return;
117+
const idx = dataset.data.findIndex(p => p && typeof p.x !== 'undefined' && p.x === step);
118+
if (idx !== -1 && dataset.data[idx]) {
119+
const elementKey = `${datasetIndex}-${idx}`;
120+
if (!seen.has(elementKey)) {
121+
// 验证元素的有效性
122+
if (datasetIndex >= 0 && datasetIndex < chart.data.datasets.length &&
123+
idx >= 0 && idx < dataset.data.length) {
124+
activeElements.push({ datasetIndex, index: idx });
125+
seen.add(elementKey);
126+
}
127+
}
94128
}
95129
});
96-
chart.setActiveElements(activeElements);
97-
chart.tooltip.setActiveElements(activeElements, { x: 0, y: 0 });
98-
chart.update('none');
130+
131+
// 只有当activeElements不为空且所有元素都有效时才设置
132+
if (activeElements.length > 0) {
133+
try {
134+
const pos = { x: chart.scales.x.getPixelForValue(step), y: 0 };
135+
chart.setActiveElements(activeElements);
136+
chart.tooltip.setActiveElements(activeElements, pos);
137+
chart.draw();
138+
} catch (error) {
139+
console.warn('Error setting active elements:', error);
140+
// 如果出错,清除所有activeElements
141+
chart.setActiveElements([]);
142+
chart.tooltip.setActiveElements([], { x: 0, y: 0 });
143+
chart.draw();
144+
}
145+
} else {
146+
// 如果没有找到有效的activeElements,清除当前的
147+
chart.setActiveElements([]);
148+
chart.tooltip.setActiveElements([], { x: 0, y: 0 });
149+
chart.draw();
150+
}
99151
}
100152
});
153+
syncLockRef.current = false;
101154
}, []);
102155

103156
const parsedData = useMemo(() => {
@@ -206,30 +259,41 @@ export default function ChartContainer({
206259
}, [parsedData, onXRangeChange]);
207260

208261
const colors = ['#ef4444', '#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#f97316'];
209-
const createChartData = dataArray => ({
210-
datasets: dataArray.map((item, index) => {
211-
const color = colors[index % colors.length];
212-
return {
213-
label: item.name?.replace(/\.(log|txt)$/i, '') || `File ${index + 1}`,
214-
data: item.data,
215-
borderColor: color,
216-
backgroundColor: `${color}33`,
217-
borderWidth: 2,
218-
fill: false,
219-
tension: 0,
220-
pointRadius: 0,
221-
pointHoverRadius: 4,
222-
pointBackgroundColor: color,
223-
pointBorderColor: color,
224-
pointBorderWidth: 1,
225-
pointHoverBackgroundColor: color,
226-
pointHoverBorderColor: color,
227-
pointHoverBorderWidth: 1,
228-
animation: false,
229-
animations: { colors: false, x: false, y: false },
230-
};
231-
})
232-
});
262+
const createChartData = dataArray => {
263+
// 确保没有重复的 datasets
264+
const uniqueItems = dataArray.reduce((acc, item) => {
265+
const exists = acc.find(existing => existing.name === item.name);
266+
if (!exists) {
267+
acc.push(item);
268+
}
269+
return acc;
270+
}, []);
271+
272+
return {
273+
datasets: uniqueItems.map((item, index) => {
274+
const color = colors[index % colors.length];
275+
return {
276+
label: item.name?.replace(/\.(log|txt)$/i, '') || `File ${index + 1}`,
277+
data: item.data,
278+
borderColor: color,
279+
backgroundColor: `${color}33`,
280+
borderWidth: 2,
281+
fill: false,
282+
tension: 0,
283+
pointRadius: 0,
284+
pointHoverRadius: 4,
285+
pointBackgroundColor: color,
286+
pointBorderColor: color,
287+
pointBorderWidth: 1,
288+
pointHoverBackgroundColor: color,
289+
pointHoverBorderColor: color,
290+
pointHoverBorderWidth: 1,
291+
animation: false,
292+
animations: { colors: false, x: false, y: false },
293+
};
294+
})
295+
};
296+
};
233297

234298
const getComparisonData = (data1, data2, mode) => {
235299
const map2 = new Map(data2.map(p => [p.x, p.y]));
@@ -291,7 +355,7 @@ export default function ChartContainer({
291355
animations: { colors: false, x: false, y: false },
292356
hover: { animationDuration: 0 },
293357
responsiveAnimationDuration: 0,
294-
interaction: { mode: 'x', intersect: false },
358+
interaction: { mode: 'nearest', intersect: false, axis: 'x' },
295359
plugins: {
296360
zoom: {
297361
pan: {
@@ -340,8 +404,9 @@ export default function ChartContainer({
340404
}
341405
},
342406
tooltip: {
343-
mode: 'x',
407+
mode: 'nearest',
344408
intersect: false,
409+
axis: 'x',
345410
animation: false,
346411
backgroundColor: 'rgba(15, 23, 42, 0.92)',
347412
titleColor: '#f1f5f9',
@@ -364,7 +429,8 @@ export default function ChartContainer({
364429
},
365430
label: function (context) {
366431
const value = Number(context.parsed.y.toPrecision(4));
367-
return ` ${value}`;
432+
const label = context.dataset?.label || 'Dataset';
433+
return ` ${label}: ${value}`;
368434
},
369435
labelColor: function (context) {
370436
return {
@@ -548,6 +614,7 @@ export default function ChartContainer({
548614
chartId={`metric-comp-${idx}`}
549615
onRegisterChart={registerChart}
550616
onSyncHover={syncHoverToAllCharts}
617+
syncRef={syncLockRef}
551618
data={compData}
552619
options={compOptions}
553620
/>
@@ -562,6 +629,7 @@ export default function ChartContainer({
562629
chartId={`metric-${idx}`}
563630
onRegisterChart={registerChart}
564631
onSyncHover={syncHoverToAllCharts}
632+
syncRef={syncLockRef}
565633
data={createChartData(dataArray)}
566634
options={options}
567635
/>

src/components/__tests__/ChartContainer.test.jsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ vi.mock('react-chartjs-2', async () => {
3333
data: props.data,
3434
setActiveElements: vi.fn(),
3535
tooltip: { setActiveElements: vi.fn() },
36-
update: vi.fn(),
36+
draw: vi.fn(),
37+
scales: { x: { getPixelForValue: vi.fn(() => 0) } },
3738
};
3839
charts.push(chart);
3940
if (typeof ref === 'function') ref(chart);
@@ -110,6 +111,7 @@ describe('ChartContainer', () => {
110111
const hover = __lineProps[0].options.onHover;
111112
hover({}, [{ index: 0, datasetIndex: 0 }]);
112113
expect(__charts[1].setActiveElements).toHaveBeenCalled();
114+
expect(__charts[1].draw).toHaveBeenCalled();
113115
});
114116

115117
it('parses metrics, applies range and triggers callbacks', () => {

0 commit comments

Comments
 (0)