• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2016 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package org.drrickorang.loopback;
18 
19 import android.content.Context;
20 import android.graphics.Bitmap;
21 import android.graphics.Canvas;
22 import android.graphics.Color;
23 import android.graphics.Paint;
24 import android.graphics.Rect;
25 import android.graphics.RectF;
26 import android.view.View;
27 import android.widget.LinearLayout.LayoutParams;
28 
29 /**
30  * Creates a heat map graphic for glitches and callback durations over the time period of the test
31  * Instantiated view is used for displaying heat map on android device,  static methods can be used
32  * without an instantiated view to draw graph on a canvas for use in exporting an image file
33  */
34 public class GlitchAndCallbackHeatMapView extends View {
35 
36     private final BufferCallbackTimes mPlayerCallbackTimes;
37     private final BufferCallbackTimes mRecorderCallbackTimes;
38     private final int[] mGlitchTimes;
39     private boolean mGlitchesExceededCapacity;
40     private final int mTestDurationSeconds;
41     private final String mTitle;
42 
43     private static final int MILLIS_PER_SECOND = 1000;
44     private static final int SECONDS_PER_MINUTE = 60;
45     private static final int MINUTES_PER_HOUR = 60;
46     private static final int SECONDS_PER_HOUR = 3600;
47 
48     private static final int LABEL_SIZE = 36;
49     private static final int TITLE_SIZE = 80;
50     private static final int LINE_WIDTH = 5;
51     private static final int INNER_MARGIN = 20;
52     private static final int OUTER_MARGIN = 60;
53     private static final int COLOR_LEGEND_AREA_WIDTH = 250;
54     private static final int COLOR_LEGEND_WIDTH = 75;
55     private static final int EXCEEDED_LEGEND_WIDTH = 150;
56     private static final int MAX_DURATION_FOR_SECONDS_BUCKET = 240;
57     private static final int NUM_X_AXIS_TICKS = 9;
58     private static final int NUM_LEGEND_LABELS = 5;
59     private static final int TICK_SIZE = 30;
60 
61     private static final int MAX_COLOR = 0xFF0D47A1; // Dark Blue
62     private static final int START_COLOR = Color.WHITE;
63     private static final float LOG_FACTOR = 2.0f; // >=1 Higher value creates a more linear curve
64 
GlitchAndCallbackHeatMapView(Context context, BufferCallbackTimes recorderCallbackTimes, BufferCallbackTimes playerCallbackTimes, int[] glitchTimes, boolean glitchesExceededCapacity, int testDurationSeconds, String title)65     public GlitchAndCallbackHeatMapView(Context context, BufferCallbackTimes recorderCallbackTimes,
66                                         BufferCallbackTimes playerCallbackTimes, int[] glitchTimes,
67                                         boolean glitchesExceededCapacity, int testDurationSeconds,
68                                         String title) {
69         super(context);
70 
71         mRecorderCallbackTimes = recorderCallbackTimes;
72         mPlayerCallbackTimes = playerCallbackTimes;
73         mGlitchTimes = glitchTimes;
74         mGlitchesExceededCapacity = glitchesExceededCapacity;
75         mTestDurationSeconds = testDurationSeconds;
76         mTitle = title;
77 
78         setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT));
79         setWillNotDraw(false);
80     }
81 
82     @Override
onDraw(Canvas canvas)83     protected void onDraw(Canvas canvas) {
84         super.onDraw(canvas);
85         Bitmap bmpResult = Bitmap.createBitmap(canvas.getHeight(), canvas.getWidth(),
86                 Bitmap.Config.ARGB_8888);
87         // Provide rotated canvas to FillCanvas method
88         Canvas tmpCanvas = new Canvas(bmpResult);
89         fillCanvas(tmpCanvas, mRecorderCallbackTimes, mPlayerCallbackTimes, mGlitchTimes,
90                 mGlitchesExceededCapacity, mTestDurationSeconds, mTitle);
91         tmpCanvas.translate(-1 * tmpCanvas.getWidth(), 0);
92         tmpCanvas.rotate(-90, tmpCanvas.getWidth(), 0);
93         // Display landscape oriented image on android device
94         canvas.drawBitmap(bmpResult, tmpCanvas.getMatrix(), new Paint(Paint.ANTI_ALIAS_FLAG));
95     }
96 
97     /**
98      * Draw a heat map of callbacks and glitches for display on Android device or for export as png
99      */
fillCanvas(final Canvas canvas, final BufferCallbackTimes recorderCallbackTimes, final BufferCallbackTimes playerCallbackTimes, final int[] glitchTimes, final boolean glitchesExceededCapacity, final int testDurationSeconds, final String title)100     public static void fillCanvas(final Canvas canvas,
101                                   final BufferCallbackTimes recorderCallbackTimes,
102                                   final BufferCallbackTimes playerCallbackTimes,
103                                   final int[] glitchTimes, final boolean glitchesExceededCapacity,
104                                   final int testDurationSeconds, final String title) {
105 
106         final Paint heatPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
107         heatPaint.setStyle(Paint.Style.FILL);
108 
109         final Paint textPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
110         textPaint.setColor(Color.BLACK);
111         textPaint.setTextSize(LABEL_SIZE);
112         textPaint.setTextAlign(Paint.Align.CENTER);
113 
114         final Paint titlePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
115         titlePaint.setColor(Color.BLACK);
116         titlePaint.setTextAlign(Paint.Align.CENTER);
117         titlePaint.setTextSize(TITLE_SIZE);
118 
119         final Paint linePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
120         linePaint.setColor(Color.BLACK);
121         linePaint.setStyle(Paint.Style.STROKE);
122         linePaint.setStrokeWidth(LINE_WIDTH);
123 
124         final Paint colorPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
125         colorPaint.setStyle(Paint.Style.STROKE);
126 
127         ColorInterpolator colorInter = new ColorInterpolator(START_COLOR, MAX_COLOR);
128 
129         Rect textBounds = new Rect();
130         titlePaint.getTextBounds(title, 0, title.length(), textBounds);
131         Rect titleArea = new Rect(0, OUTER_MARGIN, canvas.getWidth(),
132                 OUTER_MARGIN + textBounds.height());
133 
134         Rect bottomLegendArea = new Rect(0, canvas.getHeight() - LABEL_SIZE - OUTER_MARGIN,
135                 canvas.getWidth(), canvas.getHeight() - OUTER_MARGIN);
136 
137         int graphWidth = canvas.getWidth() - COLOR_LEGEND_AREA_WIDTH - OUTER_MARGIN * 3;
138         int graphHeight = (bottomLegendArea.top - titleArea.bottom - OUTER_MARGIN * 3) / 2;
139 
140         Rect callbackHeatArea = new Rect(0, 0, graphWidth, graphHeight);
141         callbackHeatArea.offsetTo(OUTER_MARGIN, titleArea.bottom + OUTER_MARGIN);
142 
143         Rect glitchHeatArea = new Rect(0, 0, graphWidth, graphHeight);
144         glitchHeatArea.offsetTo(OUTER_MARGIN, callbackHeatArea.bottom + OUTER_MARGIN);
145 
146         final int bucketSize =
147                 testDurationSeconds < MAX_DURATION_FOR_SECONDS_BUCKET ? 1 : SECONDS_PER_MINUTE;
148 
149         String units = testDurationSeconds < MAX_DURATION_FOR_SECONDS_BUCKET ? "Second" : "Minute";
150         String glitchLabel = "Glitches Per " + units;
151         String callbackLabel = "Maximum Callback Duration(ms) Per " + units;
152 
153         // Create White background
154         canvas.drawColor(Color.WHITE);
155 
156         // Label Graph
157         canvas.drawText(title, titleArea.left + titleArea.width() / 2, titleArea.bottom,
158                 titlePaint);
159 
160         // Callback Graph /////////////
161         // label callback graph
162         Rect graphArea = new Rect(callbackHeatArea);
163         graphArea.left += LABEL_SIZE + INNER_MARGIN;
164         graphArea.bottom -= LABEL_SIZE;
165         graphArea.top += LABEL_SIZE + INNER_MARGIN;
166         canvas.drawText(callbackLabel, graphArea.left + graphArea.width() / 2,
167                 graphArea.top - INNER_MARGIN, textPaint);
168 
169         int labelX = graphArea.left - INNER_MARGIN;
170         int labelY = graphArea.top + graphArea.height() / 4;
171         canvas.save();
172         canvas.rotate(-90, labelX, labelY);
173         canvas.drawText("Recorder", labelX, labelY, textPaint);
174         canvas.restore();
175         labelY = graphArea.bottom - graphArea.height() / 4;
176         canvas.save();
177         canvas.rotate(-90, labelX, labelY);
178         canvas.drawText("Player", labelX, labelY, textPaint);
179         canvas.restore();
180 
181         // draw callback heat graph
182         CallbackGraphData recorderData =
183                 new CallbackGraphData(recorderCallbackTimes, bucketSize, testDurationSeconds);
184         CallbackGraphData playerData =
185                 new CallbackGraphData(playerCallbackTimes, bucketSize, testDurationSeconds);
186         int maxCallbackValue = Math.max(recorderData.getMax(), playerData.getMax());
187 
188         drawHeatMap(canvas, recorderData.getBucketedCallbacks(), maxCallbackValue, colorInter,
189                 recorderCallbackTimes.isCapacityExceeded(), recorderData.getLastFilledIndex(),
190                 new Rect(graphArea.left + LINE_WIDTH, graphArea.top,
191                         graphArea.right - LINE_WIDTH, graphArea.centerY()));
192         drawHeatMap(canvas, playerData.getBucketedCallbacks(), maxCallbackValue, colorInter,
193                 playerCallbackTimes.isCapacityExceeded(), playerData.getLastFilledIndex(),
194                 new Rect(graphArea.left + LINE_WIDTH, graphArea.centerY(),
195                         graphArea.right - LINE_WIDTH, graphArea.bottom));
196 
197         drawTimeTicks(canvas, testDurationSeconds, bucketSize, callbackHeatArea.bottom,
198                 graphArea.bottom, graphArea.left, graphArea.width(), textPaint, linePaint);
199 
200         // draw graph boarder
201         canvas.drawRect(graphArea, linePaint);
202 
203         // Callback Legend //////////////
204         if (maxCallbackValue > 0) {
205             Rect legendArea = new Rect(graphArea);
206             legendArea.left = graphArea.right + OUTER_MARGIN * 2;
207             legendArea.right = legendArea.left + COLOR_LEGEND_WIDTH;
208             drawColorLegend(canvas, maxCallbackValue, colorInter, linePaint, textPaint, legendArea);
209         }
210 
211 
212         // Glitch Graph /////////////
213         // label Glitch graph
214         graphArea.bottom = glitchHeatArea.bottom - LABEL_SIZE;
215         graphArea.top = glitchHeatArea.top + LABEL_SIZE + INNER_MARGIN;
216         canvas.drawText(glitchLabel, graphArea.left + graphArea.width() / 2,
217                 graphArea.top - INNER_MARGIN, textPaint);
218 
219         // draw glitch heat graph
220         int[] bucketedGlitches = new int[(testDurationSeconds + bucketSize - 1) / bucketSize];
221         int lastFilledGlitchBucket = bucketGlitches(glitchTimes, bucketSize * MILLIS_PER_SECOND,
222                 bucketedGlitches);
223         int maxGlitchValue = 0;
224         for (int totalGlitch : bucketedGlitches) {
225             maxGlitchValue = Math.max(totalGlitch, maxGlitchValue);
226         }
227         drawHeatMap(canvas, bucketedGlitches, maxGlitchValue, colorInter,
228                 glitchesExceededCapacity, lastFilledGlitchBucket,
229                 new Rect(graphArea.left + LINE_WIDTH, graphArea.top,
230                         graphArea.right - LINE_WIDTH, graphArea.bottom));
231 
232         drawTimeTicks(canvas, testDurationSeconds, bucketSize,
233                 graphArea.bottom + INNER_MARGIN + LABEL_SIZE, graphArea.bottom, graphArea.left,
234                 graphArea.width(), textPaint, linePaint);
235 
236         // draw graph border
237         canvas.drawRect(graphArea, linePaint);
238 
239         // Callback Legend //////////////
240         if (maxGlitchValue > 0) {
241             Rect legendArea = new Rect(graphArea);
242             legendArea.left = graphArea.right + OUTER_MARGIN * 2;
243             legendArea.right = legendArea.left + COLOR_LEGEND_WIDTH;
244 
245             drawColorLegend(canvas, maxGlitchValue, colorInter, linePaint, textPaint, legendArea);
246         }
247 
248         // Draw legend for exceeded capacity
249         if (playerCallbackTimes.isCapacityExceeded() || recorderCallbackTimes.isCapacityExceeded()
250                 || glitchesExceededCapacity) {
251             RectF exceededArea = new RectF(graphArea.left, bottomLegendArea.top,
252                     graphArea.left + EXCEEDED_LEGEND_WIDTH, bottomLegendArea.bottom);
253             drawExceededMarks(canvas, exceededArea);
254             canvas.drawRect(exceededArea, linePaint);
255             textPaint.setTextAlign(Paint.Align.LEFT);
256             canvas.drawText(" = No Data Available, Recording Capacity Exceeded",
257                     exceededArea.right + INNER_MARGIN, bottomLegendArea.bottom, textPaint);
258             textPaint.setTextAlign(Paint.Align.CENTER);
259         }
260 
261     }
262 
263     /**
264      * Find total number of glitches duration per minute or second
265      * Returns index of last minute or second bucket with a recorded glitches
266      */
267     private static int bucketGlitches(int[] glitchTimes, int bucketSizeMS, int[] bucketedGlitches) {
268         int bucketIndex = 0;
269 
270         for (int glitchMS : glitchTimes) {
271             bucketIndex = glitchMS / bucketSizeMS;
272             bucketedGlitches[bucketIndex]++;
273         }
274 
275         return bucketIndex;
276     }
277 
278     private static void drawHeatMap(Canvas canvas, int[] bucketedValues, int maxValue,
279                                     ColorInterpolator colorInter, boolean capacityExceeded,
280                                     int lastFilledIndex, Rect graphArea) {
281         Paint colorPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
282         colorPaint.setStyle(Paint.Style.FILL);
283         float rectWidth = (float) graphArea.width() / bucketedValues.length;
284         RectF colorRect = new RectF(graphArea.left, graphArea.top, graphArea.left + rectWidth,
285                 graphArea.bottom);
286 
287         // values are log scaled to a value between 0 and 1 using the following formula:
288         // (log(value + 1 ) / log(max + 1))^2
289         // Data is typically concentrated around the extreme high and low values,  This log scale
290         // allows low values to still be visible and the exponent makes the curve slightly more
291         // linear in order that the color gradients are still distinguishable
292 
293         float logMax = (float) Math.log(maxValue + 1);
294 
295         for (int i = 0; i <= lastFilledIndex; ++i) {
296             colorPaint.setColor(colorInter.getInterColor(
297                     (float) Math.pow((Math.log(bucketedValues[i] + 1) / logMax), LOG_FACTOR)));
298             canvas.drawRect(colorRect, colorPaint);
299             colorRect.offset(rectWidth, 0);
300         }
301 
302         if (capacityExceeded) {
303             colorRect.right = graphArea.right;
304             drawExceededMarks(canvas, colorRect);
305         }
306     }
307 
308     private static void drawColorLegend(Canvas canvas, int maxValue, ColorInterpolator colorInter,
309                                         Paint linePaint, Paint textPaint, Rect legendArea) {
310         Paint colorPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
311         colorPaint.setStyle(Paint.Style.STROKE);
312         colorPaint.setStrokeWidth(1);
313         textPaint.setTextAlign(Paint.Align.LEFT);
314 
315         float logMax = (float) Math.log(legendArea.height() + 1);
316         for (int y = legendArea.bottom; y >= legendArea.top; --y) {
317             float inter = (float) Math.pow(
318                     (Math.log(legendArea.bottom - y + 1) / logMax), LOG_FACTOR);
319             colorPaint.setColor(colorInter.getInterColor(inter));
320             canvas.drawLine(legendArea.left, y, legendArea.right, y, colorPaint);
321         }
322 
323         int tickSpacing = (maxValue + NUM_LEGEND_LABELS - 1) / NUM_LEGEND_LABELS;
324         for (int i = 0; i < maxValue; i += tickSpacing) {
325             float yPos = legendArea.bottom - (((float) i / maxValue) * legendArea.height());
326             canvas.drawText(Integer.toString(i), legendArea.right + INNER_MARGIN,
327                     yPos + LABEL_SIZE / 2, textPaint);
328             canvas.drawLine(legendArea.right, yPos, legendArea.right - TICK_SIZE, yPos,
329                     linePaint);
330         }
331         canvas.drawText(Integer.toString(maxValue), legendArea.right + INNER_MARGIN,
332                 legendArea.top + LABEL_SIZE / 2, textPaint);
333 
334         canvas.drawRect(legendArea, linePaint);
335         textPaint.setTextAlign(Paint.Align.CENTER);
336     }
337 
338     private static void drawTimeTicks(Canvas canvas, int testDurationSeconds, int bucketSizeSeconds,
339                                       int textYPos, int tickYPos, int startXPos, int width,
340                                       Paint textPaint, Paint linePaint) {
341 
342         int secondsPerTick;
343 
344         if (bucketSizeSeconds == SECONDS_PER_MINUTE) {
345             secondsPerTick = (((testDurationSeconds / SECONDS_PER_MINUTE) + NUM_X_AXIS_TICKS - 1) /
346                     NUM_X_AXIS_TICKS) * SECONDS_PER_MINUTE;
347         } else {
348             secondsPerTick = (testDurationSeconds + NUM_X_AXIS_TICKS - 1) / NUM_X_AXIS_TICKS;
349         }
350 
351         for (int seconds = 0; seconds <= testDurationSeconds - secondsPerTick;
352              seconds += secondsPerTick) {
353             float xPos = startXPos + (((float) seconds / testDurationSeconds) * width);
354 
355             if (bucketSizeSeconds == SECONDS_PER_MINUTE) {
356                 canvas.drawText(String.format("%dh:%02dm", seconds / SECONDS_PER_HOUR,
357                                 (seconds / SECONDS_PER_MINUTE) % MINUTES_PER_HOUR),
358                         xPos, textYPos, textPaint);
359             } else {
360                 canvas.drawText(String.format("%dm:%02ds", seconds / SECONDS_PER_MINUTE,
361                                 seconds % SECONDS_PER_MINUTE),
362                         xPos, textYPos, textPaint);
363             }
364 
365             canvas.drawLine(xPos, tickYPos, xPos, tickYPos - TICK_SIZE, linePaint);
366         }
367 
368         //Draw total duration marking on right side of graph
369         if (bucketSizeSeconds == SECONDS_PER_MINUTE) {
370             canvas.drawText(
371                     String.format("%dh:%02dm", testDurationSeconds / SECONDS_PER_HOUR,
372                             (testDurationSeconds / SECONDS_PER_MINUTE) % MINUTES_PER_HOUR),
373                     startXPos + width, textYPos, textPaint);
374         } else {
375             canvas.drawText(
376                     String.format("%dm:%02ds", testDurationSeconds / SECONDS_PER_MINUTE,
377                             testDurationSeconds % SECONDS_PER_MINUTE),
378                     startXPos + width, textYPos, textPaint);
379         }
380     }
381 
382     /**
383      * Draw hash marks across a given rectangle, used to indicate no data available for that
384      * time period
385      */
386     private static void drawExceededMarks(Canvas canvas, RectF rect) {
387 
388         final float LINE_WIDTH = 8;
389         final int STROKE_COLOR = Color.GRAY;
390         final float STROKE_OFFSET = LINE_WIDTH * 3; //space between lines
391 
392         Paint strikePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
393         strikePaint.setColor(STROKE_COLOR);
394         strikePaint.setStyle(Paint.Style.STROKE);
395         strikePaint.setStrokeWidth(LINE_WIDTH);
396 
397         canvas.save();
398         canvas.clipRect(rect);
399 
400         float startY = rect.bottom + STROKE_OFFSET;
401         float endY = rect.top - STROKE_OFFSET;
402         float startX = rect.left - rect.height();  //creates a 45 degree angle
403         float endX = rect.left;
404 
405         for (; startX < rect.right; startX += STROKE_OFFSET, endX += STROKE_OFFSET) {
406             canvas.drawLine(startX, startY, endX, endY, strikePaint);
407         }
408 
409         canvas.restore();
410     }
411 
412     private static class CallbackGraphData {
413 
414         private int[] mBucketedCallbacks;
415         private int mLastFilledIndex;
416 
417         /**
418          * Fills buckets with maximum callback duration per minute or second
419          */
420         CallbackGraphData(BufferCallbackTimes callbackTimes, int bucketSizeSeconds,
421                           int testDurationSeconds) {
422             mBucketedCallbacks =
423                     new int[(testDurationSeconds + bucketSizeSeconds - 1) / bucketSizeSeconds];
424             int bucketSizeMS = bucketSizeSeconds * MILLIS_PER_SECOND;
425             int bucketIndex = 0;
426             for (BufferCallbackTimes.BufferCallback callback : callbackTimes) {
427 
428                 bucketIndex = callback.timeStamp / bucketSizeMS;
429                 if (callback.callbackDuration > mBucketedCallbacks[bucketIndex]) {
430                     mBucketedCallbacks[bucketIndex] = callback.callbackDuration;
431                 }
432 
433                 // Original callback bucketing strategy, callbacks within a second/minute were added
434                 // together in attempt to capture total amount of lateness within a time period.
435                 // May become useful for debugging specific problems at some later date
436                 /*if (callback.callbackDuration > callbackTimes.getExpectedBufferPeriod()) {
437                     bucketedCallbacks[bucketIndex] += callback.callbackDuration;
438                 }*/
439             }
440             mLastFilledIndex = bucketIndex;
441         }
442 
443         public int getMax() {
444             int maxCallbackValue = 0;
445             for (int bucketValue : mBucketedCallbacks) {
446                 maxCallbackValue = Math.max(maxCallbackValue, bucketValue);
447             }
448             return maxCallbackValue;
449         }
450 
451         public int[] getBucketedCallbacks() {
452             return mBucketedCallbacks;
453         }
454 
455         public int getLastFilledIndex() {
456             return mLastFilledIndex;
457         }
458     }
459 
460     private static class ColorInterpolator {
461 
462         private final int mAlphaStart;
463         private final int mAlphaRange;
464         private final int mRedStart;
465         private final int mRedRange;
466         private final int mGreenStart;
467         private final int mGreenRange;
468         private final int mBlueStart;
469         private final int mBlueRange;
470 
471         public ColorInterpolator(int startColor, int endColor) {
472             mAlphaStart = Color.alpha(startColor);
473             mAlphaRange = Color.alpha(endColor) - mAlphaStart;
474 
475             mRedStart = Color.red(startColor);
476             mRedRange = Color.red(endColor) - mRedStart;
477 
478             mGreenStart = Color.green(startColor);
479             mGreenRange = Color.green(endColor) - mGreenStart;
480 
481             mBlueStart = Color.blue(startColor);
482             mBlueRange = Color.blue(endColor) - mBlueStart;
483         }
484 
485         /**
486          * Takes a float between 0 and 1 and returns a color int between mStartColor and mEndColor
487          **/
488         public int getInterColor(float input) {
489 
490             return Color.argb(
491                     mAlphaStart + (int) (input * mAlphaRange),
492                     mRedStart + (int) (input * mRedRange),
493                     mGreenStart + (int) (input * mGreenRange),
494                     mBlueStart + (int) (input * mBlueRange)
495             );
496         }
497     }
498 
499 }
500