blob: 30c43f0dd405319a2258d2ab8926993dac47715a [file] [log] [blame]
/*******************************************************************************
* Copyright (c) 2016 École Polytechnique de Montréal
*
* All rights reserved. This program and the accompanying materials are
* made available under the terms of the Eclipse Public License 2.0 which
* accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*******************************************************************************/
package org.eclipse.tracecompass.internal.tmf.chart.ui.swtchart;
import static org.eclipse.tracecompass.common.core.NonNullUtils.checkNotNull;
import java.text.Format;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Set;
import java.util.function.Predicate;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.osgi.util.NLS;
import org.eclipse.swt.SWT;
import org.eclipse.swt.events.MouseAdapter;
import org.eclipse.swt.events.MouseEvent;
import org.eclipse.swt.events.MouseMoveListener;
import org.eclipse.swt.events.PaintEvent;
import org.eclipse.swt.events.PaintListener;
import org.eclipse.swt.graphics.Color;
import org.eclipse.swt.graphics.GC;
import org.eclipse.swt.graphics.Point;
import org.eclipse.swt.graphics.Rectangle;
import org.eclipse.swt.widgets.Composite;
import org.eclipse.swt.widgets.Display;
import org.eclipse.swt.widgets.Event;
import org.eclipse.swt.widgets.Listener;
import org.eclipse.tracecompass.common.core.NonNullUtils;
import org.eclipse.tracecompass.internal.provisional.tmf.chart.core.chart.ChartData;
import org.eclipse.tracecompass.internal.provisional.tmf.chart.core.chart.ChartModel;
import org.eclipse.tracecompass.internal.provisional.tmf.chart.core.chart.ChartSeries;
import org.eclipse.tracecompass.internal.provisional.tmf.chart.core.descriptor.DataChartNumericalDescriptor;
import org.eclipse.tracecompass.internal.provisional.tmf.chart.core.descriptor.DataChartStringDescriptor;
import org.eclipse.tracecompass.internal.provisional.tmf.chart.core.descriptor.IDescriptorVisitor;
import org.eclipse.tracecompass.internal.provisional.tmf.chart.core.resolver.INumericalResolver;
import org.eclipse.tracecompass.internal.provisional.tmf.chart.core.resolver.IStringResolver;
import org.eclipse.tracecompass.internal.provisional.tmf.chart.core.signal.ChartSelectionUpdateSignal;
import org.eclipse.tracecompass.internal.provisional.tmf.chart.ui.chart.IChartViewer;
import org.eclipse.tracecompass.internal.tmf.chart.core.aggregator.IConsumerAggregator;
import org.eclipse.tracecompass.internal.tmf.chart.core.consumer.IDataConsumer;
import org.eclipse.tracecompass.internal.tmf.chart.core.consumer.NumericalConsumer;
import org.eclipse.tracecompass.internal.tmf.chart.ui.aggregator.NumericalConsumerAggregator;
import org.eclipse.tracecompass.internal.tmf.chart.ui.consumer.ScatterStringConsumer;
import org.eclipse.tracecompass.internal.tmf.chart.ui.consumer.XYChartConsumer;
import org.eclipse.tracecompass.internal.tmf.chart.ui.consumer.XYSeriesConsumer;
import org.eclipse.tracecompass.internal.tmf.chart.ui.data.ChartRangeMap;
import org.eclipse.tracecompass.internal.tmf.chart.ui.dialog.Messages;
import org.eclipse.tracecompass.internal.tmf.chart.ui.format.LabelFormat;
import org.eclipse.tracecompass.tmf.core.signal.TmfSignalManager;
import org.swtchart.Chart;
import org.swtchart.IAxis;
import org.swtchart.IAxisSet;
import org.swtchart.IAxisTick;
import org.swtchart.ILineSeries;
import org.swtchart.ISeries;
import org.swtchart.ISeries.SeriesType;
import org.swtchart.ISeriesSet;
import org.swtchart.LineStyle;
import com.google.common.collect.BiMap;
import com.google.common.collect.HashBiMap;
import com.google.common.collect.Iterators;
/**
* Class for building a scatter chart.
*
* FIXME: In this class, each method have if/then/else structure to cover string
* or numerical axes. The specificities for each type of axes should be wrapped
* in a small inline class that cover only the specific string or numerical
* case. We wouldn't need the ranges and string maps all in the main class, each
* sub-class would have only the fields it needs and it will be less
* error-prone.
*
* @author Gabriel-Andrew Pollo-Guilbert
*/
public final class SwtScatterChart extends SwtXYChartViewer {
// ------------------------------------------------------------------------
// Constants
// ------------------------------------------------------------------------
private static final int SELECTION_SNAP_RANGE_MULTIPLIER = 20;
// ------------------------------------------------------------------------
// Members
// ------------------------------------------------------------------------
/**
* Map linking X string categories to integer
*
* FIXME: Either the string map or a range is used for each axis, so instead
* of having them both, we should try to group the concept in subclasses
*/
private final BiMap<String, Integer> fXStringMap = HashBiMap.create();
/**
* Map linking Y string categories to integer
*/
private final BiMap<String, Integer> fYStringMap = HashBiMap.create();
/**
* Range map for the X axis
*/
private ChartRangeMap fXRanges = new ChartRangeMap();
/**
* Range map for the Y axis
*/
private ChartRangeMap fYRanges = new ChartRangeMap();
/**
* Map used for showing X categories on the axis
*/
private BiMap<String, Integer> fVisibleXMap = HashBiMap.create();
/**
* Map used for showing Y categories on the axis
*/
private BiMap<String, Integer> fVisibleYMap = HashBiMap.create();
/**
* Coordinates in pixels of the currently hovered point
*/
private Point fHoveringPoint = new Point(-1, -1);
/**
* The SWT reference of the currently hovered point
*/
private @Nullable SwtChartPoint fHoveredPoint;
// ------------------------------------------------------------------------
// Constructors
// ------------------------------------------------------------------------
/**
* Constructor.
*
* @param parent
* parent composite
* @param data
* configured data series for the chart
* @param model
* chart model to use
*/
public SwtScatterChart(Composite parent, ChartData data, ChartModel model) {
super(parent, data, model);
/* Add the mouse hovering listener */
getChart().getPlotArea().addMouseMoveListener(new MouseHoveringListener());
/* Add the mouse exit listener */
getChart().getPlotArea().addListener(SWT.MouseExit, new MouseExitListener());
/* Add the mouse click listener */
getChart().getPlotArea().addMouseListener(new MouseDownListener());
/* Add the paint listener */
getChart().getPlotArea().addPaintListener(new ScatterPainterListener());
populate();
}
// ------------------------------------------------------------------------
// Overriden methods
// ------------------------------------------------------------------------
// FIXME: This is not SWTchart-specific, it should go higher up
private class ConsumerCreatorVisitor implements IDescriptorVisitor {
private final boolean fLogScale;
private final BiMap<String, Integer> fMap;
private @Nullable IDataConsumer fConsumer;
ConsumerCreatorVisitor(boolean logScale, BiMap<String, Integer> bimap) {
fLogScale = logScale;
fMap = bimap;
}
@Override
public void visit(@NonNull DataChartStringDescriptor<?> desc) {
fConsumer = new ScatterStringConsumer(IStringResolver.class.cast(desc.getResolver()), fMap);
}
@Override
public void visit(@NonNull DataChartNumericalDescriptor<?, ? extends @NonNull Number> desc) {
/*
* FIXME: Can this visitor be made generic so that we can have the
* right parameters and not need to cast the resolver here?
*/
INumericalResolver<Object, Number> resolver = INumericalResolver.class.cast(desc.getResolver());
Predicate<@Nullable Number> predicate;
if (fLogScale) {
predicate = new LogarithmicPredicate(resolver);
} else {
predicate = Objects::nonNull;
}
/* Create a consumer for the X descriptor */
fConsumer = new NumericalConsumer(resolver, predicate);
}
public IDataConsumer getConsumer() {
IDataConsumer consumer = fConsumer;
if (consumer == null) {
throw new NullPointerException("The getConsumer method of the visitor should not be called before visiting a descriptor"); //$NON-NLS-1$
}
return consumer;
}
}
@Override
protected IDataConsumer getXConsumer(@NonNull ChartSeries series) {
ConsumerCreatorVisitor visitor = new ConsumerCreatorVisitor(getModel().isXLogscale(), fXStringMap);
series.getX().accept(visitor);
return visitor.getConsumer();
}
@Override
protected IDataConsumer getYConsumer(@NonNull ChartSeries series) {
ConsumerCreatorVisitor visitor = new ConsumerCreatorVisitor(getModel().isYLogscale(), fYStringMap);
series.getY().accept(visitor);
return visitor.getConsumer();
}
@Override
protected @Nullable IConsumerAggregator getXAggregator() {
if (getXDescriptorsInfo().areNumerical()) {
return new NumericalConsumerAggregator();
}
return null;
}
@Override
protected @Nullable IConsumerAggregator getYAggregator() {
if (getYDescriptorsInfo().areNumerical()) {
return new NumericalConsumerAggregator();
}
return null;
}
@Override
protected ISeries createSwtSeries(ChartSeries chartSeries, ISeriesSet swtSeriesSet, @NonNull Color color) {
String title = chartSeries.getY().getName();
if (getXDescriptors().stream().distinct().count() > 1) {
title = NLS.bind(Messages.ChartSeries_MultiSeriesTitle, title, chartSeries.getX().getLabel());
}
ILineSeries swtSeries = (ILineSeries) swtSeriesSet.createSeries(SeriesType.LINE, title);
swtSeries.setLineStyle(LineStyle.NONE);
swtSeries.setSymbolColor(color);
return swtSeries;
}
@Override
protected void configureSeries(Map<@NonNull ISeries, Object[]> mapper) {
XYChartConsumer chartConsumer = getChartConsumer();
/* Obtain the X ranges if possible */
NumericalConsumerAggregator xAggregator = (NumericalConsumerAggregator) chartConsumer.getXAggregator();
if (xAggregator != null) {
if (getModel().isXLogscale()) {
fXRanges = clampInputDataRange(xAggregator.getChartRanges());
} else {
fXRanges = xAggregator.getChartRanges();
}
}
/* Obtain the Y ranges if possible */
NumericalConsumerAggregator yAggregator = (NumericalConsumerAggregator) chartConsumer.getYAggregator();
if (yAggregator != null) {
if (getModel().isYLogscale()) {
fYRanges = clampInputDataRange(yAggregator.getChartRanges());
} else {
fYRanges = yAggregator.getChartRanges();
}
}
/* Generate data for each SWT series */
for (XYSeriesConsumer seriesConsumer : chartConsumer.getSeries()) {
double[] xData;
double[] yData;
Object[] object = seriesConsumer.getConsumedElements().toArray();
/* Generate data for the X axis */
if (getXDescriptorsInfo().areNumerical()) {
NumericalConsumer consumer = (NumericalConsumer) seriesConsumer.getXConsumer();
List<Number> data = consumer.getData();
int size = data.size();
xData = new double[size];
for (int i = 0; i < size; i++) {
Number number = checkNotNull(data.get(i));
xData[i] = fXRanges.getInternalValue(number).doubleValue();
}
} else {
ScatterStringConsumer consumer = (ScatterStringConsumer) seriesConsumer.getXConsumer();
List<String> list = consumer.getList();
xData = new double[list.size()];
for (int i = 0; i < xData.length; i++) {
String str = list.get(i);
xData[i] = checkNotNull(fXStringMap.get(str));
}
}
/* Generate data for the Y axis */
if (getYDescriptorsInfo().areNumerical()) {
NumericalConsumer consumer = (NumericalConsumer) seriesConsumer.getYConsumer();
List<Number> data = consumer.getData();
yData = new double[data.size()];
for (int i = 0; i < yData.length; i++) {
Number number = checkNotNull(data.get(i));
yData[i] = fYRanges.getInternalValue(number).doubleValue();
}
} else {
ScatterStringConsumer consumer = (ScatterStringConsumer) seriesConsumer.getYConsumer();
List<String> list = consumer.getList();
yData = new double[list.size()];
for (int i = 0; i < yData.length; i++) {
String str = list.get(i);
yData[i] = checkNotNull(fYStringMap.get(str));
}
}
/* Set the data for the SWT series */
ISeries series = checkNotNull(getSeriesMap().get(seriesConsumer.getSeries()));
series.setXSeries(xData);
series.setYSeries(yData);
/* Create a series mapper */
mapper.put(series, checkNotNull(object));
}
}
@Override
protected void configureAxes() {
/* Format X axes */
Stream.of(getChart().getAxisSet().getXAxes()).forEach(a -> {
IAxisTick tick = checkNotNull(a.getTick());
Format format;
/* Give a continuous formatter if the descriptors are numericals */
if (getXDescriptorsInfo().areNumerical()) {
format = getContinuousAxisFormatter(fXRanges, getXDescriptorsInfo());
} else {
fVisibleXMap = HashBiMap.create(fXStringMap);
format = new LabelFormat(fVisibleXMap);
updateTickMark(fVisibleXMap, tick, getChart().getPlotArea().getSize().x);
}
tick.setFormat(format);
});
/* Format Y axes */
Stream.of(getChart().getAxisSet().getYAxes()).forEach(a -> {
IAxisTick tick = checkNotNull(a.getTick());
Format format;
/* Give a continuous formatter if the descriptors are numericals. */
if (getYDescriptorsInfo().areNumerical()) {
format = getContinuousAxisFormatter(fYRanges, getYDescriptorsInfo());
} else {
fVisibleYMap = HashBiMap.create(fYStringMap);
format = new LabelFormat(fVisibleYMap);
updateTickMark(fVisibleYMap, tick, getChart().getPlotArea().getSize().y);
}
tick.setFormat(format);
});
}
@Override
protected void refreshDisplayLabels() {
/**
* TODO: support for the Y axis too
*/
/* Only refresh if labels are visible */
Chart chart = getChart();
IAxisSet axisSet = chart.getAxisSet();
IAxis xAxis = axisSet.getXAxis(0);
if (!xAxis.getTick().isVisible()) {
return;
}
/*
* Shorten all the labels to 5 characters plus "…" when the longest
* label length is more than 50% of the chart height.
*/
Rectangle rect = chart.getClientArea();
int lengthLimit = (int) (rect.height * 0.40);
GC gc = new GC(getParent());
gc.setFont(xAxis.getTick().getFont());
// FIXME: the refresh of labels should be done differently for numerical
// or string axes. Here this only refreshes the X axis labels for string
// labels.
if (!fXStringMap.isEmpty()) {
/* Find the longest category string */
String longestString = Collections.max(fXStringMap.keySet(), Comparator.comparingInt(String::length));
/* Get the length and height of the longest label in pixels */
Point pixels = gc.stringExtent(longestString);
/* Completely arbitrary */
int cutLen = 5;
if (pixels.x > lengthLimit) {
/* We have to cut down some strings */
for (Entry<String, Integer> entry : fXStringMap.entrySet()) {
String reference = checkNotNull(entry.getKey());
if (reference.length() > cutLen) {
String key = reference.substring(0, cutLen) + ELLIPSIS;
fVisibleXMap.remove(reference);
fVisibleXMap.put(key, entry.getValue());
} else {
fVisibleXMap.inverse().remove(entry.getValue());
fVisibleXMap.put(reference, entry.getValue());
}
}
} else {
/* All strings should fit */
resetBiMap(fXStringMap, fVisibleXMap);
}
for (IAxis axis : axisSet.getXAxes()) {
IAxisTick tick = axis.getTick();
tick.setFormat(new LabelFormat(fVisibleXMap));
}
}
/* Cleanup */
gc.dispose();
}
@Override
protected void setSelection(@NonNull Set<@NonNull Object> set) {
super.setSelection(set);
/* Set color of selected symbol */
Iterator<Color> colorsIt = Iterators.cycle(COLORS);
Iterator<Color> lightColorsIt = Iterators.cycle(COLORS_LIGHT);
for (ISeries series : getChart().getSeriesSet().getSeries()) {
/* Series color */
Color lightColor = NonNullUtils.checkNotNull(lightColorsIt.next());
Color color = NonNullUtils.checkNotNull(colorsIt.next());
if (set.isEmpty()) {
/* Put all symbols to the normal colors */
((ILineSeries) series).setSymbolColor(color);
} else {
/*
* Fill with light colors to represent the deselected state. The
* paint listener is then responsible for drawing the cross and
* the dark colors for the selection.
*/
((ILineSeries) series).setSymbolColor(lightColor);
}
}
}
// ------------------------------------------------------------------------
// Util methods
// ------------------------------------------------------------------------
/**
* Util method used to reset a bimap from a reference.
*
* @param reference
* Reference map
* @param map
* Map to modify
*/
public static <K, V> void resetBiMap(BiMap<K, V> reference, BiMap<K, V> map) {
map.clear();
map.putAll(reference);
}
// ------------------------------------------------------------------------
// Listeners
// ------------------------------------------------------------------------
private final class MouseHoveringListener implements MouseMoveListener {
@Override
public void mouseMove(@Nullable MouseEvent event) {
if (event == null) {
return;
}
double closestDistance = -1.0;
boolean found = false;
for (ISeries swtSeries : getChart().getSeriesSet().getSeries()) {
ILineSeries series = (ILineSeries) swtSeries;
double[] xSeries = series.getXSeries();
for (int i = 0; i < xSeries.length; i++) {
Point dataPoint = series.getPixelCoordinates(i);
/*
* Find the distance between the data point and the mouse
* location and compare it to the symbol size * the range
* multiplier, so when a user hovers the mouse near the dot
* the cursor cross snaps to it.
*/
int snapRangeRadius = series.getSymbolSize() * SELECTION_SNAP_RANGE_MULTIPLIER;
/*
* FIXME: if and only if performance of this code is an
* issue for large sets, this can be accelerated by getting
* the distance squared, and if it is smaller than
* snapRangeRadius squared, then check hypot.
*/
double distance = Math.hypot(dataPoint.x - event.x, dataPoint.y - event.y);
if (distance < snapRangeRadius && (closestDistance == -1 || distance < closestDistance)) {
fHoveringPoint.x = dataPoint.x;
fHoveringPoint.y = dataPoint.y;
fHoveredPoint = new SwtChartPoint(series, i);
closestDistance = distance;
found = true;
}
}
}
/* Check if a point was found */
if (!found) {
fHoveredPoint = null;
}
refresh();
}
}
private final class MouseExitListener implements Listener {
@Override
public void handleEvent(@Nullable Event event) {
if (event != null) {
fHoveringPoint.x = -1;
fHoveringPoint.y = -1;
fHoveredPoint = null;
refresh();
}
}
}
private final class MouseDownListener extends MouseAdapter {
@Override
public void mouseDown(@Nullable MouseEvent event) {
if (event == null || event.button != 1) {
return;
}
/* Check if a point is hovered */
SwtChartPoint selection = fHoveredPoint;
if (selection == null) {
getSelection().clear();
} else {
boolean ctrl = (event.stateMask & SWT.CTRL) != 0;
getSelection().touch(selection, ctrl);
}
/* Redraw the selected points */
refresh();
/* Find these points map to which objects */
Set<Object> set = new HashSet<>();
for (SwtChartPoint point : getSelection().getPoints()) {
Object[] objects = checkNotNull(getObjectMap().get(point.getSeries()));
/* Add objects to the set */
Object obj = objects[point.getIndex()];
if (obj != null) {
set.add(obj);
}
}
/* Send the update signal */
setSelection(set);
ChartSelectionUpdateSignal signal = new ChartSelectionUpdateSignal(SwtScatterChart.this, getData().getDataProvider(), set);
TmfSignalManager.dispatchSignal(signal);
}
}
private final class ScatterPainterListener implements PaintListener {
@Override
public void paintControl(@Nullable PaintEvent event) {
if (event == null) {
return;
}
GC gc = event.gc;
if (gc == null) {
return;
}
/* Draw the hovering cross */
drawHoveringCross(gc);
/* Don't draw if there's no selection */
if (!getSelection().getPoints().isEmpty()) {
/* Draw the selected points */
drawSelectedDot(gc);
}
}
private void drawHoveringCross(GC gc) {
if (fHoveredPoint == null) {
return;
}
gc.setLineWidth(1);
gc.setLineStyle(SWT.LINE_SOLID);
gc.setForeground(Display.getCurrent().getSystemColor(SWT.COLOR_BLACK));
gc.setBackground(Display.getCurrent().getSystemColor(SWT.COLOR_WHITE));
/* Vertical line */
gc.drawLine(fHoveringPoint.x, 0, fHoveringPoint.x, getChart().getPlotArea().getSize().y);
/* Horizontal line */
gc.drawLine(0, fHoveringPoint.y, getChart().getPlotArea().getSize().x, fHoveringPoint.y);
}
private void drawSelectedDot(GC gc) {
for (SwtChartPoint point : getSelection().getPoints()) {
ISeries series = point.getSeries();
Point coor = series.getPixelCoordinates(point.getIndex());
int symbolSize = ((ILineSeries) series).getSymbolSize();
Color symbolColor = ((ILineSeries) series).getSymbolColor();
if (symbolColor == null) {
continue;
}
Color darkColor = IChartViewer.getCorrespondingColor(symbolColor);
/* Create a colored dot for selection */
gc.setBackground(darkColor);
gc.fillOval(coor.x - symbolSize, coor.y - symbolSize, symbolSize * 2, symbolSize * 2);
/* Configure cross settings */
gc.setLineWidth(2);
gc.setLineStyle(SWT.LINE_SOLID);
int drawingDelta = 2 * symbolSize;
/* Vertical line */
gc.drawLine(coor.x, coor.y - drawingDelta, coor.x, coor.y + drawingDelta);
/* Horizontal line */
gc.drawLine(coor.x - drawingDelta, coor.y, coor.x + drawingDelta, coor.y);
}
}
}
}