com.wanikani.androidnotifier.graph.HistogramPlot.java Source code

Java tutorial

Introduction

Here is the source code for com.wanikani.androidnotifier.graph.HistogramPlot.java

Source

package com.wanikani.androidnotifier.graph;

import java.util.Hashtable;
import java.util.List;
import java.util.Map;
import java.util.Vector;

import android.content.Context;
import android.content.res.Resources;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.DashPathEffect;
import android.graphics.Paint;
import android.graphics.Paint.FontMetrics;
import android.graphics.Paint.Style;
import android.graphics.Path;
import android.graphics.RectF;
import android.support.v4.view.ViewCompat;
import android.util.AttributeSet;
import android.util.DisplayMetrics;
import android.util.TypedValue;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.View;
import android.widget.Scroller;

import com.wanikani.androidnotifier.R;

/* 
 *  Copyright (c) 2013 Alberto Cuda
 *
 *  This program is free software: you can redistribute it and/or modify
 *  it under the terms of the GNU General Public License as published by
 *  the Free Software Foundation, either version 3 of the License, or
 *  (at your option) any later version.
 *
 *  This program is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *  GNU General Public License for more details.
 *
 *  You should have received a copy of the GNU General Public License
 *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

public class HistogramPlot extends View {

    public static class Series {

        public String name;

        public int color;

        public Series(String name, int color) {
            this.name = name;
            this.color = color;
        }

        public Series(int color) {
            this.color = color;
        }

        public Series() {
            /* empty */
        }

        public void set(String name, int color) {
            this.name = name;
            this.color = color;
        }
    }

    public static class Sample {

        public Series series;

        public long value;

        public Sample(Series series, long value) {
            this.series = series;
            this.value = value;
        }

        public Sample(Series series) {
            this.series = series;
        }

        public Sample() {
            /* empty */
        }
    }

    public static class Samples {

        public String tag;

        public List<Sample> samples;

        public Samples(String tag) {
            this.tag = tag;
            samples = new Vector<Sample>();
        }

        public long getTotal() {
            long ans;

            ans = 0;
            for (Sample s : samples)
                ans += s.value;

            return ans;
        }

        public boolean isEmpty() {
            return getTotal() == 0;
        }

    }

    /**
     * The listener that intercepts motion and fling gestures.
     */
    private class GestureListener extends GestureDetector.SimpleOnGestureListener {

        @Override
        public boolean onDown(MotionEvent mev) {
            scroller.forceFinished(true);
            ViewCompat.postInvalidateOnAnimation(HistogramPlot.this);

            return true;
        }

        @Override
        public boolean onScroll(MotionEvent mev1, MotionEvent mev2, float dx, float dy) {
            strictScroll |= dx != 0;
            vp.scroll((int) dx, (int) dy);
            ViewCompat.postInvalidateOnAnimation(HistogramPlot.this);

            return true;
        }

        @Override
        public boolean onFling(MotionEvent mev1, MotionEvent mev2, float vx, float vy) {
            strictScroll |= vx != 0;
            scroller.forceFinished(true);
            scroller.fling(vp.getAbsPosition(), 0, (int) -vx, 0, 0, vp.barToAbsPosition(vp.bars) + 2000000, 0, 0);
            ViewCompat.postInvalidateOnAnimation(HistogramPlot.this);

            return true;
        }
    }

    /**
     * A repository of all the sizes and measures. Currently no variables
     * can be customized, however I've kept them separated from their default
     * values, so allowing layouts to override these default is just a matter of adding
     * an attributes parser.
     */
    private static class Measures {

        /// Default margin around the diagram
        public float DEFAULT_MARGIN = 24;

        /// Default number of pixels per bar
        public float DEFAULT_DIP_PER_BAR = 20;

        /// Default gap between bars
        public float DEFAULT_GAP = 8;

        /// Default label font size
        public float DEFAULT_DATE_LABEL_FONT_SIZE = 12;

        /// Default axis width
        public int DEFAULT_AXIS_WIDTH = 2;

        /// Default space between axis and label
        public int DEFAULT_HEADROOM = 10;

        /// Default number of items represented by a vertical mark
        public int DEFAULT_YAXIS_GRID = 100;

        /// The plot area
        public RectF plotArea;

        /// The complete view area
        public RectF viewArea;

        /// Actual margin around the diagram
        public float margin;

        /// Actual number of pixels per bar
        public float dipPerBar;

        /// Actual gap between bars
        public float gap;

        /// Actual label font size
        public float axisWidth;

        /// Actual space between axis and label
        public int headroom = 10;

        /// Actual font siz
        public float dateLabelFontSize;

        /// Actual number of items represented by a vertical mark
        public int yaxisGrid;

        /**
         * Constructor
         * @param ctxt the context 
         * @param attrs attributes of the plot. Currently ignored
         */
        public Measures(Context ctxt, AttributeSet attrs) {
            DisplayMetrics dm;
            TypedArray a;

            a = ctxt.obtainStyledAttributes(attrs, R.styleable.HistogramPlot);

            dm = ctxt.getResources().getDisplayMetrics();

            margin = DEFAULT_MARGIN;
            dipPerBar = DEFAULT_DIP_PER_BAR;
            gap = DEFAULT_GAP;
            axisWidth = DEFAULT_AXIS_WIDTH;
            headroom = DEFAULT_HEADROOM;
            dateLabelFontSize = DEFAULT_DATE_LABEL_FONT_SIZE;
            yaxisGrid = a.getInteger(R.styleable.HistogramPlot_ticks, DEFAULT_YAXIS_GRID);

            dateLabelFontSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, dateLabelFontSize, dm);

            a.recycle();

            updateSize(new RectF());
        }

        public void updateLabelPaint(Paint paint) {
            dipPerBar = paint.measureText(" 999 ");
        }

        /**
         * Called when the plot changes it size. Updates the inner plot rect
         * @param rect the new plot size
         */
        public void updateSize(RectF rect) {
            viewArea = new RectF(rect);
            plotArea = new RectF(rect);

            plotArea.top += margin;
            plotArea.bottom -= margin;
        }

        /**
         * Makes sure that the margin is large enough to display the time axis labels.
         * @param mm minimum margin
         */
        public void ensureFontMargin(long mm) {
            margin = mm + headroom;
            updateSize(viewArea);
        }

    }

    /**
     * A collection of all the paint objects that will be needed when drawing
     * on the canvas. We create them beforehand and recycle them for performance
     * reasons.
     */
    private static class PaintAssets {

        /// Paint used to draw the axis
        Paint axisPaint;

        /// Paint used to draw the grids            
        Paint gridPaint;

        /// Paint used to draw labels
        Paint labelPaint;

        /// Paint used to draw total amount
        Paint levelupPaint;

        /// Paint used to draw total amount, when drawn inside the bar
        Paint levelupPaintInside;

        /// Series to paint map
        Map<Series, Paint> series;

        /**
         * Constructor. Creates all the paints, using the chart attributes and
         * measures
         * @param res the resources
         * @param attrs the chart attributes
         * @param meas measures object
         */
        public PaintAssets(Resources res, AttributeSet attrs, Measures meas) {
            FontMetrics fm;
            float points[];

            axisPaint = new Paint();
            axisPaint.setColor(Color.BLACK);
            axisPaint.setStrokeWidth(meas.axisWidth);

            points = new float[] { 1, 1 };
            gridPaint = new Paint();
            gridPaint.setColor(Color.BLACK);
            gridPaint.setPathEffect(new DashPathEffect(points, 0));

            levelupPaint = new Paint();
            levelupPaint.setColor(res.getColor(R.color.levelup));
            levelupPaint.setTextAlign(Paint.Align.CENTER);
            levelupPaint.setTextSize((int) meas.dateLabelFontSize);
            levelupPaint.setAntiAlias(true);

            levelupPaintInside = new Paint();
            levelupPaintInside.setColor(Color.WHITE);
            levelupPaintInside.setTextAlign(Paint.Align.CENTER);
            levelupPaintInside.setTextSize((int) meas.dateLabelFontSize);
            levelupPaintInside.setAntiAlias(true);

            labelPaint = new Paint();
            labelPaint.setColor(Color.BLACK);
            labelPaint.setTextAlign(Paint.Align.CENTER);
            labelPaint.setTextSize((int) meas.dateLabelFontSize);
            labelPaint.setAntiAlias(true);
            meas.updateLabelPaint(labelPaint);

            fm = labelPaint.getFontMetrics();
            meas.ensureFontMargin((int) (fm.bottom - fm.ascent));

            series = new Hashtable<Series, Paint>();
        }

        /**
         * Called when the series set changes. Recreates the mapping between
         * series and paint objects
         * @param series the new series
         */
        public void setSeries(List<Series> series) {
            Paint p;

            this.series.clear();
            for (Series s : series) {
                p = new Paint();
                p.setColor(s.color);
                p.setStyle(Paint.Style.FILL_AND_STROKE);
                p.setAntiAlias(true);
                this.series.put(s, p);
            }
        }
    }

    /**
     * This object tracks the position of the interval of the plot which is
     * currently visible.
     */
    private static class Viewport {

        /// The measure object
        Measures meas;

        /// The first (leftmost) tag 
        float t0;

        /// The last (rightmost) tag
        float t1;

        /// Size of interval (<tt>t1-t0</tt>) 
        float interval;

        /// Max value of Y axis
        long yMax;

        /// Y scale
        float yScale;

        /// Number of bars
        int bars;

        /**
         * Constructor
         * @param meas the measure object
         * @param bars the number of bars
         * @param yMax max value of Y axis
         */
        public Viewport(Measures meas, int bars, long yMax) {
            this.meas = meas;
            this.bars = bars;
            this.yMax = yMax;

            t1 = bars;

            updateSize();
        }

        /**
         * Called when the plot area changes
         */
        public void updateSize() {
            boolean firstRun;

            firstRun = interval == 0;

            interval = meas.plotArea.width() / (meas.dipPerBar + meas.gap);
            yScale = meas.plotArea.height() / yMax;
            if (!firstRun)
                t0 = t1 - interval;

            adjust();
        }

        /**
         * Updates the lower and upper edges after the viewport is resized 
         */
        private void adjust() {
            if (t0 < 0)
                t0 = 0;
            else if (t0 > bars - interval)
                t0 = bars - interval;

            t1 = t0 + interval;
        }

        /**
         * Returns the number of pixels between the left margin of the viewport
         * and the first bar. Of course these pixels are not displayed because they
         * are outside the viewport.
         * @return the number of pixels
         */
        public int getAbsPosition() {
            return barToAbsPosition(t0);
        }

        /**
         * Moves the viewport, putting its left margin at a number of pixels to
         * the right of first bar
         * @param pos the new position
         */
        public void setAbsPosition(int pos) {
            setBar(absPositionToBar(pos));
        }

        /**
         * Moves the viewport, putting its left margin on the left of a given bar
         * @param bar the bar
         */
        public void setBar(float bar) {
            t0 = bar;
            t1 = t0 + interval;
            adjust();
        }

        /**
         * Returns the number of pixels between the leftmost bar and a given bar
         * @param bar a bar
         * @return the number of pixels
         */
        public int getRelPosition(int bar) {
            return (int) ((bar - t0) * (meas.dipPerBar + meas.gap));
        }

        /**
         * Converts item numbers to pixel
         * @param y item numbers
         * @return the number of pixel
         */
        public float getY(float y) {
            return meas.plotArea.bottom - y * yScale;
        }

        /**
         * Scrolls the viewport by a given interval
         * @param dx the horizontal interval
         * @param dy the vertical interval (ignored)
         */
        public void scroll(int dx, int dy) {
            setAbsPosition(getAbsPosition() + dx);
        }

        /**
         * Returns the number of pixels between a given day and the
         * day of subscription.
         * @param bar a bar
         * @return the number of pixels
         */
        public int barToAbsPosition(float bar) {
            return (int) (bar * (meas.dipPerBar + meas.gap));
        }

        /**
         * Returns the day, given the number of pixels from subscription day
         * @param pos number of pixels
         * @return the bar number
         */
        public float absPositionToBar(int pos) {
            return ((float) pos) / (meas.dipPerBar + meas.gap);
        }

        /**
         * A floor operation that always points to -inf.
         * @param d a number 
         * @return the floor
         */
        private int floor(float d) {
            return (int) (d > 0 ? Math.floor(d) : Math.ceil(d));
        }

        /**
         * A ceil operation that always points to +inf.
         * @param d a number 
         * @return the ceil
         */
        private int ceil(float d) {
            return (int) (d > 0 ? Math.ceil(d) : Math.floor(d));
        }

        /**
         * Returns the rightmost complete bar represented in this viewport.
         * This differs from {@link #t1} because it is an integer
         * @return the bar
         */
        public int rightmostBar() {
            return floor(t1);
        }

        /**
         * Returns the leftmost complete bar represented in this viewport.
         * This differs from {@link #t0} because it is an integer
         * @return the bar
         */
        public int leftmostBar() {
            return ceil(t0);
        }

    }

    /// The scroller object that tracks fling gestures
    private Scroller scroller;

    /// The android gesture detector
    private GestureDetector gdect;

    /// Our gesture listener
    private GestureListener glist;

    /// The measure object
    private Measures meas;

    /// The current viewport
    private Viewport vp;

    /// The paint objects
    private PaintAssets pas;

    /// <tt>true</tt> during fling gestures
    private boolean scrolling;

    private boolean strictScroll;

    private boolean drawTotal;

    /// The actual data
    private List<Samples> bars;

    /**
     * Constructor
     * @param ctxt the context
     * @param attrs the attributes
     */
    public HistogramPlot(Context ctxt, AttributeSet attrs) {
        super(ctxt, attrs);

        scroller = new Scroller(ctxt);
        glist = new GestureListener();
        gdect = new GestureDetector(ctxt, glist);

        loadAttributes(ctxt, attrs);
    }

    /**
     * Constructs the objects that use attributes.
     * @param ctxt the context
     * @param attrs the attributes
     */
    void loadAttributes(Context ctxt, AttributeSet attrs) {
        TypedArray a;

        meas = new Measures(ctxt, attrs);
        pas = new PaintAssets(getResources(), attrs, meas);

        a = ctxt.obtainStyledAttributes(attrs, R.styleable.HistogramPlot);
        drawTotal = a.getBoolean(R.styleable.HistogramPlot_drawTotal, true);
        a.recycle();
    }

    /**
     * Sets the data samples.
     * @param series a list of series that will be referenced by <tt>data</tt> 
     * @param bars a list of samples, each representing a bar
     * @param cap maximum Y value admitted (may be smaller if bars are smaller than that)
     */
    public void setData(List<Series> series, List<Samples> bars, long cap) {
        setData(series, bars, cap, -1);
    }

    /**
     * Sets the data samples.
     * @param series a list of series that will be referenced by <tt>data</tt> 
     * @param bars a list of samples, each representing a bar
     * @param cap maximum Y value admitted (may be smaller if bars are smaller than that)
     * @param bar which bar to align to (set it to a negative value not to align it)
     */
    public void setData(List<Series> series, List<Samples> bars, long cap, int bar) {
        pas.setSeries(series);
        vp = new Viewport(meas, bars.size(), getMaxY(bars, cap));
        if (bar >= 0)
            vp.setBar(bar);
        this.bars = bars;

        invalidate();
    }

    static private long getMaxY(List<Samples> bars, long cap) {
        long ans, current, rmax;

        ans = rmax = 0;
        for (Samples bar : bars) {
            current = 0;
            for (Sample s : bar.samples)
                current += s.value;
            if (cap <= 0 || current < cap)
                ans = Math.max(ans, current);
            rmax = Math.max(rmax, current);
        }

        return ans != 0 ? ans : rmax;
    }

    @Override
    public boolean onTouchEvent(MotionEvent mev) {
        boolean ans;

        switch (mev.getAction()) {
        case MotionEvent.ACTION_DOWN:
            scrolling = true;
            strictScroll = false;
            break;

        case MotionEvent.ACTION_UP:
        case MotionEvent.ACTION_CANCEL:
            scrolling = false;
            break;
        }

        ans = gdect.onTouchEvent(mev);

        return ans || super.onTouchEvent(mev);
    }

    @Override
    protected void onSizeChanged(int width, int height, int ow, int oh) {
        meas.updateSize(new RectF(0, 0, width, height));
        vp.updateSize();

        invalidate();
    }

    @Override
    public void computeScroll() {
        super.computeScroll();

        if (scroller.computeScrollOffset()) {
            vp.setAbsPosition(scroller.getCurrX());
            ViewCompat.postInvalidateOnAnimation(this);
        }
    }

    @Override
    protected void onDraw(Canvas canvas) {
        float left, right, tagLabelBaseline;
        int d, lo, hi, ascent;
        Samples bar;

        canvas.drawLine(meas.plotArea.left, meas.plotArea.bottom, meas.plotArea.right, meas.plotArea.bottom,
                pas.axisPaint);
        lo = vp.leftmostBar() - 1; /* We want broken bars too :) */
        hi = vp.rightmostBar() + 1;

        ascent = (int) pas.labelPaint.getFontMetrics().ascent;

        tagLabelBaseline = meas.plotArea.bottom - ascent + meas.headroom / 2;

        for (d = lo; d <= hi; d++) {

            if (d < 0)
                continue;

            if (d >= bars.size())
                break;

            left = vp.getRelPosition(d);
            right = left + vp.meas.dipPerBar;
            bar = bars.get(d);

            drawBar(canvas, bar, left, right);

            canvas.drawText(bar.tag, (left + right) / 2, tagLabelBaseline, pas.labelPaint);
        }

        if (meas.yaxisGrid > 0) {
            for (d = meas.yaxisGrid; vp.getY(d) >= meas.plotArea.top; d += meas.yaxisGrid) {
                canvas.drawLine(meas.plotArea.left, vp.getY(d), meas.plotArea.right, vp.getY(d), pas.gridPaint);
            }
        }
    }

    protected void drawBar(Canvas canvas, Samples bar, float left, float right) {
        long base, height;
        float top, tbl;
        Paint lpaint;
        Paint paint;
        Path path;
        RectF rect;

        top = vp.getY(vp.yMax);
        base = 0;
        for (Sample sample : bar.samples) {
            if (sample.value > 0) {
                height = sample.value;

                if (base > vp.yMax)
                    ;
                else if (base + height > vp.yMax) {
                    path = new Path();
                    path.moveTo(left, vp.getY(base));
                    path.lineTo(left, top);
                    path.lineTo(left + (right - left) / 3, top - 10);
                    path.lineTo(left + (right - left) * 2 / 3, top + 5);
                    path.lineTo(right, top);
                    path.lineTo(right, vp.getY(base));
                    path.close();
                    canvas.drawPath(path, pas.series.get(sample.series));
                } else {
                    rect = new RectF(left, vp.getY(base + height), right, vp.getY(base));
                    rect.intersect(meas.plotArea);
                    paint = pas.series.get(sample.series);
                    paint.setStyle(Style.FILL);
                    canvas.drawRect(rect, paint);
                    paint.setStyle(Style.STROKE);
                    canvas.drawRect(rect, paint);
                }
                base += height;
            }
        }

        if (base <= vp.yMax) {
            lpaint = pas.levelupPaint;
            tbl = vp.getY(base) - meas.headroom / 2;
        } else {
            lpaint = pas.levelupPaintInside;
            tbl = vp.getY(vp.yMax) + meas.margin;
        }

        if (base > 0 && drawTotal)
            canvas.drawText(Long.toString(base), (left + right) / 2, tbl, lpaint);
    }

    /**
     * True if scrolling 
     * @return <tt>true</tt> if scrolling
     */
    public boolean scrolling(boolean strict) {
        return scrolling && (!strict || strictScroll);
    }
}