diff options
Diffstat (limited to 'LoopbackApp/app/src/main/java/org/drrickorang/loopback/GlitchAndCallbackHeatMapView.java')
-rw-r--r-- | LoopbackApp/app/src/main/java/org/drrickorang/loopback/GlitchAndCallbackHeatMapView.java | 498 |
1 files changed, 498 insertions, 0 deletions
diff --git a/LoopbackApp/app/src/main/java/org/drrickorang/loopback/GlitchAndCallbackHeatMapView.java b/LoopbackApp/app/src/main/java/org/drrickorang/loopback/GlitchAndCallbackHeatMapView.java new file mode 100644 index 0000000..de24e81 --- /dev/null +++ b/LoopbackApp/app/src/main/java/org/drrickorang/loopback/GlitchAndCallbackHeatMapView.java @@ -0,0 +1,498 @@ +/* + * 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) + ); + } + } +} |