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.Stack; 45 import java.util.function.Consumer; 46 47 import org.openjdk.jmc.common.IDisplayable; 48 import org.openjdk.jmc.common.unit.IQuantity; 49 import org.openjdk.jmc.common.unit.IRange; 50 import org.openjdk.jmc.common.unit.QuantitiesToolkit; 51 import org.openjdk.jmc.common.unit.QuantityRange; 52 import org.openjdk.jmc.common.unit.UnitLookup; 53 import org.openjdk.jmc.ui.charts.IChartInfoVisitor.ITick; 54 import org.openjdk.jmc.ui.misc.ChartDisplayControlBar; 55 import org.openjdk.jmc.ui.misc.TimelineCanvas; 56 import org.openjdk.jmc.ui.misc.PatternFly.Palette; 57 58 public class XYChart { 59 private static final String ELLIPSIS = "..."; //$NON-NLS-1$ 60 private static final Color SELECTION_COLOR = new Color(255, 255, 255, 220); 61 private static final Color RANGE_INDICATION_COLOR = new Color(255, 60, 20); 62 private static final int RANGE_INDICATOR_HEIGHT = 7; 63 private final IQuantity start; 64 private final IQuantity end; 65 private IQuantity rangeDuration; 66 private IXDataRenderer rendererRoot; 67 private IRenderedRow rendererResult; 68 private final int xOffset; 69 private int yOffset = 35; 70 private final int bucketWidth; 71 // FIXME: Use bucketWidth * ticksPerBucket instead of hardcoded value? 72 // private final int ticksPerBucket = 4; 73 74 private IQuantity currentStart; 75 private IQuantity currentEnd; 76 77 private final Set<Object> selectedRows = new HashSet<>(); 78 private int axisWidth; 79 private int rowColorCounter; 80 private IQuantity selectionStart; 81 private IQuantity selectionEnd; 82 private SubdividedQuantityRange xBucketRange; 83 private SubdividedQuantityRange xTickRange; 84 85 // JFR Threads Page 86 private static final double ZOOM_PAN_FACTOR = 0.05; 87 private static final int ZOOM_PAN_MODIFIER = 2; 88 private double zoomPanPower = ZOOM_PAN_FACTOR / ZOOM_PAN_MODIFIER; 89 private double currentZoom; 90 private int zoomSteps; 91 private ChartDisplayControlBar displayBar; 92 private ChartFilterControlBar filterBar; 93 private Stack<Integer> modifiedSteps; 94 private TimelineCanvas timelineCanvas; 95 96 public XYChart(IRange<IQuantity> range, IXDataRenderer rendererRoot) { 97 this(range.getStart(), range.getEnd(), rendererRoot); 98 } 99 100 public XYChart(IRange<IQuantity> range, IXDataRenderer rendererRoot, int xOffset) { 101 this(range.getStart(), range.getEnd(), rendererRoot, xOffset); 102 } 103 104 // JFR Threads Page 105 public XYChart(IRange<IQuantity> range, IXDataRenderer rendererRoot, int xOffset, Integer yOffset, TimelineCanvas timelineCanvas, ChartFilterControlBar filterBar, ChartDisplayControlBar displayBar) { 106 this(range.getStart(), range.getEnd(), rendererRoot, xOffset); 107 this.yOffset = yOffset; 108 this.timelineCanvas = timelineCanvas; 109 this.filterBar = filterBar; 110 this.displayBar = displayBar; 111 this.rangeDuration = range.getExtent(); 112 this.currentZoom = 100; 113 this.isZoomCalculated = false; 114 } 115 116 public XYChart(IRange<IQuantity> range, IXDataRenderer rendererRoot, int xOffset, int bucketWidth) { 117 this(range.getStart(), range.getEnd(), rendererRoot, xOffset, bucketWidth); 118 } 119 120 public XYChart(IQuantity start, IQuantity end, IXDataRenderer rendererRoot) { 121 this(start, end, rendererRoot, 60); 122 } 123 124 public XYChart(IQuantity start, IQuantity end, IXDataRenderer rendererRoot, int xOffset) { 125 this(start, end, rendererRoot, xOffset, 25); 126 } 127 128 public XYChart(IQuantity start, IQuantity end, IXDataRenderer rendererRoot, int xOffset, int bucketWidth) { 129 this.rendererRoot = rendererRoot; 130 // Start value must always be strictly less than end 131 assert (start.compareTo(end) < 0); 132 this.currentStart = start; 133 this.start = start; 134 this.currentEnd = end; 135 this.end = end; 136 this.xOffset = xOffset; 137 this.bucketWidth = bucketWidth; 138 } 139 140 public void setRendererRoot(IXDataRenderer rendererRoot) { 141 clearSelection(); 142 this.rendererRoot = rendererRoot; 143 } 144 145 public IXDataRenderer getRendererRoot() { 146 return rendererRoot; 147 } 148 149 public Object[] getSelectedRows() { 150 return selectedRows.toArray(new Object[selectedRows.size()]); 151 } 152 153 public IQuantity getSelectionStart() { 154 return selectionStart; 155 } 156 157 public IQuantity getSelectionEnd() { 158 return selectionEnd; 159 } 160 161 public IRange<IQuantity> getSelectionRange() { 162 return (selectionStart != null) && (selectionEnd != null) 163 ? QuantityRange.createWithEnd(selectionStart, selectionEnd) : null; 164 } 165 166 public void renderChart(Graphics2D context, int width, int height) { 167 if (width > xOffset && height > yOffset) { 168 axisWidth = width - xOffset; 169 // FIXME: xBucketRange and xTickRange should be more related, so that each tick is typically an integer number of buckets (or possibly 2.5 buckets). 170 xBucketRange = new SubdividedQuantityRange(currentStart, currentEnd, axisWidth, bucketWidth); 171 // FIXME: Use bucketWidth * ticksPerBucket instead of hardcoded value? 172 xTickRange = new SubdividedQuantityRange(currentStart, currentEnd, axisWidth, 100); 173 AffineTransform oldTransform = context.getTransform(); 174 context.translate(xOffset, 0); 175 doRenderChart(context, height - yOffset); 176 context.setTransform(oldTransform); 177 } 178 } 179 180 public void renderTextCanvasText(Graphics2D context, int width) { 181 axisWidth = width; 182 AffineTransform oldTransform = context.getTransform(); 183 doRenderTextCanvasText(context); 184 context.setTransform(oldTransform); 185 } 186 187 public void renderText(Graphics2D context, int width, int height) { 188 if (width > xOffset && height > yOffset) { 189 axisWidth = xOffset; 190 AffineTransform oldTransform = context.getTransform(); 191 doRenderText(context); 192 context.setTransform(oldTransform); 193 axisWidth = width - xOffset; 194 } 195 } 196 197 private void renderRangeIndication(Graphics2D context, int rangeIndicatorY) { 198 // FIXME: Extract the needed functionality from SubdividedQuantityRange 199 SubdividedQuantityRange fullRangeAxis = new SubdividedQuantityRange(start, end, axisWidth, 25); 200 int x1 = (int) fullRangeAxis.getPixel(currentStart); 201 int x2 = (int) Math.ceil(fullRangeAxis.getPixel(currentEnd)); 202 203 if (timelineCanvas != null) { 204 timelineCanvas.renderRangeIndicator(x1, x2); 205 } else { 206 context.setPaint(RANGE_INDICATION_COLOR); 207 context.fillRect(x1, rangeIndicatorY, x2 - x1, RANGE_INDICATOR_HEIGHT); 208 context.setPaint(Color.DARK_GRAY); 209 context.drawRect(0, rangeIndicatorY, axisWidth - 1, RANGE_INDICATOR_HEIGHT); 210 } 211 } 212 213 private void doRenderChart(Graphics2D context, int axisHeight) { 214 rowColorCounter = 0; 215 context.setPaint(Color.LIGHT_GRAY); 216 AWTChartToolkit.drawGrid(context, xTickRange, axisHeight, false); 217 // Attempt to make graphs so low they cover the axis show by drawing the full axis first ... 218 context.setPaint(Color.BLACK); 219 if (timelineCanvas != null) { 220 timelineCanvas.setXTickRange(xTickRange); 221 } else { 222 AWTChartToolkit.drawAxis(context, xTickRange, axisHeight - 1, false, 1 - xOffset, false); 223 } 224 // ... then the graph ... 225 rendererResult = rendererRoot.render(context, xBucketRange, axisHeight); 226 AffineTransform oldTransform = context.getTransform(); 227 228 context.setTransform(oldTransform); 229 if (!selectedRows.isEmpty()) { 230 renderSelectionChart(context, rendererResult); 231 context.setTransform(oldTransform); 232 } 233 // .. and finally a semitransparent axis line again. 234 context.setPaint(new Color(0, 0, 0, 64)); 235 context.drawLine(0, axisHeight - 1, axisWidth - 1, axisHeight - 1); 236 renderRangeIndication(context, axisHeight + 25); 237 } 238 239 private void doRenderText(Graphics2D context) { 240 AffineTransform oldTransform = context.getTransform(); 241 rowColorCounter = -1; 242 renderText(context, rendererResult); 243 context.setTransform(oldTransform); 244 } 245 246 private void doRenderTextCanvasText(Graphics2D context) { 247 AffineTransform oldTransform = context.getTransform(); 248 rowColorCounter = 0; 249 renderText(context, rendererResult); 250 context.setTransform(oldTransform); 251 if (!selectedRows.isEmpty()) { 252 renderSelectionText(context, rendererResult); 253 context.setTransform(oldTransform); 254 } 255 } 256 257 private void renderSelectionText(Graphics2D context, IRenderedRow row) { 258 if (selectedRows.contains(row.getPayload())) { 259 if (row.getHeight() != rendererResult.getHeight()) { 260 Color highlight = new Color(0, 206, 209, 20); 261 context.setColor(highlight); 262 context.fillRect(0, 0, axisWidth, row.getHeight()); 263 } else { 264 selectedRows.clear(); 265 } 266 } else { 267 List<IRenderedRow> subdivision = row.getNestedRows(); 268 if (subdivision.isEmpty()) { 269 dimRect(context, 0, axisWidth, row.getHeight()); 270 } else { 271 for (IRenderedRow nestedRow : row.getNestedRows()) { 272 renderSelectionText(context, nestedRow); 273 } 274 return; 275 } 276 } 277 context.translate(0, row.getHeight()); 278 } 279 280 private void renderSelectionChart(Graphics2D context, IRenderedRow row) { 281 if (selectedRows.contains(row.getPayload())) { 282 renderSelection(context, xBucketRange, row.getHeight()); 283 } else { 284 List<IRenderedRow> subdivision = row.getNestedRows(); 285 if (subdivision.isEmpty()) { 286 dimRect(context, 0, axisWidth, row.getHeight()); 287 } else { 288 for (IRenderedRow nestedRow : row.getNestedRows()) { 289 renderSelectionChart(context, nestedRow); 290 } 291 return; 292 } 293 } 294 context.translate(0, row.getHeight()); 295 } 296 297 // Paint the background of every-other row in a slightly different shade 298 // to better differentiate the thread lanes from one another 299 private void paintRowBackground(Graphics2D context, int height) { 300 if (rowColorCounter >= 0) { 301 if (rowColorCounter % 2 == 0) { 302 context.setColor(Palette.PF_BLACK_100.getAWTColor()); 303 } else { 304 context.setColor(Palette.PF_BLACK_200.getAWTColor()); 305 } 306 context.fillRect(0, 0, axisWidth, height); 307 rowColorCounter++; 308 } 309 } 310 311 private void renderText(Graphics2D context, IRenderedRow row) { 312 String text = row.getName(); 313 int height = row.getHeight(); 314 if (height >= context.getFontMetrics().getHeight()) { 315 if (text != null) { 316 paintRowBackground(context, row.getHeight()); 317 context.setColor(Color.BLACK); 318 context.drawLine(0, height - 1, axisWidth -15, height - 1); 319 int y = ((height - context.getFontMetrics().getHeight()) / 2) + context.getFontMetrics().getAscent(); 320 int charsWidth = context.getFontMetrics().charsWidth(text.toCharArray(), 0, text.length()); 321 if (xOffset > 0 && charsWidth > xOffset) { 322 float fitRatio = ((float) xOffset) / (charsWidth 323 + context.getFontMetrics().charsWidth(ELLIPSIS.toCharArray(), 0, ELLIPSIS.length())); 324 text = text.substring(0, ((int) (text.length() * fitRatio)) - 1) + ELLIPSIS; 325 } 326 context.drawString(text, 2, y); 327 } else { 328 List<IRenderedRow> subdivision = row.getNestedRows(); 329 if (!subdivision.isEmpty()) { 330 for (IRenderedRow nestedRow : row.getNestedRows()) { 331 renderText(context, nestedRow); 332 } 333 return; 334 } 335 } 336 } 337 context.translate(0, height); 338 } 339 340 /** 341 * Pan the view. 342 * 343 * @param rightPercent 344 * @return true if the bounds changed. That is, if a redraw is required. 345 */ 346 public boolean pan(int rightPercent) { 347 if (rangeDuration != null) { 348 return panRange(Integer.signum(rightPercent)); 349 } 350 if (xBucketRange != null) { 351 IQuantity oldStart = currentStart; 352 IQuantity oldEnd = currentEnd; 353 if (rightPercent > 0) { 354 currentEnd = QuantitiesToolkit 355 .min(xBucketRange.getQuantityAtPixel(axisWidth + axisWidth * rightPercent / 100), end); 356 currentStart = QuantitiesToolkit 357 .max(xBucketRange.getQuantityAtPixel(xBucketRange.getPixel(currentEnd) - axisWidth), start); 358 } else if (rightPercent < 0) { 359 currentStart = QuantitiesToolkit.max(xBucketRange.getQuantityAtPixel(axisWidth * rightPercent / 100), 360 start); 361 currentEnd = QuantitiesToolkit 362 .min(xBucketRange.getQuantityAtPixel(xBucketRange.getPixel(currentStart) + axisWidth), end); 363 } 364 return (currentStart.compareTo(oldStart) != 0) || (currentEnd.compareTo(oldEnd) != 0); 365 } 366 // Return true since a redraw forces creation of xBucketRange. 367 return true; 368 } 369 370 /** 371 * Pan the view at a rate relative the current zoom level. 372 * @param panDirection -1 to pan left, 1 to pan right 373 * @return true if the chart needs to be redrawn 374 */ 375 public boolean panRange(int panDirection) { 376 if (zoomSteps == 0 || panDirection == 0 || 377 (currentStart.compareTo(start) == 0 && panDirection == -1) || 378 (currentEnd.compareTo(end) == 0 && panDirection == 1)) { 379 return false; 380 } 381 382 IQuantity panDiff = rangeDuration.multiply(panDirection * zoomPanPower); 383 IQuantity newStart = currentStart.in(UnitLookup.EPOCH_NS).add(panDiff); 384 IQuantity newEnd = currentEnd.in(UnitLookup.EPOCH_NS).add(panDiff); 385 386 // if panning would flow over the recording range start or end time, 387 // calculate the difference and add it so the other side. 388 if (newStart.compareTo(start) < 0) { 389 IQuantity diff = start.subtract(newStart); 390 newStart = start; 391 newEnd = newEnd.add(diff); 392 } else if (newEnd.compareTo(end) > 0) { 393 IQuantity diff = newEnd.subtract(end); 394 newStart = newStart.add(diff); 395 newEnd = end; 396 } 397 currentStart = newStart; 398 currentEnd = newEnd; 399 filterBar.setStartTime(currentStart); 400 filterBar.setEndTime(currentEnd); 401 isZoomCalculated = true; 402 return true; 403 } 404 405 /** 406 * Zoom the view. 407 * 408 * @param zoomInSteps 409 * @return true if the bounds changed. That is, if a redraw is required. 410 */ 411 public boolean zoom(int zoomInSteps) { 412 if (rangeDuration != null) { 413 return zoomRange(zoomInSteps); 414 } 415 return zoomXAxis(axisWidth / 2, zoomInSteps); 416 } 417 418 /** 419 * Zoom the view. 420 * 421 * @param x 422 * @param zoomInSteps 423 * @return true if the bounds changed. That is, if a redraw is required. 424 */ 425 public boolean zoom(int x, int zoomInSteps) { 426 return zoomXAxis(x - xOffset, zoomInSteps); 427 } 428 429 // Default zoom mechanics 430 private boolean zoomXAxis(int x, int zoomInSteps) { 431 if (xBucketRange == null) { 432 // Return true since a redraw forces creation of xBucketRange. 433 return true; 434 } 435 if ((x > 0) && (x < axisWidth)) { 436 IQuantity oldStart = currentStart; 437 IQuantity oldEnd = currentEnd; 438 // Absolute value of zoomFactor must be less than 1. Currently it ranges between -0.5 and 0.5. 439 double zoomFactor = Math.atan(zoomInSteps) / Math.PI; 440 int newStart = (int) (zoomFactor * x); 441 int newEnd = (int) (axisWidth * (1 - zoomFactor)) + newStart; 442 SubdividedQuantityRange xAxis = new SubdividedQuantityRange(currentStart, currentEnd, axisWidth, 1); 443 setVisibleRange(xAxis.getQuantityAtPixel(newStart), xAxis.getQuantityAtPixel(newEnd)); 444 return (currentStart.compareTo(oldStart) != 0) || (currentEnd.compareTo(oldEnd) != 0); 445 } 446 return false; 447 } 448 449 /** 450 * Zoom based on a percentage of the recording range 451 * @param zoomInSteps 452 * @return true if a redraw is required as a result of a successful zoom 453 */ 454 public boolean zoomRange(int zoomInSteps) { 455 if (zoomInSteps > 0) { 456 zoomIn(); 457 } else { 458 if (zoomSteps == 0) { 459 return false; 460 } 461 zoomOut(); 462 } 463 // set displayBar text 464 displayBar.setZoomPercentageText(currentZoom); 465 return true; 466 } 467 468 /** 469 * Zoom into the chart at a rate of 5% of the overall recording range at each step. 470 * If the chart is zoomed in far enough such that one more step at 5% is not possible, 471 * the zoom power is halved and the zoom will proceed. 472 * <br> 473 * Every time the zoom power is halved, the instigating step value is pushed onto the 474 * modifiedSteps stack. This stack is consulted on zoom out events in order to ensure 475 * the chart zooms out the same way it was zoomed in. 476 */ 477 private void zoomIn() { 478 IQuantity zoomDiff = rangeDuration.multiply(zoomPanPower); 479 IQuantity newStart = currentStart.in(UnitLookup.EPOCH_NS).add(zoomDiff); 480 IQuantity newEnd = currentEnd.in(UnitLookup.EPOCH_NS).subtract(zoomDiff); 481 if (newStart.compareTo(newEnd) >= 0) { // adjust the zoom factor 482 if (modifiedSteps == null) { 483 modifiedSteps = new Stack<Integer>(); 484 } 485 modifiedSteps.push(zoomSteps); 486 zoomPanPower = zoomPanPower / ZOOM_PAN_MODIFIER; 487 zoomDiff = rangeDuration.multiply(zoomPanPower); 488 newStart = currentStart.in(UnitLookup.EPOCH_NS).add(zoomDiff); 489 newEnd = currentEnd.in(UnitLookup.EPOCH_NS).subtract(zoomDiff); 490 } 491 currentZoom = currentZoom + (zoomPanPower * ZOOM_PAN_MODIFIER * 100); 492 isZoomCalculated = true; 493 setVisibleRange(newStart, newEnd); 494 zoomSteps++; 495 } 496 497 /** 498 * Zoom out of the chart at a rate equal to the how the chart was zoomed in. 499 */ 500 private void zoomOut() { 501 if (modifiedSteps != null && modifiedSteps.size() > 0 && modifiedSteps.peek() == zoomSteps) { 502 modifiedSteps.pop(); 503 zoomPanPower = zoomPanPower * ZOOM_PAN_MODIFIER; 504 } 505 IQuantity zoomDiff = rangeDuration.multiply(zoomPanPower); 506 IQuantity newStart = currentStart.in(UnitLookup.EPOCH_NS).subtract(zoomDiff); 507 IQuantity newEnd = currentEnd.in(UnitLookup.EPOCH_NS).add(zoomDiff); 508 509 // if zooming out would flow over the recording range start or end time, 510 // calculate the difference and add it to the other side. 511 if (newStart.compareTo(start) < 0) { 512 IQuantity diff = start.subtract(newStart); 513 newStart = start; 514 newEnd = newEnd.add(diff); 515 } else if (newEnd.compareTo(end) > 0) { 516 IQuantity diff = newEnd.subtract(end); 517 newStart = newStart.subtract(diff); 518 newEnd = end; 519 } 520 currentZoom = currentZoom - (zoomPanPower * ZOOM_PAN_MODIFIER * 100); 521 if (currentZoom < 100) { 522 currentZoom = 100; 523 } 524 isZoomCalculated = true; 525 setVisibleRange(newStart, newEnd); 526 zoomSteps--; 527 } 528 529 // need to check from ChartAndPopupTableUI if not using the OG start/end position, 530 // will have to calculate the new zoom level 531 public void resetZoomFactor() { 532 zoomSteps = 0; 533 zoomPanPower = ZOOM_PAN_FACTOR / ZOOM_PAN_MODIFIER; 534 currentZoom = 100; 535 displayBar.setZoomPercentageText(currentZoom); 536 modifiedSteps = new Stack<Integer>(); 537 } 538 539 /** 540 * Reset the visible range to be the recording range, and reset the zoom-related objects 541 */ 542 public void resetTimeline() { 543 resetZoomFactor(); 544 setVisibleRange(start, end); 545 } 546 547 private void selectionZoom(IQuantity newStart, IQuantity newEnd) { 548 double percentage = calculateZoom(newStart, newEnd); 549 zoomSteps = calculateZoomSteps(percentage); 550 currentZoom = 100 + (percentage * 100); 551 displayBar.setScaleValue(zoomSteps); 552 displayBar.setZoomPercentageText(currentZoom); 553 } 554 555 /** 556 * When a drag-select zoom occurs, use the new range value to determine how many steps have been taken, 557 * and adjust zoomSteps and zoomPower accordingly 558 */ 559 private double calculateZoom(IQuantity newStart, IQuantity newEnd) { 560 // calculate the new visible range, and it's percentage of the total range 561 IQuantity newRange = newEnd.in(UnitLookup.EPOCH_NS).subtract(newStart.in(UnitLookup.EPOCH_NS)); 562 return 1 - (newRange.longValue() / (double) rangeDuration.in(UnitLookup.NANOSECOND).longValue()); 563 } 564 565 /** 566 * Calculate the number of steps required to achieve the passed zoom percentage 567 */ 568 private int calculateZoomSteps(double percentage) { 569 double tempPercent = 0; 570 int steps = 0; 571 do { 572 tempPercent = tempPercent + ZOOM_PAN_FACTOR; 573 steps++; 574 } while (tempPercent <= percentage); 575 return steps; 576 } 577 578 private boolean isZoomCalculated; 579 private boolean isZoomPanDrag; 580 581 public void setIsZoomPanDrag(boolean isZoomPanDrag) { 582 this.isZoomPanDrag = isZoomPanDrag; 583 } 584 585 private boolean getIsZoomPanDrag() { 586 return isZoomPanDrag; 587 } 588 589 public void setVisibleRange(IQuantity rangeStart, IQuantity rangeEnd) { 590 if (rangeDuration != null && !isZoomCalculated && rangeStart != start && !getIsZoomPanDrag()) { 591 selectionZoom(rangeStart, rangeEnd); 592 } 593 isZoomCalculated = false; 594 rangeStart = QuantitiesToolkit.max(rangeStart, start); 595 rangeEnd = QuantitiesToolkit.min(rangeEnd, end); 596 if (rangeStart.compareTo(rangeEnd) < 0) { 597 SubdividedQuantityRange testRange = new SubdividedQuantityRange(rangeStart, rangeEnd, 10000, 1); 598 if (testRange.getQuantityAtPixel(0).compareTo(testRange.getQuantityAtPixel(1)) < 0) { 599 currentStart = rangeStart; 600 currentEnd = rangeEnd; 601 } else { 602 // Ensures that zoom out is always allowed 603 currentStart = QuantitiesToolkit.min(rangeStart, currentStart); 604 currentEnd = QuantitiesToolkit.max(rangeEnd, currentEnd); 605 } 606 if (filterBar != null) { 607 filterBar.setStartTime(currentStart); 608 filterBar.setEndTime(currentEnd); 609 } 610 rangeListeners.stream().forEach(l -> l.accept(getVisibleRange())); 611 } 612 } 613 614 private List<Consumer<IRange<IQuantity>>> rangeListeners = new ArrayList<>(); 615 616 public void addVisibleRangeListener(Consumer<IRange<IQuantity>> rangeListener) { 617 rangeListeners.add(rangeListener); 618 } 619 620 public IRange<IQuantity> getVisibleRange() { 621 return (currentStart != null) && (currentEnd != null) ? QuantityRange.createWithEnd(currentStart, currentEnd) 622 : null; 623 } 624 625 public void clearVisibleRange() { 626 currentStart = start; 627 currentEnd = end; 628 } 629 630 public boolean select(int x1, int x2, int y1, int y2, boolean clear) { 631 int xStart = Math.min(x1, x2); 632 int xEnd = Math.max(x1, x2); 633 634 if (xBucketRange != null && (xEnd != xStart) && xEnd - xOffset >= 0) { 635 return select(xBucketRange.getQuantityAtPixel(Math.max(0, xStart - xOffset)), xBucketRange.getQuantityAtPixel(xEnd - xOffset), 636 y1, y2, clear); 637 } else { 638 return select(null, null, y1, y2, clear); 639 } 640 } 641 642 public boolean select(IQuantity xStart, IQuantity xEnd, int y1, int y2, boolean clear) { 643 if (xStart != null && xStart.compareTo(start) < 0) { 644 xStart = start; 645 } 646 if (xEnd != null && xEnd.compareTo(end) > 0) { 647 xEnd = end; 648 } 649 Set<Object> oldRows = null; 650 if (QuantitiesToolkit.same(selectionStart, xStart) && QuantitiesToolkit.same(selectionEnd, xEnd)) { 651 oldRows = new HashSet<>(selectedRows); 652 } 653 if (clear) { 654 selectedRows.clear(); 655 } 656 addSelectedRows(rendererResult, 0, Math.min(y1, y2), Math.max(y1, y2)); 657 selectionStart = xStart; 658 selectionEnd = xEnd; 659 return (oldRows == null) || !oldRows.equals(selectedRows); 660 } 661 662 public boolean clearSelection() { 663 if ((selectionStart == null) && (selectionEnd == null) && selectedRows.isEmpty()) { 664 return false; 665 } 666 selectedRows.clear(); 667 selectionStart = selectionEnd = null; 668 return true; 669 } 670 671 private boolean addSelectedRows(IRenderedRow row, int yRowStart, int ySelectionStart, int ySelectionEnd) { 672 List<IRenderedRow> subdivision = row.getNestedRows(); // height 1450, has all 32 rows 673 if (subdivision.isEmpty()) { 674 return addPayload(row); 675 } else { 676 boolean nestedHasPayload = false; 677 for (IRenderedRow nestedRow : row.getNestedRows()) { 678 int yRowEnd = yRowStart + nestedRow.getHeight(); 679 if (yRowStart > ySelectionEnd) { 680 break; 681 } else if (yRowEnd > ySelectionStart) { 682 nestedHasPayload |= addSelectedRows(nestedRow, yRowStart, ySelectionStart, ySelectionEnd); 683 } 684 yRowStart = yRowEnd; 685 } 686 return nestedHasPayload || addPayload(row); 687 } 688 } 689 690 private boolean addPayload(IRenderedRow row) { 691 Object payload = row.getPayload(); 692 if (payload != null) { 693 if (selectedRows.contains(payload)) { // ctrl+click deselection 694 selectedRows.remove(payload); 695 } else { 696 selectedRows.add(payload); 697 } 698 return true; 699 } 700 return false; 701 } 702 703 private void renderSelection(Graphics2D context, SubdividedQuantityRange xRange, int height) { 704 int selFrom = 0; 705 int selTo = axisWidth; 706 if (selectionStart != null && selectionEnd != null) { 707 selFrom = (int) xRange.getPixel(selectionStart); 708 // Removed "+ 1" for now to make the selection symmetrical with respect to chart highlights. 709 selTo = (int) xRange.getPixel(selectionEnd); 710 } 711 // FIXME: Would like to show selection by graying out the other parts, can we do that? 712 // if (selWidth > 0) { 713 // context.setColor(Color.WHITE); 714 // context.setXORMode(Color.BLACK); 715 // Stroke oldStroke = context.getStroke(); 716 // context.setStroke(SELECTION_STROKE); 717 // context.drawRect(selFrom, 0, selWidth, height); 718 // context.setStroke(oldStroke); 719 // context.setPaintMode(); 720 // } 721 if (selFrom > 0) { 722 dimRect(context, 0, selFrom, height); 723 context.setColor(Color.BLACK); 724 context.drawLine(selFrom, 0, selFrom, height); 725 } 726 if (selTo < axisWidth) { 727 dimRect(context, selTo, axisWidth - selTo, height); 728 context.setColor(Color.BLACK); 729 context.drawLine(selTo, 0, selTo, height); 730 } 731 } 732 733 private static void dimRect(Graphics2D context, int from, int width, int height) { 734 context.setColor(SELECTION_COLOR); 735 context.fillRect(from , 0, width, height); 736 } 737 738 /** 739 * Let the {@code visitor} visit the chart elements in the vicinity of {@code x} and {@code y}. 740 * The visitation should adhere to a basic front to back ordering, so that elements which 741 * <em>conceptually</em> are at the front should be visited first. Note that this has no direct 742 * link to the drawing order. Also, this doesn't dictate any particular order between elements 743 * that conceptually are at the same level. (Good practice is to visit elements from different 744 * sub charts in a consistent order. If the sub charts have some kind of fixed ordering, such as 745 * stacked line charts, this order from top to bottom seems most appropriate.) 746 * 747 * @param visitor 748 * @param x 749 * @param y 750 */ 751 public void infoAt(IChartInfoVisitor visitor, int x, int y) { 752 if (rendererResult == null) { 753 return; 754 } 755 final int height = rendererResult.getHeight(); 756 if (y < height) { 757 rendererResult.infoAt(visitor, x - xOffset, y, new Point(xOffset, 0)); 758 } else { 759 x -= xOffset; 760 if (x >= 0) { 761 // Snap to closest of ticks and buckets (useful even if no bar charts are shown). 762 int tickIndex = xTickRange.getClosestSubdividerAtPixel(x); 763 double tickX = xTickRange.getSubdividerPixel(tickIndex); 764 int bucketIndex = xBucketRange.getClosestSubdividerAtPixel(x); 765 double bucketX = xBucketRange.getSubdividerPixel(bucketIndex); 766 if (Math.abs(x - bucketX) < Math.abs(x - tickX)) { 767 visitor.visit(tickFor(xBucketRange, bucketIndex)); 768 } else { 769 visitor.visit(tickFor(xTickRange, tickIndex)); 770 } 771 } 772 } 773 } 774 775 private ITick tickFor(final SubdividedQuantityRange xRange, final int index) { 776 return new ITick() { 777 @Override 778 public IDisplayable getValue() { 779 return xRange.getSubdivider(index); 780 } 781 782 @Override 783 public Point2D getTarget() { 784 return new Point(xOffset + (int) xRange.getSubdividerPixel(index), rendererResult.getHeight() - 1); 785 } 786 }; 787 } 788 }