/*
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.tools.idea.memory;
import com.android.annotations.VisibleForTesting;
import com.intellij.ui.Gray;
import com.intellij.ui.JBColor;
import gnu.trove.TIntObjectHashMap;
import org.jetbrains.annotations.NotNull;
import javax.swing.*;
import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.HierarchyEvent;
import java.awt.event.HierarchyListener;
import java.awt.geom.AffineTransform;
import java.awt.geom.Path2D;
/**
* A component to display a TimelineData object. It locks the timeline object to prevent modifications to it while it's begin
* rendered, but objects of this class should not be accessed from different threads.
*/
@SuppressWarnings({"FieldAccessedSynchronizedAndUnsynchronized", "UseJBColor"})
public class TimelineComponent extends JComponent implements ActionListener, HierarchyListener {
private static final Color TEXT_COLOR = Gray._128;
private static final Font TIMELINE_FONT = new Font("Sans", Font.PLAIN, 10);
private static final int LEFT_MARGIN = 120;
private static final int RIGHT_MARGIN = 200;
private static final int TOP_MARGIN = 10;
private static final int BOTTOM_MARGIN = 30;
private static final int FPS = 40;
/**
* The number of pixels a second in the timeline takes on the screen.
*/
private static final float X_SCALE = 20;
private final float myBufferTime;
@NotNull
private final TimelineData myData;
private final float myInitialMax;
private final float myInitialMarkerSeparation;
private final Timer myTimer;
private String[] myStreamNames;
private Color[] myStreamColors;
private boolean myFirstFrame;
private long myLastRenderTime;
private Path2D.Float[] myPaths;
private boolean myDrawDebugInfo;
/**
* The current maximum range in y-axis units.
*/
private float myCurrentMax;
/**
* Marker separation in y-axis units.
*/
private float myMarkerSeparation;
/**
* The current alpha of markers at even positions. When there are not enough/too many markers, the markers
* at even positions are faded in/out respectively. This tracks the animated alpha of such markers.
*/
private float myEvenMarkersAlpha;
/**
* The current value in pixels where the x-axis is drawn.
*/
private int myBottom;
/**
* The current value in pixels where the right hand side y-axis is drawn.
*/
private int myRight;
/**
* The length of the last frame in seconds.
*/
private float myFrameLength;
/**
* The current scale from y-axis values to pixels.
*/
private float myYScale;
/**
* The current time value at the right edge of the timeline in seconds.
*/
private float myEndTime;
/**
* The current time value at the left edge of the timeline in seconds.
*/
private float myBeginTime;
/**
* The current state for all in-progress markers.
*/
private float myEventProgress;
/**
* Which sample types should be rendered as events.
*/
private TIntObjectHashMap<Event> myEvents;
/**
* The units of the y-axis values.
*/
private String myUnits;
/**
* Creates a timeline component that renders the given timeline data. It will animate the timeline data by showing the
* value at the current time on the right y-axis of the graph.
*
* @param data the data to be displayed.
* @param bufferTime the time, in seconds, to lag behind the given {@code data}.
* @param initialMax the initial maximum value for the y-axis.
* @param initialMarkerSeparation the initial separations for the markers on the y-axis.
*/
public TimelineComponent(@NotNull TimelineData data, float bufferTime, float initialMax, float initialMarkerSeparation) {
myData = data;
myBufferTime = bufferTime;
myInitialMax = initialMax;
myInitialMarkerSeparation = initialMarkerSeparation;
int streams = myData.getStreamCount();
myTimer = new Timer(1000 / FPS, this);
addHierarchyListener(this);
myPaths = new Path2D.Float[streams];
myStreamNames = new String[streams];
myStreamColors = new Color[streams];
for (int i = 0; i < streams; i++) {
myPaths[i] = new Path2D.Float();
myStreamNames[i] = "Stream " + i;
myStreamColors[i] = Color.BLACK;
}
myUnits = "";
myEvents = new TIntObjectHashMap<Event>();
setOpaque(true);
reset();
}
public void configureStream(int stream, String name, JBColor color) {
myStreamNames[stream] = name;
myStreamColors[stream] = color;
}
public void configureEvent(int typeFrom, int typeTo, int stream, Icon icon, Color color, Color progress) {
myEvents.put(typeFrom, new Event(typeFrom, typeTo, stream, icon, color, progress));
}
public void configureUnits(String units) {
myUnits = units;
}
/**
* A linear interpolation that accumulates over time. This gives an exponential effect where the value {@code from} moves
* towards the value {@code to} at a rate of {@code fraction} per second. The actual interpolated amount depends
* on the current frame length.
*
* @param from the value to interpolate from.
* @param to the target value.
* @param fraction the interpolation fraction.
* @return the interpolated value.
*/
private float lerp(float from, float to, float fraction) {
float q = (float)Math.pow(1.0f - fraction, myFrameLength);
return from * q + to * (1.0f - q);
}
public void reset() {
myCurrentMax = myInitialMax;
myMarkerSeparation = myInitialMarkerSeparation;
myEvenMarkersAlpha = 1.0f;
myFirstFrame = true;
}
public boolean isDrawDebugInfo() {
return myDrawDebugInfo;
}
public void setDrawDebugInfo(boolean drawDebugInfo) {
myDrawDebugInfo = drawDebugInfo;
}
@Override
public void paintComponent(Graphics g) {
Graphics2D g2d = (Graphics2D)g.create();
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.setFont(TIMELINE_FONT);
Dimension dim = getSize();
g2d.setClip(0, 0, dim.width, dim.height);
g2d.setColor(getBackground());
g2d.fillRect(0, 0, dim.width, dim.height);
myBottom = dim.height - BOTTOM_MARGIN;
myRight = dim.width - RIGHT_MARGIN;
// Update frame length.
long now = System.nanoTime();
myFrameLength = (now - myLastRenderTime) / 1000000000.0f;
myLastRenderTime = now;
synchronized (myData) {
// Calculate begin and end times in seconds.
myEndTime = myData.getEndTime() - myBufferTime;
myBeginTime = myEndTime - (myRight - LEFT_MARGIN) / X_SCALE;
// Animate the current maximum towards the real one.
if (myData.getMaxTotal() > myCurrentMax) {
myCurrentMax = lerp(myCurrentMax, myData.getMaxTotal(), myFirstFrame ? 1.f : .95f);
}
myYScale = (myBottom - TOP_MARGIN) / myCurrentMax;
g2d.setClip(LEFT_MARGIN, TOP_MARGIN, myRight - LEFT_MARGIN, myBottom - TOP_MARGIN);
drawTimelineData(g2d);
drawEvents(g2d);
g2d.setClip(0, 0, dim.width, dim.height);
drawLabels(g2d);
drawTimeMarkers(g2d);
drawMarkers(g2d);
drawGuides(g2d);
if (myDrawDebugInfo) {
drawDebugInfo(g2d);
}
g2d.dispose();
}
myFirstFrame = false;
}
private void drawDebugInfo(Graphics2D g2d) {
int size = myData.size();
int drawn = 0;
g2d.setFont(TIMELINE_FONT.deriveFont(5.0f));
for (int i = 0; i < size; ++i) {
TimelineData.Sample sample = myData.get(i);
if (sample.time > myBeginTime && sample.time < myEndTime) {
float v = 0.0f;
for (float f : sample.values) {
v += f;
int x = (int)timeToX(sample.time);
int y = (int)valueToY(v);
Color c = new Color((17 * sample.type) % 255, (121 * sample.type) % 255, (71 * sample.type) % 255);
g2d.setColor(c);
g2d.drawLine(x, y - 2, x, y + 2);
g2d.drawLine(x - 2, y, x + 2, y);
g2d.setColor(TEXT_COLOR);
if (sample.id > 0) {
g2d.drawString(String.format("[%d]", sample.id), x - 3, y - 5);
}
}
drawn++;
}
}
g2d.setFont(TIMELINE_FONT);
g2d.drawString(String.format("FPS: %.2f", (1.0f / myFrameLength)), myRight + 20, myBottom - 40);
g2d.drawString(String.format("Total samples: %d", size), myRight + 20, myBottom - 30);
g2d.drawString(String.format("Drawn samples: %d", drawn), myRight + 20, myBottom - 20);
g2d.drawString(String.format("Render time: %.2fms", (System.nanoTime() - myLastRenderTime) / 1000000.f), myRight + 20, myBottom - 10);
}
@Override
public void actionPerformed(ActionEvent actionEvent) {
repaint();
}
@Override
public void hierarchyChanged(HierarchyEvent hierarchyEvent) {
if (myTimer.isRunning() && !isShowing()) {
myTimer.stop();
} else if (!myTimer.isRunning() && isShowing()) {
myTimer.start();
}
}
private void drawTimelineData(Graphics2D g2d) {
if (myData.size() > 1) {
setPaths(0, myData.size());
for (int i = myPaths.length - 1; i >= 0; i--) {
g2d.setColor(myStreamColors[i]);
g2d.fill(myPaths[i]);
}
}
}
private void drawEvents(Graphics2D g2d) {
int size = myData.size();
AffineTransform tx = g2d.getTransform();
Stroke stroke = g2d.getStroke();
Event currentEvent = null;
int start = 0;
float startX = 0;
float startY = 0;
for (int i = 0; i < size + 1; ++i) {
TimelineData.Sample sample = i < size ? myData.get(i) : null;
Event event = sample == null ? null : myEvents.get(sample.type);
// A new event or the end of the current one.
if (sample == null || event != null || (currentEvent != null && currentEvent.typeTo == sample.type)) {
// If there was an event in progress, end it.
if (currentEvent != null) {
setPaths(start, i < size ? i + 1 : size);
g2d.setColor(currentEvent.color);
g2d.fill(myPaths[0]);
AffineTransform dt = new AffineTransform(tx);
dt.translate(startX, startY);
g2d.setTransform(dt);
Icon icon = currentEvent.icon;
icon.paintIcon(this, g2d, 0, -icon.getIconHeight());
g2d.setColor(currentEvent.progress);
g2d.setStroke(new BasicStroke(1.5f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
g2d.drawLine(0, 0, 0, (int)(myBottom - startY));
g2d.drawLine(0, 0, icon.getIconWidth(), 0);
if (sample == null) {
drawInProgressMarker(g2d, icon.getIconWidth() + 6, -icon.getIconHeight(), 6);
}
g2d.setTransform(tx);
}
if (sample != null) {
float x = timeToX(sample.time);
float y = valueToY(sample.values[0]);
currentEvent = event;
if (currentEvent != null) {
start = i;
startX = x;
startY = y;
}
}
}
}
g2d.setStroke(stroke);
}
private void drawInProgressMarker(Graphics2D g2d, int x, int y, int size) {
float dir = myEventProgress < 0.f ? -1.f : 1.f;
float startAngle = (System.currentTimeMillis() / 8) % 360;
float endAngle = 360 * myEventProgress * dir;
// Invert the animation if we move in the opposite direction.
if (dir < 0.0f) {
startAngle += endAngle;
endAngle = 360 - endAngle;
}
g2d.drawArc(x - size / 2, y - size / 2, size, size, (int)startAngle, (int)endAngle);
myEventProgress = myEventProgress * dir > 0.95f ? 0.01f * -dir : lerp(myEventProgress, dir, .9f);
}
private float valueToY(float val) {
return myBottom - val * myYScale;
}
private float timeToX(float time) {
return LEFT_MARGIN + (time - myBeginTime) * X_SCALE;
}
private void drawLabels(Graphics2D g2d) {
if (!myData.isEmpty()) {
TimelineData.Sample value = myData.get(myData.size() - 1);
g2d.setFont(TIMELINE_FONT);
FontMetrics metrics = g2d.getFontMetrics();
for (int i = 0; i < myData.getStreamCount(); i++) {
g2d.setColor(myStreamColors[i]);
int y = TOP_MARGIN + 15 + (myData.getStreamCount() - i - 1) * 20;
g2d.fillRect(myRight + 20, y, 15, 15);
g2d.setColor(TEXT_COLOR);
g2d.drawString(String.format("%s [%.2f %s]", myStreamNames[i], value.values[i], myUnits), myRight + 40,
y + 7 + metrics.getAscent() * .5f);
}
}
}
private void drawTimeMarkers(Graphics2D g2d) {
g2d.setFont(TIMELINE_FONT);
g2d.setColor(TEXT_COLOR);
FontMetrics metrics = g2d.getFontMetrics();
float offset = metrics.stringWidth("000") * 0.5f;
Path2D.Float lines = new Path2D.Float();
for (int sec = Math.max((int)Math.ceil(myBeginTime), 0); sec < myEndTime; sec++) {
float x = timeToX(sec);
boolean big = sec % 5 == 0;
if (big) {
String text = formatTime(sec);
g2d.drawString(text, x - metrics.stringWidth(text) + offset, myBottom + metrics.getAscent() + 5);
}
lines.moveTo(x, myBottom);
lines.lineTo(x, myBottom + (big ? 5 : 2));
}
g2d.draw(lines);
}
@VisibleForTesting
static String formatTime(int seconds) {
int[] factors = {60, seconds};
String[] suffix = {"m", "h"};
String ret = seconds % 60 + "s";
int t = seconds / 60;
for (int i = 0; i < suffix.length && t > 0; i++) {
ret = t % factors[i] + suffix[i] + " " + ret;
t /= factors[i];
}
return ret;
}
private void drawMarkers(Graphics2D g2d) {
if (myYScale <= 0) {
return;
}
// Animate the fade in/out of markers.
g2d.setFont(TIMELINE_FONT);
FontMetrics metrics = g2d.getFontMetrics();
int ascent = metrics.getAscent();
float distance = myMarkerSeparation * myYScale;
float evenMarkersTarget = 1.0f;
if (distance < ascent * 2) { // Too many markers
if (myEvenMarkersAlpha < 0.1f) {
myMarkerSeparation *= 2;
myEvenMarkersAlpha = 1.0f;
}
else {
evenMarkersTarget = 0.0f;
}
}
else if (distance > ascent * 5) { // Not enough
if (myEvenMarkersAlpha > 0.9f) {
myMarkerSeparation /= 2;
myEvenMarkersAlpha = 0.0f;
}
}
myEvenMarkersAlpha = lerp(myEvenMarkersAlpha, evenMarkersTarget, 0.999f);
int markers = (int)(myCurrentMax / myMarkerSeparation);
float markerPosition = LEFT_MARGIN - 10;
for (int i = 0; i < markers + 1; i++) {
float markerValue = (i + 1) * myMarkerSeparation;
int y = (int)valueToY(markerValue);
// Too close to the top
if (myCurrentMax - markerValue < myMarkerSeparation * 0.5f) {
markerValue = myCurrentMax;
//noinspection AssignmentToForLoopParameter
i = markers;
y = TOP_MARGIN;
}
if (i < markers && i % 2 == 0 && myEvenMarkersAlpha < 1.0f) {
g2d.setColor(new Color(TEXT_COLOR.getColorSpace(), TEXT_COLOR.getColorComponents(null), myEvenMarkersAlpha));
}
else {
g2d.setColor(TEXT_COLOR);
}
g2d.drawLine(LEFT_MARGIN - 2, y, LEFT_MARGIN, y);
String marker = String.format("%.2f %s", markerValue, myUnits);
g2d.drawString(marker, markerPosition - metrics.stringWidth(marker), y + ascent * 0.5f);
}
}
private void drawGuides(Graphics2D g2d) {
g2d.setColor(TEXT_COLOR);
g2d.drawLine(LEFT_MARGIN - 10, myBottom, myRight + 10, myBottom);
if (myYScale > 0) {
g2d.drawLine(LEFT_MARGIN, myBottom, LEFT_MARGIN, TOP_MARGIN);
g2d.drawLine(myRight, myBottom, myRight, TOP_MARGIN);
}
}
private void setPaths(int from, int to) {
for (Path2D.Float path : myPaths) {
path.reset();
}
if (to - from > 1) {
// Optimize to not render too many samples even though they get clipped.
while (from < to - 1 && myData.get(from + 1).time < myBeginTime) {
from++;
}
TimelineData.Sample sample = myData.get(from);
for (Path2D.Float path : myPaths) {
path.moveTo(timeToX(sample.time), valueToY(0.0f));
}
for (int i = from; i < to; i++) {
sample = myData.get(i);
float val = 0.0f;
for (int j = 0; j < sample.values.length; j++) {
val += sample.values[j];
myPaths[j].lineTo(timeToX(sample.time), valueToY(val));
}
// Stop rendering if we are over the end limit.
if (sample.time > myEndTime) {
break;
}
}
for (Path2D.Float path : myPaths) {
path.lineTo(timeToX(sample.time), valueToY(0.0f));
}
}
}
private static class Event {
public final int typeFrom;
public final int typeTo;
public final int stream;
public final Icon icon;
public final Color color;
public final Color progress;
private Event(int typeFrom, int typeTo, int stream, Icon icon, Color color, Color progress) {
this.typeFrom = typeFrom;
this.typeTo = typeTo;
this.stream = stream;
this.icon = icon;
this.color = color;
this.progress = progress;
}
}
}