Skip to content

Commit ee96de4

Browse files
authored
Merge pull request #1012 from knowm/issue-593-cursor-live-chart-regression-test
Issue #593: add regression test for cursor leak and stale tooltip on live charts
2 parents 58ddf1c + aaf15ef commit ee96de4

3 files changed

Lines changed: 132 additions & 3 deletions

File tree

xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue593.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@
1212
* <p>Before the fix: each repaint appended new DataPoints to the cursor's list without ever
1313
* clearing it, causing a memory leak and stale/wrong tooltip values on live charts.
1414
*
15-
* <p>After the fix: {@code PlotContent_XY.doPaint()} calls {@code cursor.clearDataPoints()} at
16-
* the start of each paint cycle, so only the current frame's points are present.
15+
* <p>After the fix: interaction data is collected fresh on every paint ({@code
16+
* PlotContent_.paint()} clears it) and {@code Cursor.setData()} rebuilds its own list from it, so
17+
* only the current frame's points are ever present.
1718
*
1819
* <p>To observe: enable the cursor, hover over the chart while it repaints, and confirm the
1920
* tooltip always reflects the current data rather than accumulating old entries.

xchart/src/main/java/org/knowm/xchart/internal/chartpart/Cursor.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ public class Cursor extends MouseAdapter implements ChartPart {
2626

2727
private static final int MOUSE_SPACING = 15;
2828

29-
private final List<DataPoint> dataPointList = new ArrayList<>();
29+
// package-private so tests in this package can assert it doesn't grow across repaints
30+
final List<DataPoint> dataPointList = new ArrayList<>();
3031
// package-private so tests in this package can assert on what the cursor label shows
3132
final List<DataPoint> matchingDataPointList = new ArrayList<>();
3233

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package org.knowm.xchart.internal.chartpart;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import java.awt.Component;
6+
import java.awt.Graphics2D;
7+
import java.awt.event.MouseEvent;
8+
import java.awt.image.BufferedImage;
9+
import java.util.ArrayList;
10+
import java.util.List;
11+
import javax.swing.JPanel;
12+
import org.junit.jupiter.api.Test;
13+
import org.knowm.xchart.XYChart;
14+
import org.knowm.xchart.XYChartBuilder;
15+
16+
// https://github.com/knowm/XChart/issues/593
17+
// On a live chart the cursor used to append the current frame's screen-space points to its list on
18+
// every repaint without ever clearing it. Two symptoms followed: unbounded growth (a memory leak
19+
// that showed up as gigabytes of Cursor$DataPoint), and a label showing values from earlier frames
20+
// because stale points still matched the mouse position.
21+
//
22+
// Exercises the cursor directly (no XChartPanel / Swing display) so it runs headless on CI.
23+
class CursorLiveChartTest {
24+
25+
private static final int POINT_COUNT = 20;
26+
27+
/** Index of the data point the mouse is parked on. */
28+
private static final int HOVER_INDEX = 12;
29+
30+
private static final int REPAINTS = 30;
31+
32+
// MouseEvent needs a non-null source Component; a JPanel is fine to construct headless.
33+
private final Component source = new JPanel();
34+
35+
@Test
36+
void cursorDataDoesNotAccumulateAcrossLiveUpdates() {
37+
38+
XYChart chart = buildChart();
39+
Cursor cursor = new Cursor(chart);
40+
PlotInteractionData data = ((Chart<?, ?>) chart).getInteractionData();
41+
42+
render(chart, cursor);
43+
PlotInteractionData.CursorData target = data.getCursorDataList().get(HOVER_INDEX);
44+
cursor.mouseMoved(moved((int) target.x, (int) data.getPlotBounds().getCenterY()));
45+
46+
for (int frame = 0; frame < REPAINTS; frame++) {
47+
chart.updateXYSeries("s", xData(), yData(frame), null);
48+
render(chart, cursor);
49+
50+
assertThat(data.getCursorDataList())
51+
.as("interaction data must be rebuilt each paint, not appended to")
52+
.hasSize(POINT_COUNT);
53+
assertThat(cursor.dataPointList)
54+
.as("cursor must drop the previous frame's points (issue #593 memory leak)")
55+
.hasSize(POINT_COUNT);
56+
}
57+
}
58+
59+
@Test
60+
void cursorLabelReflectsTheLatestDataAfterLiveUpdates() {
61+
62+
XYChart chart = buildChart();
63+
Cursor cursor = new Cursor(chart);
64+
PlotInteractionData data = ((Chart<?, ?>) chart).getInteractionData();
65+
66+
render(chart, cursor);
67+
PlotInteractionData.CursorData target = data.getCursorDataList().get(HOVER_INDEX);
68+
cursor.mouseMoved(moved((int) target.x, (int) data.getPlotBounds().getCenterY()));
69+
70+
for (int frame = 0; frame < REPAINTS; frame++) {
71+
chart.updateXYSeries("s", xData(), yData(frame), null);
72+
render(chart, cursor);
73+
74+
assertThat(cursor.matchingDataPointList)
75+
.as("exactly one label line per series, no leftovers from earlier frames")
76+
.hasSize(1);
77+
// ground truth: the interaction data is rebuilt from the series on every paint
78+
String currentYValue = data.getCursorDataList().get(HOVER_INDEX).yValue;
79+
assertThat(cursor.matchingDataPointList.get(0).yValue)
80+
.as("cursor label must show the current frame's value, not an earlier frame's")
81+
.isEqualTo(currentYValue);
82+
}
83+
}
84+
85+
private static XYChart buildChart() {
86+
87+
XYChart chart = new XYChartBuilder().width(800).height(600).build();
88+
chart.addSeries("s", xData(), yData(0));
89+
chart.enableInteractionData();
90+
return chart;
91+
}
92+
93+
private static List<Double> xData() {
94+
95+
List<Double> xData = new ArrayList<>();
96+
for (int i = 0; i < POINT_COUNT; i++) {
97+
xData.add((double) i);
98+
}
99+
return xData;
100+
}
101+
102+
/** Y values shift by frame so a stale point is distinguishable from a current one. */
103+
private static List<Double> yData(int frame) {
104+
105+
List<Double> yData = new ArrayList<>();
106+
for (int i = 0; i < POINT_COUNT; i++) {
107+
yData.add(i * 10.0 + frame);
108+
}
109+
return yData;
110+
}
111+
112+
/** Paints the chart and hands the collected interaction data to the cursor, as the panel does. */
113+
private static void render(XYChart chart, Cursor cursor) {
114+
115+
BufferedImage image =
116+
new BufferedImage(chart.getWidth(), chart.getHeight(), BufferedImage.TYPE_INT_ARGB);
117+
Graphics2D g = image.createGraphics();
118+
chart.paint(g, chart.getWidth(), chart.getHeight());
119+
chart.consumeInteractionData(g, null, cursor, null);
120+
g.dispose();
121+
}
122+
123+
private MouseEvent moved(int x, int y) {
124+
125+
return new MouseEvent(source, MouseEvent.MOUSE_MOVED, 1L, 0, x, y, 0, false);
126+
}
127+
}

0 commit comments

Comments
 (0)