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