/******************************************************************************* * Copyright (c) 2015, 2016 EfficiOS Inc., Alexandre Montplaisir * * 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.Comparator; 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.stream.Stream; 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.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.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.signals.LamiSelectionUpdateSignal; import org.eclipse.tracecompass.internal.provisional.analysis.lami.ui.views.LamiReportViewTabPage; import org.eclipse.tracecompass.tmf.core.signal.TmfSignalManager; import org.swtchart.IAxis; import org.swtchart.IAxisTick; import org.swtchart.IBarSeries; import org.swtchart.ISeries; import org.swtchart.ISeries.SeriesType; import org.swtchart.Range; import com.google.common.collect.Iterators; /** * Bar chart Viewer for LAMI views. * * @author Alexandre Montplaisir * @author Jonathan Rajotte-Julien * @author Mathieu Desnoyers */ public class LamiBarChartViewer extends LamiXYChartViewer { private static final double LOGSCALE_EPSILON_FACTOR = 100.0; private class Mapping { final private @Nullable Integer fInternalValue; final private @Nullable Integer fModelValue; public Mapping(@Nullable Integer internalValue, @Nullable Integer modelValue) { fInternalValue = internalValue; fModelValue = modelValue; } public @Nullable Integer getInternalValue() { return fInternalValue; } public @Nullable Integer getModelValue() { return fModelValue; } } private final String[] fCategories; private final Map<ISeries, List<Mapping>> fIndexPerSeriesMapping; private final Map<LamiTableEntry, Mapping> fEntryToCategoriesMap; private LamiGraphRange fYInternalRange = new LamiGraphRange(BigDecimal.ZERO, BigDecimal.ONE); private LamiGraphRange fYExternalRange; /** * Creates a bar chart Viewer instance based on SWTChart. * * @param parent * The parent composite to draw in. * @param page * The {@link LamiReportViewTabPage} parent page * @param chartModel * The information about the chart to build */ public LamiBarChartViewer(Composite parent, LamiReportViewTabPage page, LamiChartModel chartModel) { super(parent, page, chartModel); List<LamiTableEntryAspect> xAxisAspects = getXAxisAspects(); List<LamiTableEntryAspect> yAxisAspects = getYAxisAspects(); /* bar chart cannot deal with multiple X series */ if (getChartModel().getChartType() != ChartType.BAR_CHART && xAxisAspects.size() != 1) { throw new IllegalArgumentException("Invalid configuration passed to a bar chart."); //$NON-NLS-1$ } /* Enable categories */ getChart().getAxisSet().getXAxis(0).enableCategory(true); LamiTableEntryAspect xAxisAspect = xAxisAspects.get(0); List<LamiTableEntry> entries = getResultTable().getEntries(); boolean logscale = chartModel.yAxisIsLog(); fIndexPerSeriesMapping = new HashMap<>(); fEntryToCategoriesMap = new HashMap<>(); /* Categories index mapping */ Format formatter = null; if (xAxisAspect.isContinuous()) { formatter = getContinuousAxisFormatter(xAxisAspects, entries, null, null); } List<@Nullable String> xCategories = new ArrayList<>(); for (int i = 0; i < entries.size(); i++) { String string = xAxisAspect.resolveString(entries.get(i)); if (string == null) { fEntryToCategoriesMap.put(entries.get(i), new Mapping(null, i)); continue; } fEntryToCategoriesMap.put(entries.get(i), new Mapping(xCategories.size(), i)); if (formatter != null) { string = formatter.format(xAxisAspect.resolveNumber(entries.get(i))); } xCategories.add(string); } fCategories = xCategories.toArray(new String[0]); /* The y values range */ /* Clamp minimum to zero or negative value */ fYExternalRange = getRange(yAxisAspects, true); /* * Log scale magic course 101: * * It uses the relative difference divided by a factor * (100) to get as close as it can to the actual minimum but still a * little bit smaller. This is used as a workaround of SWTCHART * limitations regarding custom scale drawing in log scale mode, bogus * representation of NaN double values and limited support of multiple * size series. * * This should be good enough for most users. */ double min = Double.MAX_VALUE; double max = Double.MIN_VALUE; double logScaleEpsilon = ZERO_DOUBLE; if (logscale) { /* Find minimum and maximum values excluding <= 0 values */ for (LamiTableEntryAspect aspect : yAxisAspects) { for (LamiTableEntry entry : entries) { Number externalValue = aspect.resolveNumber(entry); if (externalValue == null) { continue; } Double value = getInternalDoubleValue(externalValue, fYInternalRange, fYExternalRange); if (value <= 0) { continue; } min = Math.min(min, value); max = Math.max(max, value); } } if (min == Double.MAX_VALUE) { /* Series are empty in log scale*/ return; } double delta = max - min; logScaleEpsilon = min - ((min * delta) / (LOGSCALE_EPSILON_FACTOR * max)); } for (LamiTableEntryAspect yAxisAspect : yAxisAspects) { if (!yAxisAspect.isContinuous() || yAxisAspect.isTimeStamp()) { /* Only plot continuous aspects */ continue; } List<Double> validXValues = new ArrayList<>(); List<Double> validYValues = new ArrayList<>(); List<Mapping> indexMapping = new ArrayList<>(); for (int i = 0; i < entries.size(); i++) { Integer categoryIndex = checkNotNull(fEntryToCategoriesMap.get(checkNotNull(entries.get(i)))).fInternalValue; if (categoryIndex == null) { /* Invalid value do not show */ continue; } Double yValue = ZERO_DOUBLE; @Nullable Number number = yAxisAspect.resolveNumber(entries.get(i)); if (number == null) { /* * Null value for y is the same as zero since this is a bar * chart */ yValue = ZERO_DOUBLE; } else { yValue = getInternalDoubleValue(number, fYInternalRange, fYExternalRange); } if (logscale && yValue <= ZERO_DOUBLE) { /* * Less or equal to 0 values can't be plotted on a log * scale. We map them to the mean of the >=0 minimal value * and the calculated log scale magic epsilon. */ yValue = (min + logScaleEpsilon) / 2.0; } validXValues.add(checkNotNull(categoryIndex).doubleValue()); validYValues.add(yValue.doubleValue()); indexMapping.add(new Mapping(categoryIndex, checkNotNull(fEntryToCategoriesMap.get(checkNotNull(entries.get(i)))).fModelValue)); } String name = yAxisAspect.getLabel(); if (validXValues.isEmpty() || validYValues.isEmpty()) { /* No need to plot an empty series */ continue; } IBarSeries barSeries = (IBarSeries) getChart().getSeriesSet().createSeries(SeriesType.BAR, name); barSeries.setXSeries(validXValues.stream().mapToDouble(Double::doubleValue).toArray()); barSeries.setYSeries(validYValues.stream().mapToDouble(Double::doubleValue).toArray()); fIndexPerSeriesMapping.put(barSeries, indexMapping); } setBarSeriesColors(); /* Set all y axis logscale mode */ Stream.of(getChart().getAxisSet().getYAxes()).forEach(axis -> axis.enableLogScale(logscale)); /* Set the formatter on the Y axis */ IAxisTick yTick = getChart().getAxisSet().getYAxis(0).getTick(); yTick.setFormat(getContinuousAxisFormatter(yAxisAspects, entries, fYInternalRange, fYExternalRange)); /* * 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); /* Adjust the chart range */ getChart().getAxisSet().adjustRange(); if (logscale && logScaleEpsilon != max) { getChart().getAxisSet().getYAxis(0).setRange(new Range(logScaleEpsilon, max)); } /* Once the chart is filled, refresh the axis labels */ refreshDisplayLabels(); /* Add mouse listener */ getChart().getPlotArea().addMouseListener(new LamiBarChartMouseDownListener()); /* Custom Painter listener to highlight the current selection */ getChart().getPlotArea().addPaintListener(new LamiBarChartPainterListener()); } private final class LamiBarChartMouseDownListener extends MouseAdapter { @Override public void mouseDown(@Nullable MouseEvent event) { if (event == null || event.button != 1) { return; } boolean ctrlMode = false; int xMouseLocation = event.x; int yMouseLocation = event.y; Set<Integer> selections; if ((event.stateMask & SWT.CTRL) != 0) { ctrlMode = true; selections = getSelection(); } else { /* Reset selection state */ unsetSelection(); selections = new HashSet<>(); } ISeries[] series = getChart().getSeriesSet().getSeries(); /* * Iterate over all series, get the rectangle bounds for each * category, and find the category index under the mouse. * * Since categories map directly to the index of the fResultTable * and that this table is immutable the index of the entry * corresponds to the categories index. Signal to all LamiViewer and * LamiView the update of selection. */ for (ISeries oneSeries : series) { IBarSeries barSerie = ((IBarSeries) oneSeries); Rectangle[] recs = barSerie.getBounds(); for (int j = 0; j < recs.length; j++) { Rectangle rectangle = recs[j]; if (rectangle.contains(xMouseLocation, yMouseLocation)) { int index = getTableEntryIndexFromGraphIndex(checkNotNull(oneSeries), j); if (!ctrlMode || (index >= 0 && !selections.remove(index))) { selections.add(index); } } } } /* Save the current selection internally */ setSelection(selections); /* Signal all Lami viewers & views of the selection */ LamiSelectionUpdateSignal signal = new LamiSelectionUpdateSignal(this, selections, getPage()); TmfSignalManager.dispatchSignal(signal); redraw(); } } @Override protected void redraw() { setBarSeriesColors(); super.redraw(); } /** * Set the chart series colors according to the selection state. Use light * colors when a selection is present. */ private void setBarSeriesColors() { Iterator<Color> colorsIt; if (isSelected()) { colorsIt = Iterators.cycle(LIGHT_COLORS); } else { colorsIt = Iterators.cycle(COLORS); } for (ISeries series : getChart().getSeriesSet().getSeries()) { ((IBarSeries) series).setBarColor(colorsIt.next()); } } private final class LamiBarChartPainterListener implements PaintListener { @Override public void paintControl(@Nullable PaintEvent e) { if (e == null || !isSelected()) { return; } Iterator<Color> colorsIt = Iterators.cycle(COLORS); GC gc = e.gc; for (ISeries series : getChart().getSeriesSet().getSeries()) { Color color = colorsIt.next(); for (int index : getSelection()) { int graphIndex = getGraphIndexFromTableEntryIndex(series, index); if (graphIndex < 0) { /* Invalid index */ continue; } Rectangle[] bounds = ((IBarSeries) series).getBounds(); if (bounds.length != fCategories.length) { /* * The plot is too cramped and SWTChart currently does * its best on rectangle drawing and returns the * rectangle that it is able to draw. * * For now we simply do not draw since it is really hard * to see anyway. A better way to visualize the value * would be a full cross for each selection based on * their coordinates. */ continue; } Rectangle rectangle = bounds[graphIndex]; gc.setBackground(color); gc.fillRectangle(rectangle); } } } } @Override protected void refreshDisplayLabels() { /* Only if we have at least 1 category */ if (fCategories.length == 0) { return; } /* Only refresh if labels are visible */ IAxis xAxis = getChart().getAxisSet().getXAxis(0); if (!xAxis.getTick().isVisible() || !xAxis.isCategoryEnabled()) { return; } /* * Shorten all the labels to 5 characters plus "…" when the longest * label length is more than 50% of the chart height. */ Rectangle rect = getChart().getClientArea(); int lengthLimit = (int) (rect.height * 0.40); GC gc = new GC(fParent); gc.setFont(xAxis.getTick().getFont()); /* Find the longest category string */ String longestString = Arrays.stream(fCategories).max(Comparator.comparingInt(String::length)).orElse(fCategories[0]); /* Get the length and height of the longest label in pixels */ Point pixels = gc.stringExtent(longestString); // Completely arbitrary int cutLen = 5; String[] displayCategories = new String[fCategories.length]; if (pixels.x > lengthLimit) { /* We have to cut down some strings */ for (int i = 0; i < fCategories.length; i++) { if (fCategories[i].length() > cutLen) { displayCategories[i] = fCategories[i].substring(0, cutLen) + ELLIPSIS; } else { displayCategories[i] = fCategories[i]; } } } else { /* All strings should fit */ displayCategories = Arrays.copyOf(fCategories, fCategories.length); } xAxis.setCategorySeries(displayCategories); /* Cleanup */ gc.dispose(); } private int getTableEntryIndexFromGraphIndex(ISeries series, int index) { List<Mapping> indexes = fIndexPerSeriesMapping.get(series); if (indexes == null || index > indexes.size() || index < 0) { return -1; } Mapping mapping = indexes.get(index); Integer modelValue = mapping.getModelValue(); if (modelValue != null) { return modelValue.intValue(); } return -1; } private int getGraphIndexFromTableEntryIndex(ISeries series, int index) { List<Mapping> indexes = fIndexPerSeriesMapping.get(series); if (indexes == null || index < 0) { return -1; } int internalIndex = -1; for (Mapping mapping : indexes) { if (mapping.getModelValue() == index) { Integer internalValue = mapping.getInternalValue(); if (internalValue != null) { internalIndex = internalValue.intValue(); break; } } } return internalIndex; } }