--- old/application/org.openjdk.jmc.ui/src/main/java/org/openjdk/jmc/ui/charts/XYChart.java 2019-10-22 09:27:19.884127343 -0400 +++ new/application/org.openjdk.jmc.ui/src/main/java/org/openjdk/jmc/ui/charts/XYChart.java 2019-10-22 09:27:19.719124985 -0400 @@ -41,6 +41,7 @@ import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.Stack; import java.util.function.Consumer; import org.openjdk.jmc.common.IDisplayable; @@ -48,19 +49,24 @@ import org.openjdk.jmc.common.unit.IRange; import org.openjdk.jmc.common.unit.QuantitiesToolkit; import org.openjdk.jmc.common.unit.QuantityRange; +import org.openjdk.jmc.common.unit.UnitLookup; import org.openjdk.jmc.ui.charts.IChartInfoVisitor.ITick; +import org.openjdk.jmc.ui.misc.ChartDisplayControlBar; +import org.openjdk.jmc.ui.misc.TimelineCanvas; +import org.openjdk.jmc.ui.misc.PatternFly.Palette; public class XYChart { private static final String ELLIPSIS = "..."; //$NON-NLS-1$ private static final Color SELECTION_COLOR = new Color(255, 255, 255, 220); private static final Color RANGE_INDICATION_COLOR = new Color(255, 60, 20); - private static final int Y_OFFSET = 35; - private static final int RANGE_INDICATOR_HEIGHT = 4; + private static final int RANGE_INDICATOR_HEIGHT = 7; private final IQuantity start; private final IQuantity end; + private IQuantity rangeDuration; private IXDataRenderer rendererRoot; private IRenderedRow rendererResult; private final int xOffset; + private int yOffset = 35; private final int bucketWidth; // FIXME: Use bucketWidth * ticksPerBucket instead of hardcoded value? // private final int ticksPerBucket = 4; @@ -69,11 +75,23 @@ private IQuantity currentEnd; private final Set selectedRows = new HashSet<>(); + private int axisWidth; + private int rowColorCounter; private IQuantity selectionStart; private IQuantity selectionEnd; private SubdividedQuantityRange xBucketRange; private SubdividedQuantityRange xTickRange; - private int axisWidth; + + // JFR Threads Page + private static final double ZOOM_PAN_FACTOR = 0.05; + private static final int ZOOM_PAN_MODIFIER = 2; + private double zoomPanPower = ZOOM_PAN_FACTOR / ZOOM_PAN_MODIFIER; + private double currentZoom; + private int zoomSteps; + private ChartDisplayControlBar displayBar; + private ChartFilterControlBar filterBar; + private Stack modifiedSteps; + private TimelineCanvas timelineCanvas; public XYChart(IRange range, IXDataRenderer rendererRoot) { this(range.getStart(), range.getEnd(), rendererRoot); @@ -83,6 +101,18 @@ this(range.getStart(), range.getEnd(), rendererRoot, xOffset); } + // JFR Threads Page + public XYChart(IRange range, IXDataRenderer rendererRoot, int xOffset, Integer yOffset, TimelineCanvas timelineCanvas, ChartFilterControlBar filterBar, ChartDisplayControlBar displayBar) { + this(range.getStart(), range.getEnd(), rendererRoot, xOffset); + this.yOffset = yOffset; + this.timelineCanvas = timelineCanvas; + this.filterBar = filterBar; + this.displayBar = displayBar; + this.rangeDuration = range.getExtent(); + this.currentZoom = 100; + this.isZoomCalculated = false; + } + public XYChart(IRange range, IXDataRenderer rendererRoot, int xOffset, int bucketWidth) { this(range.getStart(), range.getEnd(), rendererRoot, xOffset, bucketWidth); } @@ -99,14 +129,14 @@ this.rendererRoot = rendererRoot; // Start value must always be strictly less than end assert (start.compareTo(end) < 0); - currentStart = start; + this.currentStart = start; this.start = start; - currentEnd = end; + this.currentEnd = end; this.end = end; this.xOffset = xOffset; this.bucketWidth = bucketWidth; } - + public void setRendererRoot(IXDataRenderer rendererRoot) { clearSelection(); this.rendererRoot = rendererRoot; @@ -133,8 +163,8 @@ ? QuantityRange.createWithEnd(selectionStart, selectionEnd) : null; } - public void render(Graphics2D context, int width, int height) { - if (width > xOffset && height > Y_OFFSET) { + public void renderChart(Graphics2D context, int width, int height) { + if (width > xOffset && height > yOffset) { axisWidth = width - xOffset; // FIXME: xBucketRange and xTickRange should be more related, so that each tick is typically an integer number of buckets (or possibly 2.5 buckets). xBucketRange = new SubdividedQuantityRange(currentStart, currentEnd, axisWidth, bucketWidth); @@ -142,8 +172,25 @@ xTickRange = new SubdividedQuantityRange(currentStart, currentEnd, axisWidth, 100); AffineTransform oldTransform = context.getTransform(); context.translate(xOffset, 0); - doRender(context, height - Y_OFFSET); + doRenderChart(context, height - yOffset); + context.setTransform(oldTransform); + } + } + + public void renderTextCanvasText(Graphics2D context, int width) { + axisWidth = width; + AffineTransform oldTransform = context.getTransform(); + doRenderTextCanvasText(context); + context.setTransform(oldTransform); + } + + public void renderText(Graphics2D context, int width, int height) { + if (width > xOffset && height > yOffset) { + axisWidth = xOffset; + AffineTransform oldTransform = context.getTransform(); + doRenderText(context); context.setTransform(oldTransform); + axisWidth = width - xOffset; } } @@ -152,7 +199,10 @@ SubdividedQuantityRange fullRangeAxis = new SubdividedQuantityRange(start, end, axisWidth, 25); int x1 = (int) fullRangeAxis.getPixel(currentStart); int x2 = (int) Math.ceil(fullRangeAxis.getPixel(currentEnd)); - if (x1 > 0 || x2 < axisWidth) { + + if (timelineCanvas != null) { + timelineCanvas.renderRangeIndicator(x1, x2); + } else { context.setPaint(RANGE_INDICATION_COLOR); context.fillRect(x1, rangeIndicatorY, x2 - x1, RANGE_INDICATOR_HEIGHT); context.setPaint(Color.DARK_GRAY); @@ -160,19 +210,24 @@ } } - private void doRender(Graphics2D context, int axisHeight) { + private void doRenderChart(Graphics2D context, int axisHeight) { + rowColorCounter = 0; context.setPaint(Color.LIGHT_GRAY); AWTChartToolkit.drawGrid(context, xTickRange, axisHeight, false); // Attempt to make graphs so low they cover the axis show by drawing the full axis first ... context.setPaint(Color.BLACK); - AWTChartToolkit.drawAxis(context, xTickRange, axisHeight - 1, false, 1 - xOffset, false); + if (timelineCanvas != null) { + timelineCanvas.setXTickRange(xTickRange); + } else { + AWTChartToolkit.drawAxis(context, xTickRange, axisHeight - 1, false, 1 - xOffset, false); + } // ... then the graph ... rendererResult = rendererRoot.render(context, xBucketRange, axisHeight); AffineTransform oldTransform = context.getTransform(); - renderText(context, rendererResult); + context.setTransform(oldTransform); if (!selectedRows.isEmpty()) { - renderSelection(context, rendererResult); + renderSelectionChart(context, rendererResult); context.setTransform(oldTransform); } // .. and finally a semitransparent axis line again. @@ -181,7 +236,48 @@ renderRangeIndication(context, axisHeight + 25); } - private void renderSelection(Graphics2D context, IRenderedRow row) { + private void doRenderText(Graphics2D context) { + AffineTransform oldTransform = context.getTransform(); + rowColorCounter = -1; + renderText(context, rendererResult); + context.setTransform(oldTransform); + } + + private void doRenderTextCanvasText(Graphics2D context) { + AffineTransform oldTransform = context.getTransform(); + rowColorCounter = 0; + renderText(context, rendererResult); + context.setTransform(oldTransform); + if (!selectedRows.isEmpty()) { + renderSelectionText(context, rendererResult); + context.setTransform(oldTransform); + } + } + + private void renderSelectionText(Graphics2D context, IRenderedRow row) { + if (selectedRows.contains(row.getPayload())) { + if (row.getHeight() != rendererResult.getHeight()) { + Color highlight = new Color(0, 206, 209, 20); + context.setColor(highlight); + context.fillRect(0, 0, axisWidth, row.getHeight()); + } else { + selectedRows.clear(); + } + } else { + List subdivision = row.getNestedRows(); + if (subdivision.isEmpty()) { + dimRect(context, 0, axisWidth, row.getHeight()); + } else { + for (IRenderedRow nestedRow : row.getNestedRows()) { + renderSelectionText(context, nestedRow); + } + return; + } + } + context.translate(0, row.getHeight()); + } + + private void renderSelectionChart(Graphics2D context, IRenderedRow row) { if (selectedRows.contains(row.getPayload())) { renderSelection(context, xBucketRange, row.getHeight()); } else { @@ -190,7 +286,7 @@ dimRect(context, 0, axisWidth, row.getHeight()); } else { for (IRenderedRow nestedRow : row.getNestedRows()) { - renderSelection(context, nestedRow); + renderSelectionChart(context, nestedRow); } return; } @@ -198,27 +294,36 @@ context.translate(0, row.getHeight()); } + // Paint the background of every-other row in a slightly different shade + // to better differentiate the thread lanes from one another + private void paintRowBackground(Graphics2D context, int height) { + if (rowColorCounter >= 0) { + if (rowColorCounter % 2 == 0) { + context.setColor(Palette.PF_BLACK_100.getAWTColor()); + } else { + context.setColor(Palette.PF_BLACK_200.getAWTColor()); + } + context.fillRect(0, 0, axisWidth, height); + rowColorCounter++; + } + } + private void renderText(Graphics2D context, IRenderedRow row) { String text = row.getName(); int height = row.getHeight(); if (height >= context.getFontMetrics().getHeight()) { if (text != null) { + paintRowBackground(context, row.getHeight()); context.setColor(Color.BLACK); - int y; - if (height > 40) { - context.drawLine(-xOffset, height - 1, -15, height - 1); - y = height - context.getFontMetrics().getHeight() / 2; - } else { - // draw the string in the middle of the row - y = ((height - context.getFontMetrics().getHeight()) / 2) + context.getFontMetrics().getAscent(); - } + context.drawLine(0, height - 1, axisWidth -15, height - 1); + int y = ((height - context.getFontMetrics().getHeight()) / 2) + context.getFontMetrics().getAscent(); int charsWidth = context.getFontMetrics().charsWidth(text.toCharArray(), 0, text.length()); - if (charsWidth > xOffset) { + if (xOffset > 0 && charsWidth > xOffset) { float fitRatio = ((float) xOffset) / (charsWidth + context.getFontMetrics().charsWidth(ELLIPSIS.toCharArray(), 0, ELLIPSIS.length())); text = text.substring(0, ((int) (text.length() * fitRatio)) - 1) + ELLIPSIS; } - context.drawString(text, -xOffset + 2, y); + context.drawString(text, 2, y); } else { List subdivision = row.getNestedRows(); if (!subdivision.isEmpty()) { @@ -239,6 +344,9 @@ * @return true if the bounds changed. That is, if a redraw is required. */ public boolean pan(int rightPercent) { + if (rangeDuration != null) { + return panRange(Integer.signum(rightPercent)); + } if (xBucketRange != null) { IQuantity oldStart = currentStart; IQuantity oldEnd = currentEnd; @@ -260,12 +368,50 @@ } /** + * Pan the view at a rate relative the current zoom level. + * @param panDirection -1 to pan left, 1 to pan right + * @return true if the chart needs to be redrawn + */ + public boolean panRange(int panDirection) { + if (zoomSteps == 0 || panDirection == 0 || + (currentStart.compareTo(start) == 0 && panDirection == -1) || + (currentEnd.compareTo(end) == 0 && panDirection == 1)) { + return false; + } + + IQuantity panDiff = rangeDuration.multiply(panDirection * zoomPanPower); + IQuantity newStart = currentStart.in(UnitLookup.EPOCH_NS).add(panDiff); + IQuantity newEnd = currentEnd.in(UnitLookup.EPOCH_NS).add(panDiff); + + // if panning would flow over the recording range start or end time, + // calculate the difference and add it so the other side. + if (newStart.compareTo(start) < 0) { + IQuantity diff = start.subtract(newStart); + newStart = start; + newEnd = newEnd.add(diff); + } else if (newEnd.compareTo(end) > 0) { + IQuantity diff = newEnd.subtract(end); + newStart = newStart.add(diff); + newEnd = end; + } + currentStart = newStart; + currentEnd = newEnd; + filterBar.setStartTime(currentStart); + filterBar.setEndTime(currentEnd); + isZoomCalculated = true; + return true; + } + + /** * Zoom the view. * * @param zoomInSteps * @return true if the bounds changed. That is, if a redraw is required. */ public boolean zoom(int zoomInSteps) { + if (rangeDuration != null) { + return zoomRange(zoomInSteps); + } return zoomXAxis(axisWidth / 2, zoomInSteps); } @@ -280,6 +426,7 @@ return zoomXAxis(x - xOffset, zoomInSteps); } + // Default zoom mechanics private boolean zoomXAxis(int x, int zoomInSteps) { if (xBucketRange == null) { // Return true since a redraw forces creation of xBucketRange. @@ -299,7 +446,151 @@ return false; } + /** + * Zoom based on a percentage of the recording range + * @param zoomInSteps + * @return true if a redraw is required as a result of a successful zoom + */ + public boolean zoomRange(int zoomInSteps) { + if (zoomInSteps > 0) { + zoomIn(); + } else { + if (zoomSteps == 0) { + return false; + } + zoomOut(); + } + // set displayBar text + displayBar.setZoomPercentageText(currentZoom); + return true; + } + + /** + * Zoom into the chart at a rate of 5% of the overall recording range at each step. + * If the chart is zoomed in far enough such that one more step at 5% is not possible, + * the zoom power is halved and the zoom will proceed. + *
+ * Every time the zoom power is halved, the instigating step value is pushed onto the + * modifiedSteps stack. This stack is consulted on zoom out events in order to ensure + * the chart zooms out the same way it was zoomed in. + */ + private void zoomIn() { + IQuantity zoomDiff = rangeDuration.multiply(zoomPanPower); + IQuantity newStart = currentStart.in(UnitLookup.EPOCH_NS).add(zoomDiff); + IQuantity newEnd = currentEnd.in(UnitLookup.EPOCH_NS).subtract(zoomDiff); + if (newStart.compareTo(newEnd) >= 0) { // adjust the zoom factor + if (modifiedSteps == null) { + modifiedSteps = new Stack(); + } + modifiedSteps.push(zoomSteps); + zoomPanPower = zoomPanPower / ZOOM_PAN_MODIFIER; + zoomDiff = rangeDuration.multiply(zoomPanPower); + newStart = currentStart.in(UnitLookup.EPOCH_NS).add(zoomDiff); + newEnd = currentEnd.in(UnitLookup.EPOCH_NS).subtract(zoomDiff); + } + currentZoom = currentZoom + (zoomPanPower * ZOOM_PAN_MODIFIER * 100); + isZoomCalculated = true; + setVisibleRange(newStart, newEnd); + zoomSteps++; + } + + /** + * Zoom out of the chart at a rate equal to the how the chart was zoomed in. + */ + private void zoomOut() { + if (modifiedSteps != null && modifiedSteps.size() > 0 && modifiedSteps.peek() == zoomSteps) { + modifiedSteps.pop(); + zoomPanPower = zoomPanPower * ZOOM_PAN_MODIFIER; + } + IQuantity zoomDiff = rangeDuration.multiply(zoomPanPower); + IQuantity newStart = currentStart.in(UnitLookup.EPOCH_NS).subtract(zoomDiff); + IQuantity newEnd = currentEnd.in(UnitLookup.EPOCH_NS).add(zoomDiff); + + // if zooming out would flow over the recording range start or end time, + // calculate the difference and add it to the other side. + if (newStart.compareTo(start) < 0) { + IQuantity diff = start.subtract(newStart); + newStart = start; + newEnd = newEnd.add(diff); + } else if (newEnd.compareTo(end) > 0) { + IQuantity diff = newEnd.subtract(end); + newStart = newStart.subtract(diff); + newEnd = end; + } + currentZoom = currentZoom - (zoomPanPower * ZOOM_PAN_MODIFIER * 100); + if (currentZoom < 100) { + currentZoom = 100; + } + isZoomCalculated = true; + setVisibleRange(newStart, newEnd); + zoomSteps--; + } + + // need to check from ChartAndPopupTableUI if not using the OG start/end position, + // will have to calculate the new zoom level + public void resetZoomFactor() { + zoomSteps = 0; + zoomPanPower = ZOOM_PAN_FACTOR / ZOOM_PAN_MODIFIER; + currentZoom = 100; + displayBar.setZoomPercentageText(currentZoom); + modifiedSteps = new Stack(); + } + + /** + * Reset the visible range to be the recording range, and reset the zoom-related objects + */ + public void resetTimeline() { + resetZoomFactor(); + setVisibleRange(start, end); + } + + private void selectionZoom(IQuantity newStart, IQuantity newEnd) { + double percentage = calculateZoom(newStart, newEnd); + zoomSteps = calculateZoomSteps(percentage); + currentZoom = 100 + (percentage * 100); + displayBar.setScaleValue(zoomSteps); + displayBar.setZoomPercentageText(currentZoom); + } + + /** + * When a drag-select zoom occurs, use the new range value to determine how many steps have been taken, + * and adjust zoomSteps and zoomPower accordingly + */ + private double calculateZoom(IQuantity newStart, IQuantity newEnd) { + // calculate the new visible range, and it's percentage of the total range + IQuantity newRange = newEnd.in(UnitLookup.EPOCH_NS).subtract(newStart.in(UnitLookup.EPOCH_NS)); + return 1 - (newRange.longValue() / (double) rangeDuration.in(UnitLookup.NANOSECOND).longValue()); + } + + /** + * Calculate the number of steps required to achieve the passed zoom percentage + */ + private int calculateZoomSteps(double percentage) { + double tempPercent = 0; + int steps = 0; + do { + tempPercent = tempPercent + ZOOM_PAN_FACTOR; + steps++; + } while (tempPercent <= percentage); + return steps; + } + + private boolean isZoomCalculated; + private boolean isZoomPanDrag; + + public void setIsZoomPanDrag(boolean isZoomPanDrag) { + this.isZoomPanDrag = isZoomPanDrag; + } + + private boolean getIsZoomPanDrag() { + return isZoomPanDrag; + } + public void setVisibleRange(IQuantity rangeStart, IQuantity rangeEnd) { + if (rangeDuration != null && !isZoomCalculated && rangeStart != start && !getIsZoomPanDrag()) { + selectionZoom(rangeStart, rangeEnd); + } + isZoomCalculated = false; rangeStart = QuantitiesToolkit.max(rangeStart, start); rangeEnd = QuantitiesToolkit.min(rangeEnd, end); if (rangeStart.compareTo(rangeEnd) < 0) { @@ -312,6 +603,10 @@ currentStart = QuantitiesToolkit.min(rangeStart, currentStart); currentEnd = QuantitiesToolkit.max(rangeEnd, currentEnd); } + if (filterBar != null) { + filterBar.setStartTime(currentStart); + filterBar.setEndTime(currentEnd); + } rangeListeners.stream().forEach(l -> l.accept(getVisibleRange())); } } @@ -332,19 +627,19 @@ currentEnd = end; } - public boolean select(int x1, int x2, int y1, int y2) { - int xStart = Math.min(x1, x2) - xOffset; - int xEnd = Math.max(x1, x2) - xOffset; - - if (xBucketRange != null && (xEnd >= 0)) { - return select(xBucketRange.getQuantityAtPixel(Math.max(0, xStart)), xBucketRange.getQuantityAtPixel(xEnd), - y1, y2); + public boolean select(int x1, int x2, int y1, int y2, boolean clear) { + int xStart = Math.min(x1, x2); + int xEnd = Math.max(x1, x2); + + if (xBucketRange != null && (xEnd != xStart) && xEnd - xOffset >= 0) { + return select(xBucketRange.getQuantityAtPixel(Math.max(0, xStart - xOffset)), xBucketRange.getQuantityAtPixel(xEnd - xOffset), + y1, y2, clear); } else { - return select(null, null, y1, y2); + return select(null, null, y1, y2, clear); } } - public boolean select(IQuantity xStart, IQuantity xEnd, int y1, int y2) { + public boolean select(IQuantity xStart, IQuantity xEnd, int y1, int y2, boolean clear) { if (xStart != null && xStart.compareTo(start) < 0) { xStart = start; } @@ -355,7 +650,9 @@ if (QuantitiesToolkit.same(selectionStart, xStart) && QuantitiesToolkit.same(selectionEnd, xEnd)) { oldRows = new HashSet<>(selectedRows); } - selectedRows.clear(); + if (clear) { + selectedRows.clear(); + } addSelectedRows(rendererResult, 0, Math.min(y1, y2), Math.max(y1, y2)); selectionStart = xStart; selectionEnd = xEnd; @@ -372,7 +669,7 @@ } private boolean addSelectedRows(IRenderedRow row, int yRowStart, int ySelectionStart, int ySelectionEnd) { - List subdivision = row.getNestedRows(); + List subdivision = row.getNestedRows(); // height 1450, has all 32 rows if (subdivision.isEmpty()) { return addPayload(row); } else { @@ -393,7 +690,11 @@ private boolean addPayload(IRenderedRow row) { Object payload = row.getPayload(); if (payload != null) { - selectedRows.add(payload); + if (selectedRows.contains(payload)) { // ctrl+click deselection + selectedRows.remove(payload); + } else { + selectedRows.add(payload); + } return true; } return false; @@ -431,7 +732,7 @@ private static void dimRect(Graphics2D context, int from, int width, int height) { context.setColor(SELECTION_COLOR); - context.fillRect(from, 0, width, height); + context.fillRect(from , 0, width, height); } /**