/* * 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.chartlib; import com.android.annotations.NonNull; import com.android.annotations.VisibleForTesting; import java.awt.BasicStroke; import java.awt.Color; import java.awt.Dimension; import java.awt.FontMetrics; import java.awt.Graphics2D; import java.awt.RenderingHints; import java.awt.Stroke; import java.awt.event.ActionListener; import java.awt.event.HierarchyListener; import java.awt.geom.AffineTransform; import java.awt.geom.Arc2D; import java.awt.geom.Path2D; import java.awt.geom.Point2D; import java.util.HashMap; import java.util.Map; import javax.swing.Icon; import gnu.trove.TIntObjectHashMap; /** * 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. */ public final class TimelineComponent extends AnimatedComponent implements ActionListener, HierarchyListener { private static final Color TEXT_COLOR = new Color(128, 128, 128); 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 mBufferTime; @NonNull private final TimelineData mData; @NonNull private final EventData mEvents; private final float mInitialMax; private final float mAbsoluteMax; private final float mInitialMarkerSeparation; private String[] mStreamNames; private Color[] mStreamColors; private Map<Integer, Style> mStyles; private boolean mFirstFrame; /** * The current maximum range in y-axis units. */ private float mCurrentMax; /** * Marker separation in y-axis units. */ private float mMarkerSeparation; /** * 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 mEvenMarkersAlpha; /** * The current value in pixels where the x-axis is drawn. */ private int mBottom; /** * The current value in pixels where the right hand side y-axis is drawn. */ private int mRight; /** * The current scale from y-axis values to pixels. */ private float mYScale; /** * The current time value at the right edge of the timeline in seconds. */ private float mEndTime; /** * The current time value at the left edge of the timeline in seconds. */ private float mBeginTime; /** * How to render each event type. */ private TIntObjectHashMap<EventInfo> mEventsInfo; /** * The units of the y-axis values. */ private String mUnits; /** * The number of available local samples. */ private int mSize; /** * The times at which the samples occurred. */ private float[] mTimes; /** * The times at which the samples occurred. */ private int[] mTypes; /** * The render values of the samples depending on the layout mode, as in mValues[stream][sample] */ private final float[][] mValues; /** * The last values of the samples for each stream */ private final float[] mCurrent; /** * The number of events to render. */ private int mEventsSize; /** * The start time of each event. */ private float[] mEventStart; /** * The end time of each event, if NaN then the event did not end. */ private float[] mEventEnd; /** * The type of each event. */ private int[] mEventTypes; /** * The animated angle of an event in progress. */ private float mEventProgressStart; /** * The direction of the event animation. */ private float mEventProgressDir = 1.0f; /** * The current state for all in-progress events. */ private float mEventProgress; /** * 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 absoluteMax the absolute maximum value for the y-axis. * @param initialMarkerSeparation the initial separations for the markers on the y-axis. */ public TimelineComponent( @NonNull TimelineData data, @NonNull EventData events, float bufferTime, float initialMax, float absoluteMax, float initialMarkerSeparation) { super(FPS); mData = data; mEvents = events; mBufferTime = bufferTime; mInitialMax = initialMax; mAbsoluteMax = absoluteMax; mInitialMarkerSeparation = initialMarkerSeparation; int streams = mData.getStreamCount(); addHierarchyListener(this); mStreamNames = new String[streams]; mStreamColors = new Color[streams]; mValues = new float[streams][]; mCurrent = new float[streams]; mSize = 0; for (int i = 0; i < streams; i++) { mStreamNames[i] = "Stream " + i; mStreamColors[i] = Color.BLACK; } mStyles = new HashMap<Integer, Style>(); mUnits = ""; mEventsInfo = new TIntObjectHashMap<EventInfo>(); setOpaque(true); reset(); } public void configureStream(int stream, String name, Color color) { mStreamNames[stream] = name; mStreamColors[stream] = color; } public void configureEvent(int type, int stream, Icon icon, Color color, Color progress, boolean range) { mEventsInfo.put(type, new EventInfo(type, stream, icon, color, progress, range)); } public void configureType(int type, Style style) { mStyles.put(type, style); } public void configureUnits(String units) { mUnits = units; } public void reset() { mCurrentMax = mInitialMax; mMarkerSeparation = mInitialMarkerSeparation; mEvenMarkersAlpha = 1.0f; mFirstFrame = true; } @Override protected void draw(Graphics2D g2d) { Dimension dim = getSize(); mBottom = dim.height - BOTTOM_MARGIN; mRight = dim.width - RIGHT_MARGIN; g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g2d.setFont(DEFAULT_FONT); g2d.setClip(0, 0, dim.width, dim.height); g2d.setColor(getBackground()); g2d.fillRect(0, 0, dim.width, dim.height); g2d.setClip(LEFT_MARGIN, TOP_MARGIN, mRight - LEFT_MARGIN, mBottom - TOP_MARGIN); drawTimelineData(g2d); drawEvents(g2d); g2d.setClip(0, 0, dim.width, dim.height); drawLabels(g2d); drawTimeMarkers(g2d); drawMarkers(g2d); drawGuides(g2d); mFirstFrame = false; } @Override protected void debugDraw(Graphics2D g2d) { int drawn = 0; g2d.setFont(DEFAULT_FONT.deriveFont(5.0f)); for (int i = 0; i < mSize; ++i) { if (mTimes[i] > mBeginTime && mTimes[i] < mEndTime) { for (int j = 0; j < mValues.length; ++j) { int x = (int) timeToX(mTimes[i]); int y = (int) valueToY(mValues[j][i]); g2d.setColor(new Color((17 * mTypes[i]) % 255, (121 * mTypes[i]) % 255, (71 * mTypes[i]) % 255)); g2d.drawLine(x, y - 2, x, y + 2); g2d.drawLine(x - 2, y, x + 2, y); g2d.setColor(TEXT_COLOR); } drawn++; } } addDebugInfo("Drawn samples: %d", drawn); } private void drawTimelineData(Graphics2D g2d) { mYScale = (mBottom - TOP_MARGIN) / mCurrentMax; if (mSize > 1) { int from = 0; // Optimize to not render too many samples since they get clipped. while (from < mSize - 1 && mTimes[from + 1] < mBeginTime) { from++; } int to = from; while (to + 1 < mSize && mTimes[to] <= mEndTime) { to++; } if (from == to) { return; } int drawnSegments = 0; for (int j = mValues.length - 1; j >= 0; j--) { Path2D.Float path = new Path2D.Float(); path.moveTo(timeToX(mTimes[from]), valueToY(0.0f)); for (int i = from; i <= to; i++) { float val = mValues[j][i]; path.lineTo(timeToX(mTimes[i]), valueToY(Math.min(val, mAbsoluteMax))); } path.lineTo(timeToX(mTimes[to]), valueToY(0.0f)); g2d.setColor(mStreamColors[j]); g2d.fill(path); if (!mStyles.isEmpty()) { path = new Path2D.Float(); Stroke current = g2d.getStroke(); float step = 3.0f; float x0 = timeToX(mTimes[from]); float y0 = valueToY(mValues[j][from]); g2d.setColor(mStreamColors[j].darker()); Stroke stroke = null; float strokeScale = Float.NaN; for (int i = from + 1; i <= to; i++) { float x1 = timeToX(mTimes[i]); float y1 = valueToY(mValues[j][i]); Style style = mStyles.get(mTypes[i]); if (style != null && style != Style.NONE) { BasicStroke str = new BasicStroke(1.0f); float scale = 0; if (style == Style.DASHED) { float distance = (float) Point2D.distance(x0, y0, x1, y1); float delta = mTimes[i] * X_SCALE; scale = distance / (x1 - x0); str = new BasicStroke(1.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND, 0.0f, new float[]{step * scale}, (delta * scale) % (step * scale * 2)); } if (scale != strokeScale) { if (stroke != null) { g2d.setStroke(stroke); g2d.draw(path); path.reset(); drawnSegments++; } strokeScale = scale; stroke = str; path.moveTo(x0, y0); } path.lineTo(x1, y1); } x0 = x1; y0 = y1; } if (stroke != null) { g2d.setStroke(stroke); g2d.draw(path); drawnSegments++; } g2d.setStroke(current); } } addDebugInfo("Drawn segments: %d", drawnSegments); } addDebugInfo("Total samples: %d", mSize); } private float interpolate(int stream, int sample, float time) { int prev = sample > 0 ? sample - 1 : 0; int next = sample < mSize ? sample : mSize - 1; float a = mValues[stream][prev]; float b = mValues[stream][next]; float delta = mTimes[next] - mTimes[prev]; float ratio = delta != 0 ? (time - mTimes[prev]) / delta : 1.0f; return (b - a) * ratio + a; } private void drawEvents(Graphics2D g2d) { if (mSize > 0) { int drawnEvents = 0; AffineTransform tx = g2d.getTransform(); Stroke stroke = g2d.getStroke(); int s = 0; int e = 0; while (e < mEventsSize) { if (s < mSize && mTimes[s] < mEventStart[e]) { s++; } else if (Float.isNaN(mEventEnd[e]) || mEventEnd[e] > mBeginTime && mEventEnd[e] > mTimes[0]) { drawnEvents++; EventInfo info = mEventsInfo.get(mEventTypes[e]); float x = timeToX(mEventStart[e]); float y = valueToY(interpolate(info.stream, s, mEventStart[e])); AffineTransform dt = new AffineTransform(tx); dt.translate(x, y); g2d.setTransform(dt); info.icon.paintIcon(this, g2d, -info.icon.getIconWidth() / 2, -info.icon.getIconHeight() - 5); g2d.setTransform(tx); g2d.setStroke( new BasicStroke(1.5f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); Path2D.Float p = new Path2D.Float(); boolean closed = !Float.isNaN(mEventEnd[e]); if (info.range) { p.moveTo(x, mBottom); p.lineTo(x, y); float endTime = Float.isNaN(mEventEnd[e]) ? mEndTime : mEventEnd[e]; int i = s; for (; i < mSize && mTimes[i] < endTime; i++) { float val = mValues[info.stream][i]; p.lineTo(timeToX(mTimes[i]), valueToY(val)); } p.lineTo(timeToX(endTime), valueToY(interpolate(info.stream, i, endTime))); p.lineTo(timeToX(closed ? mEventEnd[e] : endTime), valueToY(0)); if (info.color != null) { g2d.setColor(info.color); g2d.fill(p); } g2d.setColor(info.progress); g2d.draw(p); } else { p.moveTo(x, y - 2.0f); p.lineTo(x, y + 2.0f); g2d.setColor(info.progress); g2d.draw(p); } if (!closed) { g2d.setColor(info.progress); // Draw in progress marker float end = 360 * mEventProgress; float start = mEventProgressStart; if (mEventProgressDir < 0.0f) { start += end; end = 360 - end; } g2d.draw(new Arc2D.Float( x + info.icon.getIconWidth() / 2 + 3, y - info.icon.getIconHeight() - 3, 6, 6, start, end, Arc2D.OPEN)); } e++; } else { e++; } } g2d.setStroke(stroke); addDebugInfo("Drawn events: %d", drawnEvents); } } private float valueToY(float val) { return mBottom - val * mYScale; } private float timeToX(float time) { return LEFT_MARGIN + (time - mBeginTime) * X_SCALE; } private void drawLabels(Graphics2D g2d) { g2d.setFont(DEFAULT_FONT); FontMetrics metrics = g2d.getFontMetrics(); for (int i = 0; i < mStreamNames.length && mSize > 0; i++) { g2d.setColor(mStreamColors[i]); int y = TOP_MARGIN + 15 + (mStreamNames.length - i - 1) * 20; g2d.fillRect(mRight + 20, y, 15, 15); g2d.setColor(TEXT_COLOR); g2d.drawString( String.format("%s [%.2f %s]", mStreamNames[i], mCurrent[i], mUnits), mRight + 40, y + 7 + metrics.getAscent() * .5f); } } private void drawTimeMarkers(Graphics2D g2d) { g2d.setFont(DEFAULT_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(mBeginTime), 0); sec < mEndTime; sec++) { float x = timeToX(sec); boolean big = sec % 5 == 0; if (big) { String text = formatTime(sec); g2d.drawString(text, x - metrics.stringWidth(text) + offset, mBottom + metrics.getAscent() + 5); } lines.moveTo(x, mBottom); lines.lineTo(x, mBottom + (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 (mYScale <= 0) { return; } int markers = (int) (mCurrentMax / mMarkerSeparation); float markerPosition = LEFT_MARGIN - 10; for (int i = 0; i < markers + 1; i++) { float markerValue = (i + 1) * mMarkerSeparation; int y = (int) valueToY(markerValue); // Too close to the top if (mCurrentMax - markerValue < mMarkerSeparation * 0.5f) { markerValue = mCurrentMax; //noinspection AssignmentToForLoopParameter i = markers; y = TOP_MARGIN; } if (i < markers && i % 2 == 0 && mEvenMarkersAlpha < 1.0f) { g2d.setColor( new Color(TEXT_COLOR.getColorSpace(), TEXT_COLOR.getColorComponents(null), mEvenMarkersAlpha)); } else { g2d.setColor(TEXT_COLOR); } g2d.drawLine(LEFT_MARGIN - 2, y, LEFT_MARGIN, y); FontMetrics metrics = getFontMetrics(DEFAULT_FONT); String marker = String.format("%.2f %s", markerValue, mUnits); g2d.drawString(marker, markerPosition - metrics.stringWidth(marker), y + metrics.getAscent() * 0.5f); } } private void drawGuides(Graphics2D g2d) { g2d.setColor(TEXT_COLOR); g2d.drawLine(LEFT_MARGIN - 10, mBottom, mRight + 10, mBottom); if (mYScale > 0) { g2d.drawLine(LEFT_MARGIN, mBottom, LEFT_MARGIN, TOP_MARGIN); g2d.drawLine(mRight, mBottom, mRight, TOP_MARGIN); } } @Override protected void updateData() { long start; synchronized (mData) { start = mData.getStartTime(); mSize = mData.size(); assert mData.getStreamCount() == mValues.length; if (mTimes == null || mTimes.length < mSize) { int alloc = Math.max(mSize, mTimes == null ? 64 : mTimes.length * 2); mTimes = new float[alloc]; mTypes = new int[alloc]; for (int j = 0; j < mData.getStreamCount(); ++j) { mValues[j] = new float[alloc]; } } for (int i = 0; i < mSize; ++i) { TimelineData.Sample sample = mData.get(i); mTimes[i] = sample.time; mTypes[i] = sample.type; float value = 0.0f; for (int j = 0; j < mData.getStreamCount(); ++j) { value += sample.values[j]; mValues[j][i] = value; } } for (int j = 0; j < mData.getStreamCount(); ++j) { mCurrent[j] = mSize > 0 ? mData.get(mSize - 1).values[j] : 0.0f; } // Calculate begin and end times in seconds. mEndTime = mData.getEndTime() - mBufferTime; mBeginTime = mEndTime - (mRight - LEFT_MARGIN) / X_SCALE; // Animate the current maximum towards the real one. float cappedMax = Math.min(mData.getMaxTotal(), mAbsoluteMax); if (cappedMax > mCurrentMax) { mCurrentMax = lerp(mCurrentMax, cappedMax, mFirstFrame ? 1.f : .95f); } // Animate the fade in/out of markers. FontMetrics metrics = getFontMetrics(DEFAULT_FONT); int ascent = metrics.getAscent(); float distance = mMarkerSeparation * mYScale; float evenMarkersTarget = 1.0f; if (distance < ascent * 2) { // Too many markers if (mEvenMarkersAlpha < 0.1f) { mMarkerSeparation *= 2; mEvenMarkersAlpha = 1.0f; } else { evenMarkersTarget = 0.0f; } } else if (distance > ascent * 5) { // Not enough if (mEvenMarkersAlpha > 0.9f) { mMarkerSeparation /= 2; mEvenMarkersAlpha = 0.0f; } } mEvenMarkersAlpha = lerp(mEvenMarkersAlpha, evenMarkersTarget, 0.999f); } synchronized (mEvents) { mEventsSize = mEvents.size(); if (mEventStart == null || mEventStart.length < mEventsSize) { int alloc = Math.max(mEventsSize, mEventStart == null ? 64 : mEventStart.length * 2); mEventStart = new float[alloc]; mEventEnd = new float[alloc]; mEventTypes = new int[alloc]; } for (int i = 0; i < mEventsSize; i++) { EventData.Event event = mEvents.get(i); mEventStart[i] = (event.from - start) / 1000.0f; mEventEnd[i] = event.to == -1 ? Float.NaN : (event.to - start) / 1000.0f; mEventTypes[i] = event.type; } // Animate events in progress if (mEventProgress > 0.95f) { mEventProgressDir = -mEventProgressDir; mEventProgress = 0.0f; } mEventProgressStart = (mEventProgressStart + mFrameLength * 200.0f) % 360.0f; mEventProgress = lerp(mEventProgress, 1.0f, .99f); } } public enum Style { NONE, SOLID, DASHED } private static class EventInfo { public final int type; public final int stream; public final Icon icon; public final Color color; public final Color progress; public final boolean range; private EventInfo(int type, int stream, Icon icon, Color color, Color progress, boolean range) { this.type = type; this.stream = stream; this.icon = icon; this.color = color; this.progress = progress; this.range = range; } } }