| /* |
| * Copyright (C) 2016 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 org.drrickorang.loopback; |
| |
| import android.content.Context; |
| import android.graphics.Bitmap; |
| import android.graphics.Canvas; |
| import android.graphics.Color; |
| import android.graphics.Paint; |
| import android.graphics.Rect; |
| import android.graphics.RectF; |
| import android.view.View; |
| import android.widget.LinearLayout.LayoutParams; |
| |
| /** |
| * Creates a heat map graphic for glitches and callback durations over the time period of the test |
| * Instantiated view is used for displaying heat map on android device, static methods can be used |
| * without an instantiated view to draw graph on a canvas for use in exporting an image file |
| */ |
| public class GlitchAndCallbackHeatMapView extends View { |
| |
| private final BufferCallbackTimes mPlayerCallbackTimes; |
| private final BufferCallbackTimes mRecorderCallbackTimes; |
| private final int[] mGlitchTimes; |
| private boolean mGlitchesExceededCapacity; |
| private final int mTestDurationSeconds; |
| private final String mTitle; |
| |
| private static final int MILLIS_PER_SECOND = 1000; |
| private static final int SECONDS_PER_MINUTE = 60; |
| private static final int MINUTES_PER_HOUR = 60; |
| private static final int SECONDS_PER_HOUR = 3600; |
| |
| private static final int LABEL_SIZE = 36; |
| private static final int TITLE_SIZE = 80; |
| private static final int LINE_WIDTH = 5; |
| private static final int INNER_MARGIN = 20; |
| private static final int OUTER_MARGIN = 60; |
| private static final int COLOR_LEGEND_AREA_WIDTH = 250; |
| private static final int COLOR_LEGEND_WIDTH = 75; |
| private static final int EXCEEDED_LEGEND_WIDTH = 150; |
| private static final int MAX_DURATION_FOR_SECONDS_BUCKET = 240; |
| private static final int NUM_X_AXIS_TICKS = 9; |
| private static final int NUM_LEGEND_LABELS = 5; |
| private static final int TICK_SIZE = 30; |
| |
| private static final int MAX_COLOR = 0xFF0D47A1; // Dark Blue |
| private static final int START_COLOR = Color.WHITE; |
| private static final float LOG_FACTOR = 2.0f; // >=1 Higher value creates a more linear curve |
| |
| public GlitchAndCallbackHeatMapView(Context context, BufferCallbackTimes recorderCallbackTimes, |
| BufferCallbackTimes playerCallbackTimes, int[] glitchTimes, |
| boolean glitchesExceededCapacity, int testDurationSeconds, |
| String title) { |
| super(context); |
| |
| mRecorderCallbackTimes = recorderCallbackTimes; |
| mPlayerCallbackTimes = playerCallbackTimes; |
| mGlitchTimes = glitchTimes; |
| mGlitchesExceededCapacity = glitchesExceededCapacity; |
| mTestDurationSeconds = testDurationSeconds; |
| mTitle = title; |
| |
| setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); |
| setWillNotDraw(false); |
| } |
| |
| @Override |
| protected void onDraw(Canvas canvas) { |
| super.onDraw(canvas); |
| Bitmap bmpResult = Bitmap.createBitmap(canvas.getHeight(), canvas.getWidth(), |
| Bitmap.Config.ARGB_8888); |
| // Provide rotated canvas to FillCanvas method |
| Canvas tmpCanvas = new Canvas(bmpResult); |
| fillCanvas(tmpCanvas, mRecorderCallbackTimes, mPlayerCallbackTimes, mGlitchTimes, |
| mGlitchesExceededCapacity, mTestDurationSeconds, mTitle); |
| tmpCanvas.translate(-1 * tmpCanvas.getWidth(), 0); |
| tmpCanvas.rotate(-90, tmpCanvas.getWidth(), 0); |
| // Display landscape oriented image on android device |
| canvas.drawBitmap(bmpResult, tmpCanvas.getMatrix(), new Paint(Paint.ANTI_ALIAS_FLAG)); |
| } |
| |
| /** |
| * Draw a heat map of callbacks and glitches for display on Android device or for export as png |
| */ |
| public static void fillCanvas(final Canvas canvas, |
| final BufferCallbackTimes recorderCallbackTimes, |
| final BufferCallbackTimes playerCallbackTimes, |
| final int[] glitchTimes, final boolean glitchesExceededCapacity, |
| final int testDurationSeconds, final String title) { |
| |
| final Paint heatPaint = new Paint(Paint.ANTI_ALIAS_FLAG); |
| heatPaint.setStyle(Paint.Style.FILL); |
| |
| final Paint textPaint = new Paint(Paint.ANTI_ALIAS_FLAG); |
| textPaint.setColor(Color.BLACK); |
| textPaint.setTextSize(LABEL_SIZE); |
| textPaint.setTextAlign(Paint.Align.CENTER); |
| |
| final Paint titlePaint = new Paint(Paint.ANTI_ALIAS_FLAG); |
| titlePaint.setColor(Color.BLACK); |
| titlePaint.setTextAlign(Paint.Align.CENTER); |
| titlePaint.setTextSize(TITLE_SIZE); |
| |
| final Paint linePaint = new Paint(Paint.ANTI_ALIAS_FLAG); |
| linePaint.setColor(Color.BLACK); |
| linePaint.setStyle(Paint.Style.STROKE); |
| linePaint.setStrokeWidth(LINE_WIDTH); |
| |
| final Paint colorPaint = new Paint(Paint.ANTI_ALIAS_FLAG); |
| colorPaint.setStyle(Paint.Style.STROKE); |
| |
| ColorInterpolator colorInter = new ColorInterpolator(START_COLOR, MAX_COLOR); |
| |
| Rect textBounds = new Rect(); |
| titlePaint.getTextBounds(title, 0, title.length(), textBounds); |
| Rect titleArea = new Rect(0, OUTER_MARGIN, canvas.getWidth(), |
| OUTER_MARGIN + textBounds.height()); |
| |
| Rect bottomLegendArea = new Rect(0, canvas.getHeight() - LABEL_SIZE - OUTER_MARGIN, |
| canvas.getWidth(), canvas.getHeight() - OUTER_MARGIN); |
| |
| int graphWidth = canvas.getWidth() - COLOR_LEGEND_AREA_WIDTH - OUTER_MARGIN * 3; |
| int graphHeight = (bottomLegendArea.top - titleArea.bottom - OUTER_MARGIN * 3) / 2; |
| |
| Rect callbackHeatArea = new Rect(0, 0, graphWidth, graphHeight); |
| callbackHeatArea.offsetTo(OUTER_MARGIN, titleArea.bottom + OUTER_MARGIN); |
| |
| Rect glitchHeatArea = new Rect(0, 0, graphWidth, graphHeight); |
| glitchHeatArea.offsetTo(OUTER_MARGIN, callbackHeatArea.bottom + OUTER_MARGIN); |
| |
| final int bucketSize = |
| testDurationSeconds < MAX_DURATION_FOR_SECONDS_BUCKET ? 1 : SECONDS_PER_MINUTE; |
| |
| String units = testDurationSeconds < MAX_DURATION_FOR_SECONDS_BUCKET ? "Second" : "Minute"; |
| String glitchLabel = "Glitches Per " + units; |
| String callbackLabel = "Maximum Callback Duration(ms) Per " + units; |
| |
| // Create White background |
| canvas.drawColor(Color.WHITE); |
| |
| // Label Graph |
| canvas.drawText(title, titleArea.left + titleArea.width() / 2, titleArea.bottom, |
| titlePaint); |
| |
| // Callback Graph ///////////// |
| // label callback graph |
| Rect graphArea = new Rect(callbackHeatArea); |
| graphArea.left += LABEL_SIZE + INNER_MARGIN; |
| graphArea.bottom -= LABEL_SIZE; |
| graphArea.top += LABEL_SIZE + INNER_MARGIN; |
| canvas.drawText(callbackLabel, graphArea.left + graphArea.width() / 2, |
| graphArea.top - INNER_MARGIN, textPaint); |
| |
| int labelX = graphArea.left - INNER_MARGIN; |
| int labelY = graphArea.top + graphArea.height() / 4; |
| canvas.save(); |
| canvas.rotate(-90, labelX, labelY); |
| canvas.drawText("Recorder", labelX, labelY, textPaint); |
| canvas.restore(); |
| labelY = graphArea.bottom - graphArea.height() / 4; |
| canvas.save(); |
| canvas.rotate(-90, labelX, labelY); |
| canvas.drawText("Player", labelX, labelY, textPaint); |
| canvas.restore(); |
| |
| // draw callback heat graph |
| CallbackGraphData recorderData = |
| new CallbackGraphData(recorderCallbackTimes, bucketSize, testDurationSeconds); |
| CallbackGraphData playerData = |
| new CallbackGraphData(playerCallbackTimes, bucketSize, testDurationSeconds); |
| int maxCallbackValue = Math.max(recorderData.getMax(), playerData.getMax()); |
| |
| drawHeatMap(canvas, recorderData.getBucketedCallbacks(), maxCallbackValue, colorInter, |
| recorderCallbackTimes.isCapacityExceeded(), recorderData.getLastFilledIndex(), |
| new Rect(graphArea.left + LINE_WIDTH, graphArea.top, |
| graphArea.right - LINE_WIDTH, graphArea.centerY())); |
| drawHeatMap(canvas, playerData.getBucketedCallbacks(), maxCallbackValue, colorInter, |
| playerCallbackTimes.isCapacityExceeded(), playerData.getLastFilledIndex(), |
| new Rect(graphArea.left + LINE_WIDTH, graphArea.centerY(), |
| graphArea.right - LINE_WIDTH, graphArea.bottom)); |
| |
| drawTimeTicks(canvas, testDurationSeconds, bucketSize, callbackHeatArea.bottom, |
| graphArea.bottom, graphArea.left, graphArea.width(), textPaint, linePaint); |
| |
| // draw graph boarder |
| canvas.drawRect(graphArea, linePaint); |
| |
| // Callback Legend ////////////// |
| if (maxCallbackValue > 0) { |
| Rect legendArea = new Rect(graphArea); |
| legendArea.left = graphArea.right + OUTER_MARGIN * 2; |
| legendArea.right = legendArea.left + COLOR_LEGEND_WIDTH; |
| drawColorLegend(canvas, maxCallbackValue, colorInter, linePaint, textPaint, legendArea); |
| } |
| |
| |
| // Glitch Graph ///////////// |
| // label Glitch graph |
| graphArea.bottom = glitchHeatArea.bottom - LABEL_SIZE; |
| graphArea.top = glitchHeatArea.top + LABEL_SIZE + INNER_MARGIN; |
| canvas.drawText(glitchLabel, graphArea.left + graphArea.width() / 2, |
| graphArea.top - INNER_MARGIN, textPaint); |
| |
| // draw glitch heat graph |
| int[] bucketedGlitches = new int[(testDurationSeconds + bucketSize - 1) / bucketSize]; |
| int lastFilledGlitchBucket = bucketGlitches(glitchTimes, bucketSize * MILLIS_PER_SECOND, |
| bucketedGlitches); |
| int maxGlitchValue = 0; |
| for (int totalGlitch : bucketedGlitches) { |
| maxGlitchValue = Math.max(totalGlitch, maxGlitchValue); |
| } |
| drawHeatMap(canvas, bucketedGlitches, maxGlitchValue, colorInter, |
| glitchesExceededCapacity, lastFilledGlitchBucket, |
| new Rect(graphArea.left + LINE_WIDTH, graphArea.top, |
| graphArea.right - LINE_WIDTH, graphArea.bottom)); |
| |
| drawTimeTicks(canvas, testDurationSeconds, bucketSize, |
| graphArea.bottom + INNER_MARGIN + LABEL_SIZE, graphArea.bottom, graphArea.left, |
| graphArea.width(), textPaint, linePaint); |
| |
| // draw graph border |
| canvas.drawRect(graphArea, linePaint); |
| |
| // Callback Legend ////////////// |
| if (maxGlitchValue > 0) { |
| Rect legendArea = new Rect(graphArea); |
| legendArea.left = graphArea.right + OUTER_MARGIN * 2; |
| legendArea.right = legendArea.left + COLOR_LEGEND_WIDTH; |
| |
| drawColorLegend(canvas, maxGlitchValue, colorInter, linePaint, textPaint, legendArea); |
| } |
| |
| // Draw legend for exceeded capacity |
| if (playerCallbackTimes.isCapacityExceeded() || recorderCallbackTimes.isCapacityExceeded() |
| || glitchesExceededCapacity) { |
| RectF exceededArea = new RectF(graphArea.left, bottomLegendArea.top, |
| graphArea.left + EXCEEDED_LEGEND_WIDTH, bottomLegendArea.bottom); |
| drawExceededMarks(canvas, exceededArea); |
| canvas.drawRect(exceededArea, linePaint); |
| textPaint.setTextAlign(Paint.Align.LEFT); |
| canvas.drawText(" = No Data Available, Recording Capacity Exceeded", |
| exceededArea.right + INNER_MARGIN, bottomLegendArea.bottom, textPaint); |
| textPaint.setTextAlign(Paint.Align.CENTER); |
| } |
| |
| } |
| |
| /** |
| * Find total number of glitches duration per minute or second |
| * Returns index of last minute or second bucket with a recorded glitches |
| */ |
| private static int bucketGlitches(int[] glitchTimes, int bucketSizeMS, int[] bucketedGlitches) { |
| int bucketIndex = 0; |
| |
| for (int glitchMS : glitchTimes) { |
| bucketIndex = glitchMS / bucketSizeMS; |
| bucketedGlitches[bucketIndex]++; |
| } |
| |
| return bucketIndex; |
| } |
| |
| private static void drawHeatMap(Canvas canvas, int[] bucketedValues, int maxValue, |
| ColorInterpolator colorInter, boolean capacityExceeded, |
| int lastFilledIndex, Rect graphArea) { |
| Paint colorPaint = new Paint(Paint.ANTI_ALIAS_FLAG); |
| colorPaint.setStyle(Paint.Style.FILL); |
| float rectWidth = (float) graphArea.width() / bucketedValues.length; |
| RectF colorRect = new RectF(graphArea.left, graphArea.top, graphArea.left + rectWidth, |
| graphArea.bottom); |
| |
| // values are log scaled to a value between 0 and 1 using the following formula: |
| // (log(value + 1 ) / log(max + 1))^2 |
| // Data is typically concentrated around the extreme high and low values, This log scale |
| // allows low values to still be visible and the exponent makes the curve slightly more |
| // linear in order that the color gradients are still distinguishable |
| |
| float logMax = (float) Math.log(maxValue + 1); |
| |
| for (int i = 0; i <= lastFilledIndex; ++i) { |
| colorPaint.setColor(colorInter.getInterColor( |
| (float) Math.pow((Math.log(bucketedValues[i] + 1) / logMax), LOG_FACTOR))); |
| canvas.drawRect(colorRect, colorPaint); |
| colorRect.offset(rectWidth, 0); |
| } |
| |
| if (capacityExceeded) { |
| colorRect.right = graphArea.right; |
| drawExceededMarks(canvas, colorRect); |
| } |
| } |
| |
| private static void drawColorLegend(Canvas canvas, int maxValue, ColorInterpolator colorInter, |
| Paint linePaint, Paint textPaint, Rect legendArea) { |
| Paint colorPaint = new Paint(Paint.ANTI_ALIAS_FLAG); |
| colorPaint.setStyle(Paint.Style.STROKE); |
| colorPaint.setStrokeWidth(1); |
| textPaint.setTextAlign(Paint.Align.LEFT); |
| |
| float logMax = (float) Math.log(legendArea.height() + 1); |
| for (int y = legendArea.bottom; y >= legendArea.top; --y) { |
| float inter = (float) Math.pow( |
| (Math.log(legendArea.bottom - y + 1) / logMax), LOG_FACTOR); |
| colorPaint.setColor(colorInter.getInterColor(inter)); |
| canvas.drawLine(legendArea.left, y, legendArea.right, y, colorPaint); |
| } |
| |
| int tickSpacing = (maxValue + NUM_LEGEND_LABELS - 1) / NUM_LEGEND_LABELS; |
| for (int i = 0; i < maxValue; i += tickSpacing) { |
| float yPos = legendArea.bottom - (((float) i / maxValue) * legendArea.height()); |
| canvas.drawText(Integer.toString(i), legendArea.right + INNER_MARGIN, |
| yPos + LABEL_SIZE / 2, textPaint); |
| canvas.drawLine(legendArea.right, yPos, legendArea.right - TICK_SIZE, yPos, |
| linePaint); |
| } |
| canvas.drawText(Integer.toString(maxValue), legendArea.right + INNER_MARGIN, |
| legendArea.top + LABEL_SIZE / 2, textPaint); |
| |
| canvas.drawRect(legendArea, linePaint); |
| textPaint.setTextAlign(Paint.Align.CENTER); |
| } |
| |
| private static void drawTimeTicks(Canvas canvas, int testDurationSeconds, int bucketSizeSeconds, |
| int textYPos, int tickYPos, int startXPos, int width, |
| Paint textPaint, Paint linePaint) { |
| |
| int secondsPerTick; |
| |
| if (bucketSizeSeconds == SECONDS_PER_MINUTE) { |
| secondsPerTick = (((testDurationSeconds / SECONDS_PER_MINUTE) + NUM_X_AXIS_TICKS - 1) / |
| NUM_X_AXIS_TICKS) * SECONDS_PER_MINUTE; |
| } else { |
| secondsPerTick = (testDurationSeconds + NUM_X_AXIS_TICKS - 1) / NUM_X_AXIS_TICKS; |
| } |
| |
| for (int seconds = 0; seconds <= testDurationSeconds - secondsPerTick; |
| seconds += secondsPerTick) { |
| float xPos = startXPos + (((float) seconds / testDurationSeconds) * width); |
| |
| if (bucketSizeSeconds == SECONDS_PER_MINUTE) { |
| canvas.drawText(String.format("%dh:%02dm", seconds / SECONDS_PER_HOUR, |
| (seconds / SECONDS_PER_MINUTE) % MINUTES_PER_HOUR), |
| xPos, textYPos, textPaint); |
| } else { |
| canvas.drawText(String.format("%dm:%02ds", seconds / SECONDS_PER_MINUTE, |
| seconds % SECONDS_PER_MINUTE), |
| xPos, textYPos, textPaint); |
| } |
| |
| canvas.drawLine(xPos, tickYPos, xPos, tickYPos - TICK_SIZE, linePaint); |
| } |
| |
| //Draw total duration marking on right side of graph |
| if (bucketSizeSeconds == SECONDS_PER_MINUTE) { |
| canvas.drawText( |
| String.format("%dh:%02dm", testDurationSeconds / SECONDS_PER_HOUR, |
| (testDurationSeconds / SECONDS_PER_MINUTE) % MINUTES_PER_HOUR), |
| startXPos + width, textYPos, textPaint); |
| } else { |
| canvas.drawText( |
| String.format("%dm:%02ds", testDurationSeconds / SECONDS_PER_MINUTE, |
| testDurationSeconds % SECONDS_PER_MINUTE), |
| startXPos + width, textYPos, textPaint); |
| } |
| } |
| |
| /** |
| * Draw hash marks across a given rectangle, used to indicate no data available for that |
| * time period |
| */ |
| private static void drawExceededMarks(Canvas canvas, RectF rect) { |
| |
| final float LINE_WIDTH = 8; |
| final int STROKE_COLOR = Color.GRAY; |
| final float STROKE_OFFSET = LINE_WIDTH * 3; //space between lines |
| |
| Paint strikePaint = new Paint(Paint.ANTI_ALIAS_FLAG); |
| strikePaint.setColor(STROKE_COLOR); |
| strikePaint.setStyle(Paint.Style.STROKE); |
| strikePaint.setStrokeWidth(LINE_WIDTH); |
| |
| canvas.save(); |
| canvas.clipRect(rect); |
| |
| float startY = rect.bottom + STROKE_OFFSET; |
| float endY = rect.top - STROKE_OFFSET; |
| float startX = rect.left - rect.height(); //creates a 45 degree angle |
| float endX = rect.left; |
| |
| for (; startX < rect.right; startX += STROKE_OFFSET, endX += STROKE_OFFSET) { |
| canvas.drawLine(startX, startY, endX, endY, strikePaint); |
| } |
| |
| canvas.restore(); |
| } |
| |
| private static class CallbackGraphData { |
| |
| private int[] mBucketedCallbacks; |
| private int mLastFilledIndex; |
| |
| /** |
| * Fills buckets with maximum callback duration per minute or second |
| */ |
| CallbackGraphData(BufferCallbackTimes callbackTimes, int bucketSizeSeconds, |
| int testDurationSeconds) { |
| mBucketedCallbacks = |
| new int[(testDurationSeconds + bucketSizeSeconds - 1) / bucketSizeSeconds]; |
| int bucketSizeMS = bucketSizeSeconds * MILLIS_PER_SECOND; |
| int bucketIndex = 0; |
| for (BufferCallbackTimes.BufferCallback callback : callbackTimes) { |
| |
| bucketIndex = callback.timeStamp / bucketSizeMS; |
| if (callback.callbackDuration > mBucketedCallbacks[bucketIndex]) { |
| mBucketedCallbacks[bucketIndex] = callback.callbackDuration; |
| } |
| |
| // Original callback bucketing strategy, callbacks within a second/minute were added |
| // together in attempt to capture total amount of lateness within a time period. |
| // May become useful for debugging specific problems at some later date |
| /*if (callback.callbackDuration > callbackTimes.getExpectedBufferPeriod()) { |
| bucketedCallbacks[bucketIndex] += callback.callbackDuration; |
| }*/ |
| } |
| mLastFilledIndex = bucketIndex; |
| } |
| |
| public int getMax() { |
| int maxCallbackValue = 0; |
| for (int bucketValue : mBucketedCallbacks) { |
| maxCallbackValue = Math.max(maxCallbackValue, bucketValue); |
| } |
| return maxCallbackValue; |
| } |
| |
| public int[] getBucketedCallbacks() { |
| return mBucketedCallbacks; |
| } |
| |
| public int getLastFilledIndex() { |
| return mLastFilledIndex; |
| } |
| } |
| |
| private static class ColorInterpolator { |
| |
| private final int mAlphaStart; |
| private final int mAlphaRange; |
| private final int mRedStart; |
| private final int mRedRange; |
| private final int mGreenStart; |
| private final int mGreenRange; |
| private final int mBlueStart; |
| private final int mBlueRange; |
| |
| public ColorInterpolator(int startColor, int endColor) { |
| mAlphaStart = Color.alpha(startColor); |
| mAlphaRange = Color.alpha(endColor) - mAlphaStart; |
| |
| mRedStart = Color.red(startColor); |
| mRedRange = Color.red(endColor) - mRedStart; |
| |
| mGreenStart = Color.green(startColor); |
| mGreenRange = Color.green(endColor) - mGreenStart; |
| |
| mBlueStart = Color.blue(startColor); |
| mBlueRange = Color.blue(endColor) - mBlueStart; |
| } |
| |
| /** |
| * Takes a float between 0 and 1 and returns a color int between mStartColor and mEndColor |
| **/ |
| public int getInterColor(float input) { |
| |
| return Color.argb( |
| mAlphaStart + (int) (input * mAlphaRange), |
| mRedStart + (int) (input * mRedRange), |
| mGreenStart + (int) (input * mGreenRange), |
| mBlueStart + (int) (input * mBlueRange) |
| ); |
| } |
| } |
| } |