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
17package org.drrickorang.loopback;
18
19import android.content.Context;
20import android.graphics.Bitmap;
21import android.graphics.Canvas;
22import android.graphics.Color;
23import android.graphics.Paint;
24import android.graphics.Rect;
25import android.graphics.RectF;
26import android.view.View;
27import 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 */
34public 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
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
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     */
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