/******************************************************************************* * Copyright (c) 2016 EfficiOS Inc., Jonathan Rajotte-Julien * * All rights reserved. This program and the accompanying materials are * made available under the terms of the Eclipse Public License v1.0 which * accompanies this distribution, and is available at * http://www.eclipse.org/legal/epl-v10.html *******************************************************************************/ package org.eclipse.tracecompass.internal.provisional.analysis.lami.ui.viewers; import static org.eclipse.tracecompass.common.core.NonNullUtils.checkNotNull; import java.math.BigDecimal; import java.text.Format; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeSet; import java.util.stream.Collectors; import java.util.stream.Stream; import org.eclipse.jdt.annotation.NonNull; import org.eclipse.jdt.annotation.Nullable; 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.widgets.Composite; import org.eclipse.swt.widgets.Display; import org.eclipse.swt.widgets.Event; import org.eclipse.swt.widgets.Listener; import org.eclipse.tracecompass.internal.provisional.analysis.lami.core.aspect.LamiTableEntryAspect; import org.eclipse.tracecompass.internal.provisional.analysis.lami.core.module.LamiChartModel; import org.eclipse.tracecompass.internal.provisional.analysis.lami.core.module.LamiChartModel.ChartType; import org.eclipse.tracecompass.internal.provisional.analysis.lami.core.module.LamiTableEntry; import org.eclipse.tracecompass.internal.provisional.analysis.lami.ui.format.LamiLabelFormat; import org.eclipse.tracecompass.internal.provisional.analysis.lami.ui.format.LamiTimeStampFormat; import org.eclipse.tracecompass.internal.provisional.analysis.lami.ui.signals.LamiSelectionUpdateSignal; import org.eclipse.tracecompass.internal.provisional.analysis.lami.ui.views.LamiReportViewTabPage; import org.eclipse.tracecompass.tmf.core.signal.TmfSignalManager; import org.swtchart.IAxisTick; import org.swtchart.ILineSeries; import org.swtchart.ISeries; import org.swtchart.ISeries.SeriesType; import org.swtchart.LineStyle; import com.google.common.collect.BiMap; import com.google.common.collect.HashBiMap; import com.google.common.collect.Iterators; /** * XY Scatter chart viewer for Lami views * * @author Jonathan Rajotte-Julien */ public class LamiScatterViewer extends LamiXYChartViewer { private static final int SELECTION_SNAP_RANGE_MULTIPLIER = 20; private static final int SELECTION_CROSS_SIZE_MULTIPLIER = 3; private final Map<ISeries, List<Integer>> fIndexMapping; /* Use a scale from 0 to 1 internally for both axes */ private LamiGraphRange fXInternalRange = new LamiGraphRange(BigDecimal.ZERO, BigDecimal.ONE); private LamiGraphRange fYInternalRange = new LamiGraphRange(BigDecimal.ZERO, BigDecimal.ONE); private @Nullable LamiGraphRange fXExternalRange = null; private @Nullable LamiGraphRange fYExternalRange = null; /* The current data point for the hovering cross */ private Point fHoveringCrossDataPoint; /** * Constructor * * @param parent * parent * @param page * The {@link LamiReportViewTabPage} parent page * @param graphModel * Model of this chart */ public LamiScatterViewer(Composite parent, LamiReportViewTabPage page, LamiChartModel graphModel) { super(parent, page, graphModel); if (getChartModel().getChartType() != ChartType.XY_SCATTER) { throw new IllegalStateException("Chart type not a Scatter Chart " + getChartModel().getChartType().toString()); //$NON-NLS-1$ } /* Inspect X series */ fIndexMapping = new HashMap<>(); fHoveringCrossDataPoint = new Point(-1, -1); List<LamiTableEntryAspect> xAxisAspects = getXAxisAspects(); if (xAxisAspects.stream().distinct().count() == 1) { LamiTableEntryAspect singleXAspect = xAxisAspects.get(0); xAxisAspects.clear(); xAxisAspects.add(singleXAspect); } BiMap<@Nullable String, Integer> xMap = HashBiMap.create(); boolean xIsLog = graphModel.xAxisIsLog(); boolean areXAspectsContinuous = areAspectsContinuous(xAxisAspects); boolean areXAspectsTimeStamp = areAspectsTimeStamp(xAxisAspects); /* Check all aspect are the same type */ for (LamiTableEntryAspect aspect : xAxisAspects) { if (aspect.isContinuous() != areXAspectsContinuous) { throw new IllegalStateException("Some X aspects are continuous and some are not"); //$NON-NLS-1$ } if (aspect.isTimeStamp() != areXAspectsTimeStamp) { throw new IllegalStateException("Some X aspects are time based and some are not"); //$NON-NLS-1$ } } /* * When xAxisAspects are discrete create a map for all values of all * series */ if (!areXAspectsContinuous) { generateLabelMap(xAxisAspects, checkNotNull(xMap)); } else { /* * Always clamp the range to min and max * * TODO: in the future this could be based on the result of the * delta between max and min multiplied by a ratio like it is done in * LibreOffice Calc */ fXExternalRange = getRange(xAxisAspects, false); } /* * Create Y series */ List<LamiTableEntryAspect> yAxisAspects = getYAxisAspects(); BiMap<@Nullable String, Integer> yMap = HashBiMap.create(); boolean yIsLog = graphModel.yAxisIsLog(); boolean areYAspectsContinuous = areAspectsContinuous(yAxisAspects); boolean areYAspectsTimeStamp = areAspectsTimeStamp(yAxisAspects); /* Check all aspect are the same type */ for (LamiTableEntryAspect aspect : yAxisAspects) { if (aspect.isContinuous() != areYAspectsContinuous) { throw new IllegalStateException("Some Y aspects are continuous and some are not"); //$NON-NLS-1$ } if (aspect.isTimeStamp() != areYAspectsTimeStamp) { throw new IllegalStateException("Some Y aspects are time based and some are not"); //$NON-NLS-1$ } } /* * When yAspects are discrete create a map for all values of all series */ if (!areYAspectsContinuous) { generateLabelMap(yAxisAspects, yMap); } else { /* * Only clamp the range to the minimum value if it is a time stamp since * plotting from 1970 would make little sense. */ fYExternalRange = getRange(yAxisAspects, areYAspectsTimeStamp); } /* Plot the series */ int index = 0; for (LamiTableEntryAspect yAspect : getYAxisAspects()) { String name; LamiTableEntryAspect xAspect; if (xAxisAspects.size() == 1) { /* Always map to the same x series */ xAspect = xAxisAspects.get(0); name = yAspect.getLabel(); } else { xAspect = xAxisAspects.get(index); name = (yAspect.getName() + ' ' + Messages.LamiScatterViewer_by + ' ' + xAspect.getName()); } List<@Nullable Double> xDoubleSeries; List<@Nullable Double> yDoubleSeries; if (xAspect.isContinuous()) { xDoubleSeries = getResultTable().getEntries().stream() .map(entry -> { Number number = xAspect.resolveNumber(entry); if (number != null && fXExternalRange != null) { return getInternalDoubleValue(number, fXInternalRange, fXExternalRange); } return null; }) .collect(Collectors.toList()); } else { xDoubleSeries = getResultTable().getEntries().stream() .map(entry -> { String string = xAspect.resolveString(entry); Integer value = xMap.get(string); if (value != null) { return Double.valueOf(value.doubleValue()); } return null; }) .collect(Collectors.toList()); } if (yAspect.isContinuous()) { yDoubleSeries = getResultTable().getEntries().stream() .map(entry -> { Number number = yAspect.resolveNumber(entry); if (number != null && fYExternalRange != null) { return getInternalDoubleValue(number, fYInternalRange, fYExternalRange); } return null; }) .collect(Collectors.toList()); } else { yDoubleSeries = getResultTable().getEntries().stream() .map(entry -> { String string = yAspect.resolveString(entry); Integer value = yMap.get(string); if (value != null) { return Double.valueOf(value.doubleValue()); } return null; }) .collect(Collectors.toList()); } List<@Nullable Double> validXDoubleSeries = new ArrayList<>(); List<@Nullable Double> validYDoubleSeries = new ArrayList<>(); List<Integer> indexSeriesCorrespondance = new ArrayList<>(); if (xDoubleSeries.size() != yDoubleSeries.size()) { throw new IllegalStateException("Series sizes don't match!"); //$NON-NLS-1$ } /* Check for invalid tuple value. Any null elements are invalid */ for (int i = 0; i < xDoubleSeries.size(); i++) { Double xValue = xDoubleSeries.get(i); Double yValue = yDoubleSeries.get(i); if (xValue == null || yValue == null) { /* Reject this tuple */ continue; } if ((xIsLog && xValue <= ZERO_DOUBLE) || (yIsLog && yValue <= ZERO_DOUBLE)) { /* * Equal or less than 0 values can't be plotted on log scale */ continue; } validXDoubleSeries.add(xValue); validYDoubleSeries.add(yValue); indexSeriesCorrespondance.add(i); } if (validXDoubleSeries.isEmpty() || validXDoubleSeries.isEmpty()) { /* No need to plot an empty series */ index++; continue; } ILineSeries scatterSeries = (ILineSeries) getChart().getSeriesSet().createSeries(SeriesType.LINE, name); scatterSeries.setLineStyle(LineStyle.NONE); double[] xserie = validXDoubleSeries.stream().mapToDouble(elem -> checkNotNull(elem).doubleValue()).toArray(); double[] yserie = validYDoubleSeries.stream().mapToDouble(elem -> checkNotNull(elem).doubleValue()).toArray(); scatterSeries.setXSeries(xserie); scatterSeries.setYSeries(yserie); fIndexMapping.put(scatterSeries, indexSeriesCorrespondance); index++; } /* Modify x axis related chart styling */ IAxisTick xTick = getChart().getAxisSet().getXAxis(0).getTick(); if (areXAspectsContinuous) { Format xAxisFormat = getContinuousAxisFormatter(xAxisAspects, getResultTable().getEntries(), fXInternalRange, fXExternalRange); xTick.setFormat(xAxisFormat); if (xAxisFormat instanceof LamiTimeStampFormat) { setXUnits(((LamiTimeStampFormat) xAxisFormat).getPattern()); } } else { xTick.setFormat(new LamiLabelFormat(checkNotNull(xMap))); updateTickMark(checkNotNull(xMap), xTick, getChart().getPlotArea().getSize().x); /* Remove vertical grid line */ getChart().getAxisSet().getXAxis(0).getGrid().setStyle(LineStyle.NONE); } /* Modify Y axis related chart styling */ IAxisTick yTick = getChart().getAxisSet().getYAxis(0).getTick(); if (areYAspectsContinuous) { Format yAxisFormat = getContinuousAxisFormatter(yAxisAspects, getResultTable().getEntries(), fYInternalRange, fYExternalRange); yTick.setFormat(yAxisFormat); if (yAxisFormat instanceof LamiTimeStampFormat) { setYUnits(((LamiTimeStampFormat) yAxisFormat).getPattern()); } } else { yTick.setFormat(new LamiLabelFormat(checkNotNull(yMap))); updateTickMark(checkNotNull(yMap), yTick, getChart().getPlotArea().getSize().y); /* Remove horizontal grid line */ getChart().getAxisSet().getYAxis(0).getGrid().setStyle(LineStyle.NONE); } /* * SWTChart workaround: SWTChart fiddles with tick mark visibility based * on the fact that it can parse the label to double or not. * * If the label happens to be a double, it checks for the presence of * that value in its own tick labels to decide if it should add it or * not. If it happens that the parsed value is already present in its * map, the tick gets a visibility of false. * * The X axis does not have this problem since SWTCHART checks on label * angle, and if it is != 0 simply does no logic regarding visibility. * So simply set a label angle of 1 to the axis. */ yTick.setTickLabelAngle(1); setLineSeriesColor(); /* Put log scale if necessary */ if (xIsLog && areXAspectsContinuous && !areXAspectsTimeStamp) { Stream.of(getChart().getAxisSet().getXAxes()).forEach(axis -> axis.enableLogScale(xIsLog)); } if (yIsLog && areYAspectsContinuous && !areYAspectsTimeStamp) { /* Set the axis as logscale */ Stream.of(getChart().getAxisSet().getYAxes()).forEach(axis -> axis.enableLogScale(yIsLog)); } getChart().getAxisSet().adjustRange(); /* * Selection listener */ getChart().getPlotArea().addMouseListener(new LamiScatterMouseDownListener()); /* * Hovering cross listener */ getChart().getPlotArea().addMouseMoveListener(new HoveringCrossListener()); /* * Mouse exit listener: reset state of hovering cross on mouse exit. */ getChart().getPlotArea().addListener(SWT.MouseExit, new Listener() { @Override public void handleEvent(@Nullable Event event) { if (event != null) { fHoveringCrossDataPoint.x = -1; fHoveringCrossDataPoint.y = -1; redraw(); } } }); /* * Selections and hovering cross painting */ getChart().getPlotArea().addPaintListener(new LamiScatterPainterListener()); /* On resize check for axis tick updating */ getChart().addListener(SWT.Resize, new Listener() { @Override public void handleEvent(@Nullable Event event) { if (yTick.getFormat() instanceof LamiLabelFormat) { updateTickMark(checkNotNull(yMap), yTick, getChart().getPlotArea().getSize().y); } if (xTick.getFormat() instanceof LamiLabelFormat) { updateTickMark(checkNotNull(xMap), xTick, getChart().getPlotArea().getSize().x); } } }); } private void generateLabelMap(List<LamiTableEntryAspect> aspects, BiMap<@Nullable String, Integer> map) { TreeSet<@Nullable String> set = new TreeSet<>(); for (LamiTableEntryAspect aspect : aspects) { for (LamiTableEntry entry : getResultTable().getEntries()) { String string = aspect.resolveString(entry); if (string != null) { set.add(string); } } } /* Ordered label mapping to double */ for (String string : set) { map.put(string, map.size()); } } /** * Set the chart series colors. */ private void setLineSeriesColor() { Iterator<Color> colorsIt; colorsIt = Iterators.cycle(COLORS); for (ISeries series : getChart().getSeriesSet().getSeries()) { ((ILineSeries) series).setSymbolColor((colorsIt.next())); /* * Generate initial array of Color to enable per point color change * on selection in the future */ ArrayList<Color> colors = new ArrayList<>(); for (int i = 0; i < series.getXSeries().length; i++) { Color color = ((ILineSeries) series).getSymbolColor(); colors.add(checkNotNull(color)); } ((ILineSeries) series).setSymbolColors(colors.toArray(new Color[colors.size()])); } } // ------------------------------------------------------------------------ // Listeners // ------------------------------------------------------------------------ private final class HoveringCrossListener implements MouseMoveListener { @Override public void mouseMove(@Nullable MouseEvent e) { if (e == null) { return; } ISeries[] series = getChart().getSeriesSet().getSeries(); @Nullable Point closest = null; double closestDistance = -1.0; for (ISeries oneSeries : series) { ILineSeries lineSerie = (ILineSeries) oneSeries; for (int i = 0; i < lineSerie.getXSeries().length; i++) { Point dataPoint = lineSerie.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 = lineSerie.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 - e.x, dataPoint.y - e.y); if (distance < snapRangeRadius) { if (closestDistance == -1 || distance < closestDistance) { closest = dataPoint; closestDistance = distance; } } } } if (closest != null) { fHoveringCrossDataPoint.x = closest.x; fHoveringCrossDataPoint.y = closest.y; } else { fHoveringCrossDataPoint.x = -1; fHoveringCrossDataPoint.y = -1; } refresh(); } } private final class LamiScatterMouseDownListener extends MouseAdapter { @Override public void mouseDown(@Nullable MouseEvent event) { if (event == null || event.button != 1) { return; } int xMouseLocation = event.x; int yMouseLocation = event.y; boolean ctrlMode = false; ISeries[] series = getChart().getSeriesSet().getSeries(); Set<Integer> selections = getSelection(); /* Check for ctrl on click */ if ((event.stateMask & SWT.CTRL) != 0) { selections = getSelection(); ctrlMode = true; } else { /* Reset selection */ unsetSelection(); selections = new HashSet<>(); } for (ISeries oneSeries : series) { ILineSeries lineSerie = (ILineSeries) oneSeries; int closest = -1; double closestDistance = -1; for (int i = 0; i < lineSerie.getXSeries().length; i++) { Point dataPoint = lineSerie.getPixelCoordinates(i); /* * Find the distance between the data point and the mouse * location, and compare it to the symbol size so when a * user clicks on a symbol it selects it. */ double distance = Math.hypot(dataPoint.x - xMouseLocation, dataPoint.y - yMouseLocation); int snapRangeRadius = lineSerie.getSymbolSize() * SELECTION_SNAP_RANGE_MULTIPLIER; if (distance < snapRangeRadius) { if (closestDistance == -1 || distance < closestDistance) { closest = i; closestDistance = distance; } } } if (closest != -1) { /* Translate to global index */ int tableEntryIndex = getTableEntryIndexFromGraphIndex(checkNotNull(oneSeries), closest); if (tableEntryIndex < 0) { continue; } LamiTableEntry entry = getResultTable().getEntries().get(tableEntryIndex); int index = getResultTable().getEntries().indexOf(entry); if (!ctrlMode || !selections.remove(index)) { selections.add(index); } /* Do no iterate since we already found a match */ break; } } setSelection(selections); /* Signal all Lami viewers & views of the selection */ LamiSelectionUpdateSignal signal = new LamiSelectionUpdateSignal(this, selections, getPage()); TmfSignalManager.dispatchSignal(signal); refresh(); } } private final class LamiScatterPainterListener implements PaintListener { @Override public void paintControl(@Nullable PaintEvent e) { if (e == null) { return; } GC gc = e.gc; /* Draw the selection */ drawSelectedDot(checkNotNull(gc)); /* Draw the hovering cross */ drawHoveringCross(checkNotNull(gc)); } private void drawSelectedDot(GC gc) { if (isSelected()) { Iterator<Color> colorsIt; colorsIt = Iterators.cycle(COLORS); for (ISeries series : getChart().getSeriesSet().getSeries()) { /* Get series colors */ Color color = colorsIt.next(); int symbolSize = ((ILineSeries) series).getSymbolSize(); for (int index : getInternalSelections()) { int graphIndex = getGraphIndexFromTableEntryIndex(series, index); if (graphIndex < 0) { continue; } Point point = series.getPixelCoordinates(graphIndex); /* Create a colored dot for selection */ gc.setBackground(color); gc.fillOval(point.x - symbolSize, point.y - symbolSize, symbolSize * 2, symbolSize * 2); /* Draw cross */ gc.setLineWidth(2); gc.setLineStyle(SWT.LINE_SOLID); /* Vertical line */ int drawingDelta = SELECTION_CROSS_SIZE_MULTIPLIER * symbolSize; gc.drawLine(point.x, point.y - drawingDelta, point.x, point.y + drawingDelta); /* Horizontal line */ gc.drawLine(point.x - drawingDelta, point.y, point.x + drawingDelta, point.y); } } } } private void drawHoveringCross(GC gc) { 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(fHoveringCrossDataPoint.x, 0, fHoveringCrossDataPoint.x, getChart().getPlotArea().getSize().y); /* Horizontal line */ gc.drawLine(0, fHoveringCrossDataPoint.y, getChart().getPlotArea().getSize().x, fHoveringCrossDataPoint.y); } } // ------------------------------------------------------------------------ // Utility functions // ------------------------------------------------------------------------ private int getTableEntryIndexFromGraphIndex(ISeries series, int index) { List<Integer> indexes = fIndexMapping.get(series); if (indexes == null || index > indexes.size() || index < 0) { return -1; } return indexes.get(index); } private int getGraphIndexFromTableEntryIndex(ISeries series, int index) { List<Integer> indexes = fIndexMapping.get(series); if (indexes == null || !indexes.contains(index)) { return -1; } return indexes.indexOf(index); } @Override protected void refreshDisplayLabels() { } /** * Return the current selection in internal mapping * * @return the internal selections */ protected Set<Integer> getInternalSelections() { /* Translate to internal table location */ Set<Integer> indexes = super.getSelection(); Set<Integer> internalIndexes = indexes.stream() .mapToInt(index -> getResultTable().getEntries().indexOf((getResultTable().getEntries().get(index)))) .boxed() .collect(Collectors.toSet()); return internalIndexes; } private static void updateTickMark(BiMap<@Nullable String, Integer> map, IAxisTick tick, int availableLenghtPixel) { int nbLabels = Math.max(1, map.size()); int stepSizePixel = availableLenghtPixel / nbLabels; /* * This step is a limitation on swtchart side regarding minimal grid * step hint size. When the step size are smaller it get defined as the * "default" value for the axis instead of the smallest one. */ if (IAxisTick.MIN_GRID_STEP_HINT > stepSizePixel) { stepSizePixel = (int) IAxisTick.MIN_GRID_STEP_HINT; } tick.setTickMarkStepHint(stepSizePixel); } @Override protected void setSelection(@NonNull Set<@NonNull Integer> selection) { super.setSelection(selection); /* Set color of selected symbol */ Iterator<Color> colorsIt = Iterators.cycle(COLORS); Iterator<Color> lightColorsIt = Iterators.cycle(LIGHT_COLORS); Set<Integer> currentSelections = getInternalSelections(); for (ISeries series : getChart().getSeriesSet().getSeries()) { /* Series color */ Color lightColor = lightColorsIt.next(); Color color = colorsIt.next(); Color[] colors = ((ILineSeries) series).getSymbolColors(); if (colors == null) { /* Should never happen */ continue; } if (currentSelections.isEmpty()) { /* Put all symbols to the normal colors */ Arrays.fill(colors, 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. */ Arrays.fill(colors, lightColor); } ((ILineSeries) series).setSymbolColors(colors); } } }