1 /*
   2  * Copyright (c) 2018, Oracle and/or its affiliates. All rights reserved.
   3  * 
   4  * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
   5  *
   6  * The contents of this file are subject to the terms of either the Universal Permissive License
   7  * v 1.0 as shown at http://oss.oracle.com/licenses/upl
   8  *
   9  * or the following license:
  10  *
  11  * Redistribution and use in source and binary forms, with or without modification, are permitted
  12  * provided that the following conditions are met:
  13  * 
  14  * 1. Redistributions of source code must retain the above copyright notice, this list of conditions
  15  * and the following disclaimer.
  16  * 
  17  * 2. Redistributions in binary form must reproduce the above copyright notice, this list of
  18  * conditions and the following disclaimer in the documentation and/or other materials provided with
  19  * the distribution.
  20  * 
  21  * 3. Neither the name of the copyright holder nor the names of its contributors may be used to
  22  * endorse or promote products derived from this software without specific prior written permission.
  23  * 
  24  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR
  25  * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
  26  * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
  27  * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
  28  * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
  29  * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
  30  * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY
  31  * WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  32  */
  33 package org.openjdk.jmc.ui.charts;
  34 
  35 import java.awt.Color;
  36 import java.awt.Graphics2D;
  37 import java.awt.Point;
  38 import java.awt.geom.AffineTransform;
  39 import java.awt.geom.Point2D;
  40 import java.util.ArrayList;
  41 import java.util.HashSet;
  42 import java.util.List;
  43 import java.util.Set;
  44 import java.util.function.Consumer;
  45 
  46 import org.openjdk.jmc.common.IDisplayable;
  47 import org.openjdk.jmc.common.unit.IQuantity;
  48 import org.openjdk.jmc.common.unit.IRange;
  49 import org.openjdk.jmc.common.unit.QuantitiesToolkit;
  50 import org.openjdk.jmc.common.unit.QuantityRange;
  51 import org.openjdk.jmc.ui.charts.IChartInfoVisitor.ITick;
  52 
  53 public class XYChart {
  54         private static final String ELLIPSIS = "..."; //$NON-NLS-1$
  55         private static final Color SELECTION_COLOR = new Color(255, 255, 255, 220);
  56         private static final Color RANGE_INDICATION_COLOR = new Color(255, 60, 20);
  57         private static final int Y_OFFSET = 35;
  58         private static final int RANGE_INDICATOR_HEIGHT = 4;
  59         private final IQuantity start;
  60         private final IQuantity end;
  61         private IXDataRenderer rendererRoot;
  62         private IRenderedRow rendererResult;
  63         private final int xOffset;
  64         private final int bucketWidth;
  65         // FIXME: Use bucketWidth * ticksPerBucket instead of hardcoded value?
  66 //      private final int ticksPerBucket = 4;
  67 
  68         private IQuantity currentStart;
  69         private IQuantity currentEnd;
  70 
  71         private final Set<Object> selectedRows = new HashSet<>();
  72         private IQuantity selectionStart;
  73         private IQuantity selectionEnd;
  74         private SubdividedQuantityRange xBucketRange;
  75         private SubdividedQuantityRange xTickRange;
  76         private int axisWidth;
  77 
  78         public XYChart(IRange<IQuantity> range, IXDataRenderer rendererRoot) {
  79                 this(range.getStart(), range.getEnd(), rendererRoot);
  80         }
  81 
  82         public XYChart(IRange<IQuantity> range, IXDataRenderer rendererRoot, int xOffset) {
  83                 this(range.getStart(), range.getEnd(), rendererRoot, xOffset);
  84         }
  85 
  86         public XYChart(IRange<IQuantity> range, IXDataRenderer rendererRoot, int xOffset, int bucketWidth) {
  87                 this(range.getStart(), range.getEnd(), rendererRoot, xOffset, bucketWidth);
  88         }
  89 
  90         public XYChart(IQuantity start, IQuantity end, IXDataRenderer rendererRoot) {
  91                 this(start, end, rendererRoot, 60);
  92         }
  93 
  94         public XYChart(IQuantity start, IQuantity end, IXDataRenderer rendererRoot, int xOffset) {
  95                 this(start, end, rendererRoot, xOffset, 25);
  96         }
  97 
  98         public XYChart(IQuantity start, IQuantity end, IXDataRenderer rendererRoot, int xOffset, int bucketWidth) {
  99                 this.rendererRoot = rendererRoot;
 100                 // Start value must always be strictly less than end
 101                 assert (start.compareTo(end) < 0);
 102                 currentStart = start;
 103                 this.start = start;
 104                 currentEnd = end;
 105                 this.end = end;
 106                 this.xOffset = xOffset;
 107                 this.bucketWidth = bucketWidth;
 108         }
 109 
 110         public void setRendererRoot(IXDataRenderer rendererRoot) {
 111                 clearSelection();
 112                 this.rendererRoot = rendererRoot;
 113         }
 114 
 115         public IXDataRenderer getRendererRoot() {
 116                 return rendererRoot;
 117         }
 118 
 119         public Object[] getSelectedRows() {
 120                 return selectedRows.toArray(new Object[selectedRows.size()]);
 121         }
 122 
 123         public IQuantity getSelectionStart() {
 124                 return selectionStart;
 125         }
 126 
 127         public IQuantity getSelectionEnd() {
 128                 return selectionEnd;
 129         }
 130 
 131         public IRange<IQuantity> getSelectionRange() {
 132                 return (selectionStart != null) && (selectionEnd != null)
 133                                 ? QuantityRange.createWithEnd(selectionStart, selectionEnd) : null;
 134         }
 135 
 136         public void render(Graphics2D context, int width, int height) {
 137                 if (width > xOffset && height > Y_OFFSET) {
 138                         axisWidth = width - xOffset;
 139                         // FIXME: xBucketRange and xTickRange should be more related, so that each tick is typically an integer number of buckets (or possibly 2.5 buckets).
 140                         xBucketRange = new SubdividedQuantityRange(currentStart, currentEnd, axisWidth, bucketWidth);
 141                         // FIXME: Use bucketWidth * ticksPerBucket instead of hardcoded value?
 142                         xTickRange = new SubdividedQuantityRange(currentStart, currentEnd, axisWidth, 100);
 143                         AffineTransform oldTransform = context.getTransform();
 144                         context.translate(xOffset, 0);
 145                         doRender(context, height - Y_OFFSET);
 146                         context.setTransform(oldTransform);
 147                 }
 148         }
 149 
 150         private void renderRangeIndication(Graphics2D context, int rangeIndicatorY) {
 151                 // FIXME: Extract the needed functionality from SubdividedQuantityRange
 152                 SubdividedQuantityRange fullRangeAxis = new SubdividedQuantityRange(start, end, axisWidth, 25);
 153                 int x1 = (int) fullRangeAxis.getPixel(currentStart);
 154                 int x2 = (int) Math.ceil(fullRangeAxis.getPixel(currentEnd));
 155                 if (x1 > 0 || x2 < axisWidth) {
 156                         context.setPaint(RANGE_INDICATION_COLOR);
 157                         context.fillRect(x1, rangeIndicatorY, x2 - x1, RANGE_INDICATOR_HEIGHT);
 158                         context.setPaint(Color.DARK_GRAY);
 159                         context.drawRect(0, rangeIndicatorY, axisWidth - 1, RANGE_INDICATOR_HEIGHT);
 160                 }
 161         }
 162 
 163         private void doRender(Graphics2D context, int axisHeight) {
 164                 context.setPaint(Color.LIGHT_GRAY);
 165                 AWTChartToolkit.drawGrid(context, xTickRange, axisHeight, false);
 166                 // Attempt to make graphs so low they cover the axis show by drawing the full axis first ...
 167                 context.setPaint(Color.BLACK);
 168                 AWTChartToolkit.drawAxis(context, xTickRange, axisHeight - 1, false, 1 - xOffset, false);
 169                 // ... then the graph ...
 170                 rendererResult = rendererRoot.render(context, xBucketRange, axisHeight);
 171                 AffineTransform oldTransform = context.getTransform();
 172                 renderText(context, rendererResult);
 173                 context.setTransform(oldTransform);
 174                 if (!selectedRows.isEmpty()) {
 175                         renderSelection(context, rendererResult);
 176                         context.setTransform(oldTransform);
 177                 }
 178                 // .. and finally a semitransparent axis line again.
 179                 context.setPaint(new Color(0, 0, 0, 64));
 180                 context.drawLine(0, axisHeight - 1, axisWidth - 1, axisHeight - 1);
 181                 renderRangeIndication(context, axisHeight + 25);
 182         }
 183 
 184         private void renderSelection(Graphics2D context, IRenderedRow row) {
 185                 if (selectedRows.contains(row.getPayload())) {
 186                         renderSelection(context, xBucketRange, row.getHeight());
 187                 } else {
 188                         List<IRenderedRow> subdivision = row.getNestedRows();
 189                         if (subdivision.isEmpty()) {
 190                                 dimRect(context, 0, axisWidth, row.getHeight());
 191                         } else {
 192                                 for (IRenderedRow nestedRow : row.getNestedRows()) {
 193                                         renderSelection(context, nestedRow);
 194                                 }
 195                                 return;
 196                         }
 197                 }
 198                 context.translate(0, row.getHeight());
 199         }
 200 
 201         private void renderText(Graphics2D context, IRenderedRow row) {
 202                 String text = row.getName();
 203                 int height = row.getHeight();
 204                 if (height >= context.getFontMetrics().getHeight()) {
 205                         if (text != null) {
 206                                 context.setColor(Color.BLACK);
 207                                 int y;
 208                                 if (height > 40) {
 209                                         context.drawLine(-xOffset, height - 1, -15, height - 1);
 210                                         y = height - context.getFontMetrics().getHeight() / 2;
 211                                 } else {
 212                                         // draw the string in the middle of the row
 213                                         y = ((height - context.getFontMetrics().getHeight()) / 2) + context.getFontMetrics().getAscent();
 214                                 }
 215                                 int charsWidth = context.getFontMetrics().charsWidth(text.toCharArray(), 0, text.length());
 216                                 if (charsWidth > xOffset) {
 217                                         float fitRatio = ((float) xOffset) / (charsWidth
 218                                                         + context.getFontMetrics().charsWidth(ELLIPSIS.toCharArray(), 0, ELLIPSIS.length()));
 219                                         text = text.substring(0, ((int) (text.length() * fitRatio)) - 1) + ELLIPSIS;
 220                                 }
 221                                 context.drawString(text, -xOffset + 2, y);
 222                         } else {
 223                                 List<IRenderedRow> subdivision = row.getNestedRows();
 224                                 if (!subdivision.isEmpty()) {
 225                                         for (IRenderedRow nestedRow : row.getNestedRows()) {
 226                                                 renderText(context, nestedRow);
 227                                         }
 228                                         return;
 229                                 }
 230                         }
 231                 }
 232                 context.translate(0, height);
 233         }
 234 
 235         /**
 236          * Pan the view.
 237          *
 238          * @param rightPercent
 239          * @return true if the bounds changed. That is, if a redraw is required.
 240          */
 241         public boolean pan(int rightPercent) {
 242                 if (xBucketRange != null) {
 243                         IQuantity oldStart = currentStart;
 244                         IQuantity oldEnd = currentEnd;
 245                         if (rightPercent > 0) {
 246                                 currentEnd = QuantitiesToolkit
 247                                                 .min(xBucketRange.getQuantityAtPixel(axisWidth + axisWidth * rightPercent / 100), end);
 248                                 currentStart = QuantitiesToolkit
 249                                                 .max(xBucketRange.getQuantityAtPixel(xBucketRange.getPixel(currentEnd) - axisWidth), start);
 250                         } else if (rightPercent < 0) {
 251                                 currentStart = QuantitiesToolkit.max(xBucketRange.getQuantityAtPixel(axisWidth * rightPercent / 100),
 252                                                 start);
 253                                 currentEnd = QuantitiesToolkit
 254                                                 .min(xBucketRange.getQuantityAtPixel(xBucketRange.getPixel(currentStart) + axisWidth), end);
 255                         }
 256                         return (currentStart.compareTo(oldStart) != 0) || (currentEnd.compareTo(oldEnd) != 0);
 257                 }
 258                 // Return true since a redraw forces creation of xBucketRange.
 259                 return true;
 260         }
 261 
 262         /**
 263          * Zoom the view.
 264          *
 265          * @param zoomInSteps
 266          * @return true if the bounds changed. That is, if a redraw is required.
 267          */
 268         public boolean zoom(int zoomInSteps) {
 269                 return zoomXAxis(axisWidth / 2, zoomInSteps);
 270         }
 271 
 272         /**
 273          * Zoom the view.
 274          *
 275          * @param x
 276          * @param zoomInSteps
 277          * @return true if the bounds changed. That is, if a redraw is required.
 278          */
 279         public boolean zoom(int x, int zoomInSteps) {
 280                 return zoomXAxis(x - xOffset, zoomInSteps);
 281         }
 282 
 283         private boolean zoomXAxis(int x, int zoomInSteps) {
 284                 if (xBucketRange == null) {
 285                         // Return true since a redraw forces creation of xBucketRange.
 286                         return true;
 287                 }
 288                 if ((x > 0) && (x < axisWidth)) {
 289                         IQuantity oldStart = currentStart;
 290                         IQuantity oldEnd = currentEnd;
 291                         // Absolute value of zoomFactor must be less than 1. Currently it ranges between -0.5 and 0.5.
 292                         double zoomFactor = Math.atan(zoomInSteps) / Math.PI;
 293                         int newStart = (int) (zoomFactor * x);
 294                         int newEnd = (int) (axisWidth * (1 - zoomFactor)) + newStart;
 295                         SubdividedQuantityRange xAxis = new SubdividedQuantityRange(currentStart, currentEnd, axisWidth, 1);
 296                         setVisibleRange(xAxis.getQuantityAtPixel(newStart), xAxis.getQuantityAtPixel(newEnd));
 297                         return (currentStart.compareTo(oldStart) != 0) || (currentEnd.compareTo(oldEnd) != 0);
 298                 }
 299                 return false;
 300         }
 301 
 302         public void setVisibleRange(IQuantity rangeStart, IQuantity rangeEnd) {
 303                 rangeStart = QuantitiesToolkit.max(rangeStart, start);
 304                 rangeEnd = QuantitiesToolkit.min(rangeEnd, end);
 305                 if (rangeStart.compareTo(rangeEnd) < 0) {
 306                         SubdividedQuantityRange testRange = new SubdividedQuantityRange(rangeStart, rangeEnd, 10000, 1);
 307                         if (testRange.getQuantityAtPixel(0).compareTo(testRange.getQuantityAtPixel(1)) < 0) {
 308                                 currentStart = rangeStart;
 309                                 currentEnd = rangeEnd;
 310                         } else {
 311                                 // Ensures that zoom out is always allowed
 312                                 currentStart = QuantitiesToolkit.min(rangeStart, currentStart);
 313                                 currentEnd = QuantitiesToolkit.max(rangeEnd, currentEnd);
 314                         }
 315                         rangeListeners.stream().forEach(l -> l.accept(getVisibleRange()));
 316                 }
 317         }
 318 
 319         private List<Consumer<IRange<IQuantity>>> rangeListeners = new ArrayList<>();
 320 
 321         public void addVisibleRangeListener(Consumer<IRange<IQuantity>> rangeListener) {
 322                 rangeListeners.add(rangeListener);
 323         }
 324 
 325         public IRange<IQuantity> getVisibleRange() {
 326                 return (currentStart != null) && (currentEnd != null) ? QuantityRange.createWithEnd(currentStart, currentEnd)
 327                                 : null;
 328         }
 329 
 330         public void clearVisibleRange() {
 331                 currentStart = start;
 332                 currentEnd = end;
 333         }
 334 
 335         public boolean select(int x1, int x2, int y1, int y2) {
 336                 int xStart = Math.min(x1, x2) - xOffset;
 337                 int xEnd = Math.max(x1, x2) - xOffset;
 338 
 339                 if (xBucketRange != null && (xEnd >= 0)) {
 340                         return select(xBucketRange.getQuantityAtPixel(Math.max(0, xStart)), xBucketRange.getQuantityAtPixel(xEnd),
 341                                         y1, y2);
 342                 } else {
 343                         return select(null, null, y1, y2);
 344                 }
 345         }
 346 
 347         public boolean select(IQuantity xStart, IQuantity xEnd, int y1, int y2) {
 348                 if (xStart != null && xStart.compareTo(start) < 0) {
 349                         xStart = start;
 350                 }
 351                 if (xEnd != null && xEnd.compareTo(end) > 0) {
 352                         xEnd = end;
 353                 }
 354                 Set<Object> oldRows = null;
 355                 if (QuantitiesToolkit.same(selectionStart, xStart) && QuantitiesToolkit.same(selectionEnd, xEnd)) {
 356                         oldRows = new HashSet<>(selectedRows);
 357                 }
 358                 selectedRows.clear();
 359                 addSelectedRows(rendererResult, 0, Math.min(y1, y2), Math.max(y1, y2));
 360                 selectionStart = xStart;
 361                 selectionEnd = xEnd;
 362                 return (oldRows == null) || !oldRows.equals(selectedRows);
 363         }
 364 
 365         public boolean clearSelection() {
 366                 if ((selectionStart == null) && (selectionEnd == null) && selectedRows.isEmpty()) {
 367                         return false;
 368                 }
 369                 selectedRows.clear();
 370                 selectionStart = selectionEnd = null;
 371                 return true;
 372         }
 373 
 374         private boolean addSelectedRows(IRenderedRow row, int yRowStart, int ySelectionStart, int ySelectionEnd) {
 375                 List<IRenderedRow> subdivision = row.getNestedRows();
 376                 if (subdivision.isEmpty()) {
 377                         return addPayload(row);
 378                 } else {
 379                         boolean nestedHasPayload = false;
 380                         for (IRenderedRow nestedRow : row.getNestedRows()) {
 381                                 int yRowEnd = yRowStart + nestedRow.getHeight();
 382                                 if (yRowStart > ySelectionEnd) {
 383                                         break;
 384                                 } else if (yRowEnd > ySelectionStart) {
 385                                         nestedHasPayload |= addSelectedRows(nestedRow, yRowStart, ySelectionStart, ySelectionEnd);
 386                                 }
 387                                 yRowStart = yRowEnd;
 388                         }
 389                         return nestedHasPayload || addPayload(row);
 390                 }
 391         }
 392 
 393         private boolean addPayload(IRenderedRow row) {
 394                 Object payload = row.getPayload();
 395                 if (payload != null) {
 396                         selectedRows.add(payload);
 397                         return true;
 398                 }
 399                 return false;
 400         }
 401 
 402         private void renderSelection(Graphics2D context, SubdividedQuantityRange xRange, int height) {
 403                 int selFrom = 0;
 404                 int selTo = axisWidth;
 405                 if (selectionStart != null && selectionEnd != null) {
 406                         selFrom = (int) xRange.getPixel(selectionStart);
 407                         // Removed "+ 1" for now to make the selection symmetrical with respect to chart highlights.
 408                         selTo = (int) xRange.getPixel(selectionEnd);
 409                 }
 410                 // FIXME: Would like to show selection by graying out the other parts, can we do that?
 411 //              if (selWidth > 0) {
 412 //                      context.setColor(Color.WHITE);
 413 //                      context.setXORMode(Color.BLACK);
 414 //                      Stroke oldStroke = context.getStroke();
 415 //                      context.setStroke(SELECTION_STROKE);
 416 //                      context.drawRect(selFrom, 0, selWidth, height);
 417 //                      context.setStroke(oldStroke);
 418 //                      context.setPaintMode();
 419 //              }
 420                 if (selFrom > 0) {
 421                         dimRect(context, 0, selFrom, height);
 422                         context.setColor(Color.BLACK);
 423                         context.drawLine(selFrom, 0, selFrom, height);
 424                 }
 425                 if (selTo < axisWidth) {
 426                         dimRect(context, selTo, axisWidth - selTo, height);
 427                         context.setColor(Color.BLACK);
 428                         context.drawLine(selTo, 0, selTo, height);
 429                 }
 430         }
 431 
 432         private static void dimRect(Graphics2D context, int from, int width, int height) {
 433                 context.setColor(SELECTION_COLOR);
 434                 context.fillRect(from, 0, width, height);
 435         }
 436 
 437         /**
 438          * Let the {@code visitor} visit the chart elements in the vicinity of {@code x} and {@code y}.
 439          * The visitation should adhere to a basic front to back ordering, so that elements which
 440          * <em>conceptually</em> are at the front should be visited first. Note that this has no direct
 441          * link to the drawing order. Also, this doesn't dictate any particular order between elements
 442          * that conceptually are at the same level. (Good practice is to visit elements from different
 443          * sub charts in a consistent order. If the sub charts have some kind of fixed ordering, such as
 444          * stacked line charts, this order from top to bottom seems most appropriate.)
 445          *
 446          * @param visitor
 447          * @param x
 448          * @param y
 449          */
 450         public void infoAt(IChartInfoVisitor visitor, int x, int y) {
 451                 if (rendererResult == null) {
 452                         return;
 453                 }
 454                 final int height = rendererResult.getHeight();
 455                 if (y < height) {
 456                         rendererResult.infoAt(visitor, x - xOffset, y, new Point(xOffset, 0));
 457                 } else {
 458                         x -= xOffset;
 459                         if (x >= 0) {
 460                                 // Snap to closest of ticks and buckets (useful even if no bar charts are shown).
 461                                 int tickIndex = xTickRange.getClosestSubdividerAtPixel(x);
 462                                 double tickX = xTickRange.getSubdividerPixel(tickIndex);
 463                                 int bucketIndex = xBucketRange.getClosestSubdividerAtPixel(x);
 464                                 double bucketX = xBucketRange.getSubdividerPixel(bucketIndex);
 465                                 if (Math.abs(x - bucketX) < Math.abs(x - tickX)) {
 466                                         visitor.visit(tickFor(xBucketRange, bucketIndex));
 467                                 } else {
 468                                         visitor.visit(tickFor(xTickRange, tickIndex));
 469                                 }
 470                         }
 471                 }
 472         }
 473 
 474         private ITick tickFor(final SubdividedQuantityRange xRange, final int index) {
 475                 return new ITick() {
 476                         @Override
 477                         public IDisplayable getValue() {
 478                                 return xRange.getSubdivider(index);
 479                         }
 480 
 481                         @Override
 482                         public Point2D getTarget() {
 483                                 return new Point(xOffset + (int) xRange.getSubdividerPixel(index), rendererResult.getHeight() - 1);
 484                         }
 485                 };
 486         }
 487 }