diff --git a/xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue679.java b/xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue679.java new file mode 100644 index 000000000..7503fe477 --- /dev/null +++ b/xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue679.java @@ -0,0 +1,60 @@ +package org.knowm.xchart.standalone.issues; + +import org.knowm.xchart.HeatMapChart; +import org.knowm.xchart.HeatMapChartBuilder; +import org.knowm.xchart.SwingWrapper; + +/** + * Demonstrates issue #679 — custom tooltips via {@code setToolTipGenerator(...)}, available on + * every series type. + * + *

The generator receives a {@code ChartDataPoint} carrying the series name, the data point's + * index within the series data and the default label, and returns the tooltip text to display + * (multi-line via {@code System.lineSeparator()}). Returning {@code null} falls back to the default + * label. Here each heat map cell's tooltip shows extra information looked up from an application + * data structure by the data point index — exactly what the issue asked for. + */ +public class TestForIssue679 { + + public static void main(String[] args) { + + SwingWrapper wrapper = new SwingWrapper<>(getChart()); + wrapper.displayChart(); + // tooltips are an XChartPanel feature and are off by default + wrapper.getXChartPanel().setToolTipsEnabled(true); + } + + /** Constructs and returns the chart without launching a window (headless-safe). */ + public static HeatMapChart getChart() { + + HeatMapChart chart = + new HeatMapChartBuilder() + .width(700) + .height(400) + .title("Issue #679 – Custom HeatMap tooltips (hover over a cell)") + .build(); + chart.getStyler().setShowValue(true); + + int[] xData = {0, 1, 2, 3}; + int[] yData = {0, 1, 2}; + // heatData[x][y] = cell value; the data point index runs x-major: index = x * yLength + y + int[][] heatData = new int[xData.length][yData.length]; + // application-side extra info, indexed the same as the heat data + String[] extraInfo = new String[xData.length * yData.length]; + for (int x : xData) { + for (int y : yData) { + heatData[x][y] = (x + 1) * (y + 1); + extraInfo[x * yData.length + y] = "sample count: " + (100 + 10 * x + y); + } + } + + chart + .addSeries("heat", xData, yData, heatData) + .setToolTipGenerator( + dataPoint -> + dataPoint.getLabel() + + System.lineSeparator() + + extraInfo[dataPoint.getDataPointIndex()]); + return chart; + } +} diff --git a/xchart/src/main/java/org/knowm/xchart/BubbleSeries.java b/xchart/src/main/java/org/knowm/xchart/BubbleSeries.java index 93e0f5133..53a4eaa06 100644 --- a/xchart/src/main/java/org/knowm/xchart/BubbleSeries.java +++ b/xchart/src/main/java/org/knowm/xchart/BubbleSeries.java @@ -40,6 +40,11 @@ public BubbleSeries setBubbleSeriesRenderStyle(BubbleSeriesRenderStyle bubbleSer return this; } + /** + * @deprecated use {@link #setToolTipGenerator(ToolTipGenerator)} instead; will be removed in + * 4.1.0 + */ + @Deprecated public boolean isCustomToolTips() { return customToolTips; @@ -52,13 +57,22 @@ public boolean isCustomToolTips() { * * @param customToolTips true to show the per-data-point strings from {@link * #setToolTips(String[])}; false to show the default formatted x/y axis values + * @deprecated use {@link #setToolTipGenerator(ToolTipGenerator)} instead, which works on every + * chart type and takes precedence over these strings when both are set; will be removed in + * 4.1.0 */ + @Deprecated public BubbleSeries setCustomToolTips(boolean customToolTips) { this.customToolTips = customToolTips; return this; } + /** + * @deprecated use {@link #setToolTipGenerator(ToolTipGenerator)} instead; will be removed in + * 4.1.0 + */ + @Deprecated public String[] getToolTips() { return toolTips; @@ -71,7 +85,11 @@ public String[] getToolTips() { * * @param toolTips the tooltip strings, one per data point; a null entry (or a null array) falls * back to the default formatted x/y axis values for that data point + * @deprecated use {@link #setToolTipGenerator(ToolTipGenerator)} instead, which works on every + * chart type and takes precedence over these strings when both are set; will be removed in + * 4.1.0 */ + @Deprecated public BubbleSeries setToolTips(String[] toolTips) { this.toolTips = toolTips; diff --git a/xchart/src/main/java/org/knowm/xchart/RadarSeries.java b/xchart/src/main/java/org/knowm/xchart/RadarSeries.java index 9efe1858b..72576227b 100644 --- a/xchart/src/main/java/org/knowm/xchart/RadarSeries.java +++ b/xchart/src/main/java/org/knowm/xchart/RadarSeries.java @@ -27,10 +27,10 @@ public class RadarSeries extends MarkerSeries { private double[] values; private String[] tooltipOverrides; - // TODO refactor tooltips override /** * @param tooltipOverrides Adds custom tooltipOverrides for series. If tooltipOverrides is null, - * they are automatically generated. + * they are automatically generated. Deprecated: pass null and use {@link + * #setToolTipGenerator(ToolTipGenerator)} instead. */ public RadarSeries(String name, double[] values, String[] tooltipOverrides) { @@ -50,6 +50,11 @@ public RadarSeries setValues(double[] values) { return this; } + /** + * @deprecated use {@link #setToolTipGenerator(ToolTipGenerator)} instead; will be removed in + * 4.1.0 + */ + @Deprecated public String[] getTooltipOverrides() { return tooltipOverrides; @@ -154,6 +159,12 @@ public LegendRenderType getLegendRenderType() { return LegendRenderType.Line; } + /** + * @deprecated use {@link #setToolTipGenerator(ToolTipGenerator)} instead, which works on every + * chart type and takes precedence over these strings when both are set; will be removed in + * 4.1.0 + */ + @Deprecated public RadarSeries setTooltipOverrides(String[] tooltipOverrides) { this.tooltipOverrides = tooltipOverrides; diff --git a/xchart/src/main/java/org/knowm/xchart/ToolTipGenerator.java b/xchart/src/main/java/org/knowm/xchart/ToolTipGenerator.java new file mode 100644 index 000000000..01fc6bd5e --- /dev/null +++ b/xchart/src/main/java/org/knowm/xchart/ToolTipGenerator.java @@ -0,0 +1,33 @@ +package org.knowm.xchart; + +import org.knowm.xchart.internal.series.Series; + +/** + * Generates custom tooltip text for a series' data points, replacing the default label built from + * the formatted axis values. Set one on any series via {@link + * Series#setToolTipGenerator(ToolTipGenerator)}: + * + *

+ * chart.getHeatMapSeries()
+ *     .setToolTipGenerator(
+ *         dataPoint -> "extra info for cell #" + dataPoint.getDataPointIndex());
+ * 
+ * + *

The generator is invoked once per rendered data point each time the chart is painted. The + * {@link ChartDataPoint} argument carries the series name, the data point's index within the + * series' data (so raw values can be looked up in the data structures the series was built from), + * the default formatted label or x/y values, and the data point's screen geometry. + * + *

Multi-line tooltips are supported: separate lines with {@link System#lineSeparator()}. + */ +@FunctionalInterface +public interface ToolTipGenerator { + + /** + * Generate the tooltip text for one data point. + * + * @param dataPoint the rendered data point the tooltip belongs to + * @return the tooltip text, or {@code null} to fall back to the default label + */ + String generateToolTip(ChartDataPoint dataPoint); +} diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/DataPointDispatcher.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/DataPointDispatcher.java index aa098475d..1060c3bd8 100644 --- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/DataPointDispatcher.java +++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/DataPointDispatcher.java @@ -3,7 +3,6 @@ import java.awt.Shape; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; -import java.awt.geom.Ellipse2D; import java.util.ArrayList; import java.util.List; import org.knowm.xchart.ChartDataPoint; @@ -17,9 +16,6 @@ */ public class DataPointDispatcher extends MouseAdapter { - // Matches the default marker hit shape built in ToolTips.ToolTip when no explicit shape is given. - private static final double MARGIN = 5; - private final List listeners; private final List dataPoints = new ArrayList<>(); private ChartDataPoint hovered = null; @@ -35,20 +31,15 @@ public void setData(PlotInteractionData data) { return; } for (PlotInteractionData.ToolTipData td : data.getToolTipDataList()) { - Shape shape = td.shape != null ? td.shape : defaultMarkerShape(td.x, td.y); + Shape shape = td.shape != null ? td.shape : PlotInteractionData.defaultMarkerShape(td.x, td.y); + // listeners see the label that is actually displayed, custom or default + String label = td.getCustomLabel() != null ? td.getCustomLabel() : td.label; dataPoints.add( new ChartDataPoint( - td.seriesName, td.dataPointIndex, td.label, td.xValue, td.yValue, td.x, td.y, shape)); + td.seriesName, td.dataPointIndex, label, td.xValue, td.yValue, td.x, td.y, shape)); } } - private static Shape defaultMarkerShape(double x, double y) { - - double halfSize = MARGIN * 1.5; - double markerSize = MARGIN * 3; - return new Ellipse2D.Double(x - halfSize, y - halfSize, markerSize, markerSize); - } - private ChartDataPoint hitTest(int x, int y) { for (ChartDataPoint dataPoint : dataPoints) { diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Box.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Box.java index c0a60e943..05c904938 100644 --- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Box.java +++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Box.java @@ -82,7 +82,8 @@ protected void doPaint(Graphics2D g) { } // data points double[] yArr = series.getYData(); - for (double yOrig : yArr) { + for (int dataIndex = 0; dataIndex < yArr.length; dataIndex++) { + double yOrig = yArr[dataIndex]; double y; if (boxPlotStyler.isYAxisLogarithmic()) { @@ -121,13 +122,15 @@ protected void doPaint(Graphics2D g) { g.draw(outPointLine2); if (toolTipsEnabled) { - interactionData.addToolTip( - xOffset, - yOffset, - series.getName() - + ":" - + System.lineSeparator() - + axesChart.getYAxisFormat().format(yOrig)); + interactionData + .addToolTip( + xOffset, + yOffset, + series.getName() + + ":" + + System.lineSeparator() + + axesChart.getYAxisFormat().format(yOrig)) + .withSeries(series, dataIndex); } } else if (chart.getStyler().getShowWithinAreaPoint()) { @@ -136,22 +139,24 @@ protected void doPaint(Graphics2D g) { series.getMarker().paint(g, xOffset, yOffset, boxPlotStyler.getMarkerSize()); if (toolTipsEnabled) { - interactionData.addToolTip( - xOffset, - yOffset, - series.getName() - + ":" - + System.lineSeparator() - + axesChart.getYAxisFormat().format(yOrig)); + interactionData + .addToolTip( + xOffset, + yOffset, + series.getName() + + ":" + + System.lineSeparator() + + axesChart.getYAxisFormat().format(yOrig)) + .withSeries(series, dataIndex); } } } - drawBoxPlot(g, series.getName(), boxPlotData); + drawBoxPlot(g, series, boxPlotData); } } - private void drawBoxPlot(Graphics2D g, String seriesName, BoxPlotData boxPlotData) { + private void drawBoxPlot(Graphics2D g, S series, BoxPlotData boxPlotData) { // when all data values are the same yMin == yMax; offsets would be NaN, so skip rendering if (yMax == yMin) { @@ -251,28 +256,31 @@ private void drawBoxPlot(Graphics2D g, String seriesName, BoxPlotData boxPlotDat area.add(new Area(rect.getBounds())); if (interactionData != null) { - interactionData.addToolTip( - area, - xOffset, - yOffset, - 10, - seriesName - + ":" - + System.lineSeparator() - + "upper: " - + axesChart.getYAxisFormat().format(boxPlotData.upper) - + System.lineSeparator() - + "q3: " - + axesChart.getYAxisFormat().format(boxPlotData.q3) - + System.lineSeparator() - + "median: " - + axesChart.getYAxisFormat().format(boxPlotData.median) - + System.lineSeparator() - + "q1: " - + axesChart.getYAxisFormat().format(boxPlotData.q1) - + System.lineSeparator() - + "lower: " - + axesChart.getYAxisFormat().format(boxPlotData.lower)); + interactionData + .addToolTip( + area, + xOffset, + yOffset, + 10, + series.getName() + + ":" + + System.lineSeparator() + + "upper: " + + axesChart.getYAxisFormat().format(boxPlotData.upper) + + System.lineSeparator() + + "q3: " + + axesChart.getYAxisFormat().format(boxPlotData.q3) + + System.lineSeparator() + + "median: " + + axesChart.getYAxisFormat().format(boxPlotData.median) + + System.lineSeparator() + + "q1: " + + axesChart.getYAxisFormat().format(boxPlotData.q1) + + System.lineSeparator() + + "lower: " + + axesChart.getYAxisFormat().format(boxPlotData.lower)) + // the box aggregates the whole series, so there is no single data point index + .withSeries(series, -1); } } } diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Bubble.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Bubble.java index cd0b2b1a4..cd9c3c56d 100644 --- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Bubble.java +++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Bubble.java @@ -142,7 +142,7 @@ public void doPaint(Graphics2D g) { && customToolTips[i] != null) { interactionData .addToolTip(bubble, xOffset, yOffset, 0, customToolTips[i]) - .withSeries(series.getName(), i); + .withSeries(series, i); } else { interactionData .addToolTip( @@ -152,7 +152,7 @@ public void doPaint(Graphics2D g) { 0, axesChart.getXAxisFormat().format(x), axesChart.getYAxisFormat().format(yOrig)) - .withSeries(series.getName(), i); + .withSeries(series, i); } } } diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Category.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Category.java index 5a6c10945..a0133f310 100644 --- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Category.java +++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Category.java @@ -521,7 +521,7 @@ public void doPaint(Graphics2D g) { barWidth, axesChart.getXAxisFormat().format(nextCat), axesChart.getYAxisFormat().format(yOrig)) - .withSeries(series.getName(), dataIndex); + .withSeries(series, dataIndex); } } diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Dial.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Dial.java index fce97c65c..6e23cf9b9 100644 --- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Dial.java +++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Dial.java @@ -242,7 +242,8 @@ public void doPaint(Graphics2D g) { label = df.format(value); } } - interactionData.addToolTip(path, xOffset, yOffset + 10, 0, label); + // a dial series is a single value, so its only data point is index 0 + interactionData.addToolTip(path, xOffset, yOffset + 10, 0, label).withSeries(series, 0); } path.moveTo(xCenter, yCenter); diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_HeatMap.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_HeatMap.java index b60510ca8..ad11d90ae 100644 --- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_HeatMap.java +++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_HeatMap.java @@ -72,7 +72,8 @@ protected void doPaint(Graphics2D g) { double yOffset = 0.0; Rectangle2D rect = null; Color heatMapValueColor = null; - for (Number[] numbers : list) { + for (int dataIndex = 0; dataIndex < list.size(); dataIndex++) { + Number[] numbers = list.get(dataIndex); if (numbers == null) { continue; } @@ -102,18 +103,20 @@ protected void doPaint(Graphics2D g) { } if (interactionData != null) { - interactionData.addToolTip( - rect, - rect.getCenterX(), - rect.getCenterY() + heatMapStyler.getToolTipFont().getSize(), - 0, - series.getName() - + ": " - + axesChart.getXAxisFormat().format(xData.get(x)) - + ", " - + axesChart.getYAxisFormat().format(yData.get(y)) - + ", " - + df.format(numbers[2])); + interactionData + .addToolTip( + rect, + rect.getCenterX(), + rect.getCenterY() + heatMapStyler.getToolTipFont().getSize(), + 0, + series.getName() + + ": " + + axesChart.getXAxisFormat().format(xData.get(x)) + + ", " + + axesChart.getYAxisFormat().format(yData.get(y)) + + ", " + + df.format(numbers[2])) + .withSeries(series, dataIndex); } } } diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_HorizontalBar.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_HorizontalBar.java index c725f275b..78792c525 100644 --- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_HorizontalBar.java +++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_HorizontalBar.java @@ -193,7 +193,7 @@ public void doPaint(Graphics2D g) { axesChart.getXAxisFormat().format(xOrig), axesChart.getYAxisFormat().format(nextCat)) // categoryCounter was post-incremented above, so this bar's index is one less - .withSeries(series.getName(), categoryCounter - 1); + .withSeries(series, categoryCounter - 1); } } diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_OHLC.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_OHLC.java index b2cae5078..8f68dae69 100644 --- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_OHLC.java +++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_OHLC.java @@ -142,11 +142,13 @@ public void doPaint(Graphics2D g) { // add tooltips if (interactionData != null) { - interactionData.addToolTip( - xOffset, - yOffset, - axesChart.getXAxisFormat().format(x), - axesChart.getYAxisFormat(series.getYAxisDecimalPattern()).format(yOrig)); + interactionData + .addToolTip( + xOffset, + yOffset, + axesChart.getXAxisFormat().format(x), + axesChart.getYAxisFormat(series.getYAxisDecimalPattern()).format(yOrig)) + .withSeries(series, i); } } } else { @@ -324,7 +326,9 @@ public void doPaint(Graphics2D g) { sb.append(System.lineSeparator()) .append("high: ") .append(axesChart.getYAxisFormat().format(highOrig)); - interactionData.addToolTip(toolTipArea, xOffset, highOffset, 0, sb.toString()); + interactionData + .addToolTip(toolTipArea, xOffset, highOffset, 0, sb.toString()) + .withSeries(series, i); } } } diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Pie.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Pie.java index cef68e11f..a63bd5590 100644 --- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Pie.java +++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Pie.java @@ -210,7 +210,10 @@ private void paintSlices(Graphics2D g, Rectangle2D pieBounds, double total, doub - Math.sin(Math.toRadians(angle)) * (pieBounds.getHeight() / 2 * pieStyler.getLabelsDistance()); - interactionData.addToolTip(toolTipShape, xOffset, yOffset + 10, 0, toolTipLabel); + // a pie series is a single slice, so its only data point is index 0 + interactionData + .addToolTip(toolTipShape, xOffset, yOffset + 10, 0, toolTipLabel) + .withSeries(series, 0); } // TOOLTIPS //////////////////////////////////////////////////// diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Radar.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Radar.java index 6c401dc21..551bc0757 100644 --- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Radar.java +++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Radar.java @@ -249,7 +249,7 @@ else if (styler.getRadarRenderStyle() == RadarStyler.RadarRenderStyle.Polygon) { String ystr = decimalFormat.format(value); label = series.getName() + " (" + radiiLabels[i] + ": " + ystr + ")"; } - interactionData.addToolTip(xOffset, yOffset, label); + interactionData.addToolTip(xOffset, yOffset, label).withSeries(series, i); } } path.closePath(); diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_XY.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_XY.java index d53a5b164..91c77159b 100644 --- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_XY.java +++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_XY.java @@ -277,7 +277,7 @@ public void doPaint(Graphics2D g) { yOffset, axesChart.getXAxisFormat().format(x), axesChart.getYAxisFormat(series.getYAxisDecimalPattern()).format(yOrig)) - .withSeries(series.getName(), i); + .withSeries(series, i); } if (interactionData != null) { diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotInteractionData.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotInteractionData.java index 67c982958..a863354eb 100644 --- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotInteractionData.java +++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotInteractionData.java @@ -1,13 +1,28 @@ package org.knowm.xchart.internal.chartpart; import java.awt.Shape; +import java.awt.geom.Ellipse2D; import java.awt.geom.Rectangle2D; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import org.knowm.xchart.ChartDataPoint; +import org.knowm.xchart.ToolTipGenerator; +import org.knowm.xchart.internal.series.Series; class PlotInteractionData { + // matches the marker highlight size used by ToolTips.ToolTip + private static final double DEFAULT_MARKER_MARGIN = 5; + + /** The default hit shape used when a tooltip is added without an explicit shape. */ + static Shape defaultMarkerShape(double x, double y) { + + double halfSize = DEFAULT_MARKER_MARGIN * 1.5; + double markerSize = DEFAULT_MARKER_MARGIN * 3; + return new Ellipse2D.Double(x - halfSize, y - halfSize, markerSize, markerSize); + } + Rectangle2D plotBounds; Rectangle2D getPlotBounds() { @@ -71,6 +86,7 @@ static final class ToolTipData { final String label; // non-null for single-label case String seriesName; // series identity, null if the chart type doesn't report it int dataPointIndex = -1; // index within the series, -1 if not reported + String customLabel; // from the series' ToolTipGenerator, null for the default label ToolTipData( Shape shape, @@ -95,6 +111,29 @@ ToolTipData withSeries(String seriesName, int dataPointIndex) { this.dataPointIndex = dataPointIndex; return this; } + + /** + * Attaches series identity to this tooltip and, if the series has a {@link ToolTipGenerator}, + * applies it to replace the default label. A custom label always takes precedence over the + * default label the chart type built, including the deprecated per-point tooltip strings. + */ + ToolTipData withSeries(Series series, int dataPointIndex) { + + withSeries(series.getName(), dataPointIndex); + ToolTipGenerator generator = series.getToolTipGenerator(); + if (generator != null) { + Shape hitShape = shape != null ? shape : defaultMarkerShape(x, y); + customLabel = + generator.generateToolTip( + new ChartDataPoint(seriesName, dataPointIndex, label, xValue, yValue, x, y, hitShape)); + } + return this; + } + + /** The label to display: the generator's custom label if one was produced, else null. */ + String getCustomLabel() { + return customLabel; + } } static final class CursorData { diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/ToolTips.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/ToolTips.java index 6c98752da..2e9c8b759 100644 --- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/ToolTips.java +++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/ToolTips.java @@ -120,10 +120,11 @@ public void paint(Graphics2D g) { // TODO need this null check?? if (tooltip != null) { // dataPoint was created in mouse move, need to render it - // TODO See OHLC04. The line series are rendering as multi-line. Can we just define the - // tooltip during creation and if it's multiline, paint it - // as multiline?? - if (styler instanceof BoxStyler || styler instanceof OHLCStyler) { + // multi-line labels (Box/OHLC defaults, or custom labels containing line separators) get + // the multi-line treatment; everything else is painted as a single line + if (styler instanceof BoxStyler + || styler instanceof OHLCStyler + || tooltip.label.contains(System.lineSeparator())) { paintMultiLineToolTip(g); } else { paintToolTip(g, tooltip); @@ -338,7 +339,14 @@ public void setData(PlotInteractionData data) { } plotBounds = data.getPlotBounds(); for (PlotInteractionData.ToolTipData td : data.getToolTipDataList()) { - if (td.label != null) { + // a custom label from the series' ToolTipGenerator replaces whatever default was built + if (td.getCustomLabel() != null) { + if (td.shape != null) { + addData(td.shape, td.x, td.y, td.w, td.getCustomLabel()); + } else { + addData(td.x, td.y, td.getCustomLabel()); + } + } else if (td.label != null) { if (td.shape != null) { addData(td.shape, td.x, td.y, td.w, td.label); } else { diff --git a/xchart/src/main/java/org/knowm/xchart/internal/series/Series.java b/xchart/src/main/java/org/knowm/xchart/internal/series/Series.java index 91c21696c..ace052012 100644 --- a/xchart/src/main/java/org/knowm/xchart/internal/series/Series.java +++ b/xchart/src/main/java/org/knowm/xchart/internal/series/Series.java @@ -1,6 +1,7 @@ package org.knowm.xchart.internal.series; import java.awt.*; +import org.knowm.xchart.ToolTipGenerator; import org.knowm.xchart.internal.chartpart.RenderableSeries.LegendRenderType; /** A Series to be plotted on a Chart */ @@ -13,6 +14,9 @@ public abstract class Series { private boolean showInLegend = true; private boolean isEnabled = true; + /** generates custom tooltip text for this series' data points; null means default labels */ + private ToolTipGenerator toolTipGenerator; + // TODO there is not always a y-axis group (pie chart for example) move this to an axis series // tyoe?? private int yAxisGroup = 0; @@ -112,6 +116,25 @@ public Series setYAxisDecimalPattern(String yAxisDecimalPattern) { return this; } + public ToolTipGenerator getToolTipGenerator() { + + return toolTipGenerator; + } + + /** + * Set a generator that produces custom tooltip text for this series' data points, replacing the + * default label built from the formatted axis values. The generator is called once per rendered + * data point on every repaint; returning {@code null} for a data point falls back to the default + * label. Requires tooltips to be enabled on the chart's styler. + * + * @param toolTipGenerator the generator, or {@code null} to restore the default labels + */ + public Series setToolTipGenerator(ToolTipGenerator toolTipGenerator) { + + this.toolTipGenerator = toolTipGenerator; + return this; + } + public enum DataType { Number, Date, diff --git a/xchart/src/test/java/org/knowm/xchart/internal/chartpart/ToolTipGeneratorTest.java b/xchart/src/test/java/org/knowm/xchart/internal/chartpart/ToolTipGeneratorTest.java new file mode 100644 index 000000000..5812199db --- /dev/null +++ b/xchart/src/test/java/org/knowm/xchart/internal/chartpart/ToolTipGeneratorTest.java @@ -0,0 +1,190 @@ +package org.knowm.xchart.internal.chartpart; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.knowm.xchart.BubbleChart; +import org.knowm.xchart.BubbleChartBuilder; +import org.knowm.xchart.BubbleSeries; +import org.knowm.xchart.ChartDataPoint; +import org.knowm.xchart.HeatMapChart; +import org.knowm.xchart.HeatMapChartBuilder; +import org.knowm.xchart.XYChart; +import org.knowm.xchart.XYChartBuilder; + +// https://github.com/knowm/XChart/issues/679 +// A ToolTipGenerator set on any series replaces the default tooltip labels with custom text built +// from the ChartDataPoint (series name + data point index + default labels). +// +// Exercises the interaction data directly (no XChartPanel / Swing display) so it runs headless on +// CI. +class ToolTipGeneratorTest { + + /** Accesses the package-private interaction data through a same-package Chart reference. */ + private static PlotInteractionData interactionData(Chart chart) { + + return chart.getInteractionData(); + } + + /** Paints the chart into an off-screen image so the interaction data gets collected. */ + private static void render(Chart chart) { + + BufferedImage image = + new BufferedImage(chart.getWidth(), chart.getHeight(), BufferedImage.TYPE_INT_ARGB); + Graphics2D g = image.createGraphics(); + chart.paint(g, chart.getWidth(), chart.getHeight()); + g.dispose(); + } + + @Test + void generatorReplacesDefaultLabelsOnXYChart() { + + XYChart chart = new XYChartBuilder().width(800).height(600).build(); + chart + .addSeries("s", new double[] {1, 2, 3}, new double[] {10, 20, 30}) + .setToolTipGenerator(dataPoint -> "custom #" + dataPoint.getDataPointIndex()); + chart.enableInteractionData(); + + render(chart); + + List toolTips = + interactionData(chart).getToolTipDataList(); + assertThat(toolTips).hasSize(3); + for (int i = 0; i < 3; i++) { + assertThat(toolTips.get(i).getCustomLabel()).isEqualTo("custom #" + i); + } + } + + @Test + void generatorReceivesSeriesIdentityAndDefaultLabels() { + + List seen = new ArrayList<>(); + + XYChart chart = new XYChartBuilder().width(800).height(600).build(); + chart + .addSeries("mySeries", new double[] {1, 2}, new double[] {10, 20}) + .setToolTipGenerator( + dataPoint -> { + seen.add(dataPoint); + return null; // fall back to the default label + }); + chart.enableInteractionData(); + + render(chart); + + assertThat(seen).hasSize(2); + assertThat(seen.get(0).getSeriesName()).isEqualTo("mySeries"); + assertThat(seen.get(0).getDataPointIndex()).isZero(); + assertThat(seen.get(1).getDataPointIndex()).isEqualTo(1); + // XY tooltips are x/y pairs, so the pair fields carry the default formatted values + assertThat(seen.get(1).getXValue()).isNotNull(); + assertThat(seen.get(1).getYValue()).isNotNull(); + + // generator returned null, so the default labels stay in effect + for (PlotInteractionData.ToolTipData td : interactionData(chart).getToolTipDataList()) { + assertThat(td.getCustomLabel()).isNull(); + } + } + + // the original issue #679 request: extra per-cell information on a heat map + @Test + void generatorWorksOnHeatMapCells() { + + HeatMapChart chart = new HeatMapChartBuilder().width(800).height(600).build(); + List xData = java.util.Arrays.asList(0, 1); + List yData = java.util.Arrays.asList(0, 1); + List heatData = new ArrayList<>(); + heatData.add(new Number[] {0, 0, 5}); + heatData.add(new Number[] {0, 1, 6}); + heatData.add(new Number[] {1, 0, 7}); + heatData.add(new Number[] {1, 1, 8}); + chart + .addSeries("heat", xData, yData, heatData) + .setToolTipGenerator( + dataPoint -> + "cell " + + dataPoint.getDataPointIndex() + + " extra info" + + System.lineSeparator() + + "default: " + + dataPoint.getLabel()); + chart.enableInteractionData(); + + render(chart); + + List toolTips = + interactionData(chart).getToolTipDataList(); + assertThat(toolTips).hasSize(4); + for (int i = 0; i < 4; i++) { + assertThat(toolTips.get(i).seriesName).isEqualTo("heat"); + assertThat(toolTips.get(i).getCustomLabel()) + .startsWith("cell " + i + " extra info") + .contains("default: heat: "); + } + } + + @Test + void generatorTakesPrecedenceOverDeprecatedPerPointStrings() { + + BubbleChart chart = new BubbleChartBuilder().width(800).height(600).build(); + BubbleSeries series = + chart.addSeries("b", new double[] {1, 2}, new double[] {10, 20}, new double[] {5, 5}); + series.setCustomToolTips(true).setToolTips(new String[] {"old 0", "old 1"}); + series.setToolTipGenerator(dataPoint -> "new " + dataPoint.getDataPointIndex()); + chart.enableInteractionData(); + + render(chart); + + List toolTips = + interactionData(chart).getToolTipDataList(); + assertThat(toolTips).hasSize(2); + for (int i = 0; i < 2; i++) { + // the deprecated string became the default label, the generator's label wins + assertThat(toolTips.get(i).label).isEqualTo("old " + i); + assertThat(toolTips.get(i).getCustomLabel()).isEqualTo("new " + i); + } + } + + @Test + void deprecatedPerPointStringsStillWorkWithoutGenerator() { + + BubbleChart chart = new BubbleChartBuilder().width(800).height(600).build(); + chart + .addSeries("b", new double[] {1, 2}, new double[] {10, 20}, new double[] {5, 5}) + .setCustomToolTips(true) + .setToolTips(new String[] {"old 0", "old 1"}); + chart.enableInteractionData(); + + render(chart); + + List toolTips = + interactionData(chart).getToolTipDataList(); + assertThat(toolTips).hasSize(2); + for (int i = 0; i < 2; i++) { + assertThat(toolTips.get(i).label).isEqualTo("old " + i); + assertThat(toolTips.get(i).getCustomLabel()).isNull(); + } + } + + @Test + void dispatcherReportsTheDisplayedCustomLabel() { + + XYChart chart = new XYChartBuilder().width(800).height(600).build(); + chart + .addSeries("s", new double[] {1}, new double[] {10}) + .setToolTipGenerator(dataPoint -> "displayed"); + chart.enableInteractionData(); + + render(chart); + + DataPointDispatcher dispatcher = new DataPointDispatcher(new ArrayList<>()); + dispatcher.setData(interactionData(chart)); + + PlotInteractionData.ToolTipData td = interactionData(chart).getToolTipDataList().get(0); + assertThat(dispatcher.isOverDataPoint((int) td.x, (int) td.y)).isTrue(); + } +}