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 }