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

Java tutorial

Introduction

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

Source

package com.wanikani.androidnotifier.graph;

import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.Hashtable;
import java.util.List;
import java.util.Locale;
import java.util.Map;

import android.content.Context;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.BitmapShader;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.DashPathEffect;
import android.graphics.Paint;
import android.graphics.Paint.FontMetrics;
import android.graphics.Path;
import android.graphics.RectF;
import android.graphics.Shader;
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;
import com.wanikani.androidnotifier.graph.Pager.DataSet;
import com.wanikani.wklib.UserInformation;

/* 
 *  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/>.
 */

/**
 * A time-value chart. This view represents just the diagram, while {@link TYChart}
 * shows some other useful widgets too. Though it is possible to insert this view
 * into a layout, it is advisable to use the chart, because otherwise e.g. data
 * retrieval won't cause the spinner to show up.
 */
public class TYPlot extends View {

    /**
     * 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(TYPlot.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(TYPlot.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.dayToAbsPosition(taxis.today) + 2000000, 0,
                    0);
            ViewCompat.postInvalidateOnAnimation(TYPlot.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 day
        public float DEFAULT_DIP_PER_DAY = 8;

        /// Default number of days after today that are displayed at startup
        public int DEFAULT_LOOKAHEAD = 7;

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

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

        /// Default height of a day tick
        public int DEFAULT_TICK_SIZE = 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 day
        public float dipPerDay;

        /// Actual number of days after today that are displayed at startup
        public int lookAhead;

        /// Actual label font size
        public float axisWidth;

        /// Actual height of a day tick
        public float dateLabelFontSize;

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

        /// 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;

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

            margin = DEFAULT_MARGIN;
            dipPerDay = DEFAULT_DIP_PER_DAY;
            lookAhead = DEFAULT_LOOKAHEAD;
            axisWidth = DEFAULT_AXIS_WIDTH;
            dateLabelFontSize = DEFAULT_DATE_LABEL_FONT_SIZE;
            tickSize = DEFAULT_TICK_SIZE;
            yaxisGrid = DEFAULT_YAXIS_GRID;

            dateLabelFontSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, dateLabelFontSize, dm);
            updateSize(new RectF());
        }

        /**
         * 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 = Math.max(DEFAULT_MARGIN, mm + tickSize);
            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 the labels
        Paint dateLabels;

        /// Paint used to draw the area where samples are not available
        Paint partial;

        /// Paint used to draw levelups
        Paint levelup;

        /// Series to paint map
        Map<Pager.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;
            Bitmap bmp;
            Shader shader;
            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));

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

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

            bmp = BitmapFactory.decodeResource(res, R.drawable.partial);

            partial = new Paint(Paint.FILTER_BITMAP_FLAG);
            shader = new BitmapShader(bmp, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT);
            partial.setShader(shader);

            levelup = new Paint();
            levelup.setTextAlign(Paint.Align.CENTER);
            levelup.setTextSize((int) meas.dateLabelFontSize);
            levelup.setAntiAlias(true);

            series = new Hashtable<Pager.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<Pager.Series> series) {
            Paint p;

            this.series.clear();
            for (Pager.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 connector to the datasource. Needed to refresh when the viewport moves
        DataSink dsink;

        /// The measure object
        Measures meas;

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

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

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

        /// Y scale
        float yScale;

        /// Number of days since subscription
        int today;

        /**
         * Constructor
         * @param dsink the datasink 
         * @param meas the measure object
         * @param today number of days since subscription
         */
        public Viewport(DataSink dsink, Measures meas, int today) {
            this.dsink = dsink;
            this.meas = meas;
            this.today = today;

            t1 = today + meas.lookAhead;

            /* Will be updated as soon as we have a valid datasource */
            updateSize(100);
        }

        /**
         * Changes the current day. Must be called when changing the datasource
         * @param today the new origin
         */
        public void setToday(int today) {
            if (today == this.today)
                return;

            t1 += today - this.today;
            t0 = t1 - interval;

            this.today = today;
            adjust();
            dsink.refresh();
        }

        /**
         * Called when the plot area changes
         * @param yMax the Y max
         */
        public void updateSize(float yMax) {
            interval = meas.plotArea.width() / meas.dipPerDay;
            yScale = meas.plotArea.height() / yMax;
            if (interval < meas.lookAhead)
                interval = meas.lookAhead;
            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 > today)
                t0 = today;

            t1 = t0 + interval;
        }

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

        /**
         * Moves the viewport, putting its left margin at a number of pixels to
         * the right of day zero
         * @param pos the new position
         */
        public void setAbsPosition(int pos) {
            t0 = absPositionToDay(pos);
            t1 = t0 + interval;
            adjust();
            dsink.refresh();
        }

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

        /**
         * 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 day a day
         * @return the number of pixels
         */
        public int dayToAbsPosition(float day) {
            return (int) (day * meas.dipPerDay);
        }

        /**
         * Returns the day, given the number of pixels from subscription day
         * @param pos number of pixels
         * @return the day number
         */
        public float absPositionToDay(int pos) {
            return ((float) pos) / meas.dipPerDay;
        }

        /**
         * 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 day represented in this viewport.
         * This differs from {@link #t1} because it is an integer
         * @return the day
         */
        public int rightmostDay() {
            return floor(t1);
        }

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

        /**
         * Returns the current displayed interval
         * @return the interval
         */
        public Pager.Interval getInterval() {
            return new Pager.Interval(floor(t0), ceil(t1));
        }

        /**
         * Tells whether a subset of this interval is visible in the viewport 
         * @param i an interval
         * @return <tt>true</tt> if it is visible
         */
        public boolean visible(Pager.Interval i) {
            return i.start < today && i.stop > t0;
        }
    }

    /**
     * The time axis state
     */
    private static class TimeAxis {

        /// The date mapped as "day zero"
        public Date origin;

        /// Now as a date
        public Date now;

        /// Now as a day
        public int today;

        /**
         * Constructor
         */
        public TimeAxis() {
            now = new Date();
            setOrigin(new Date(0));
        }

        /**
         * Moves the origin to a given date
         * @param date the date
         */
        public void setOrigin(Date date) {
            origin = date;
            today = UserInformation.getDay(origin, now);
        }

        /**
         * Converts a day into the calendar
         * @param day a day
         * @return the calendar position
         */
        public Calendar dayToCalendar(int day) {
            Calendar cal;

            cal = Calendar.getInstance();
            cal.setTime(origin);
            cal.add(Calendar.DATE, day);

            return cal;
        }
    }

    /**
     * An implementation of the pager datasink. This is the object that
     * requests data from the data source, and updates the plot when
     * it receives the samples.
     */
    private class DataSink implements Pager.DataSink {

        /// The current dataset
        DataSet ds;

        /**
         * Called when the plot needs to be refreshed. Note that we always
         * request data because caching is done at a lower layer.
         */
        public void refresh() {
            if (pager != null && gotOrigin) {
                refreshing(true);
                pager.requestData(vp.getInterval());
            } else
                invalidate();
        }

        /**
         * Called when data is available. Refreshes the plot area
         * @param ds the samples
         */
        public void dataAvailable(DataSet ds) {
            if (ds.interval.equals(vp.getInterval())) {
                this.ds = ds;
                refreshing(false);
                invalidate();
            }
        }
    }

    /// 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;

    /// Date format to display time labels
    private DateFormat datef;

    /// Date format to display january (we also print the year)
    private DateFormat janf;

    /// The time axis
    private TimeAxis taxis;

    /// The pager, if a datasource is available, <tt>null</tt> otherwise
    private Pager pager;

    /// Our datasink
    private DataSink dsink;

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

    private boolean strictScroll;

    /// A reference to the parent chart, if any
    private TYChart chart;

    /// <tt>true</tt> if we know where we are
    boolean gotOrigin;

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

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

        datef = new SimpleDateFormat("MMM", Locale.US);
        janf = new SimpleDateFormat("MMM yyyy", Locale.US);
        taxis = new TimeAxis();
        dsink = new DataSink();

        loadAttributes(ctxt, attrs);
    }

    /**
     * Called by the parent chart, if any. Calling this method enables some
     * extra features. 
     * @param chart the chart
     */
    void setTYChart(TYChart chart) {
        this.chart = chart;
    }

    /**
     * Constructs the objects that use attributes.
     * @param ctxt the context
     * @param attrs the attributes
     */
    void loadAttributes(Context ctxt, AttributeSet attrs) {
        meas = new Measures(ctxt, attrs);
        vp = new Viewport(dsink, meas, taxis.today);
        pas = new PaintAssets(getResources(), attrs, meas);
    }

    /**
     * Sets the time origin. Until this call is made, no data is ever shown,
     * so it must be the first call after 
     * {@link #setDataSource(com.wanikani.androidnotifier.graph.Pager.DataSource)}
     * @param date the origin
     */
    public void setOrigin(Date date) {
        gotOrigin = true;
        taxis.setOrigin(date);
        vp.setToday(taxis.today);
    }

    /**
     * Sets the datasource. After this, callers should use {@link #setOrigin(Date)}
     * @param dsource the new datasource
     */
    public void setDataSource(Pager.DataSource dsource) {
        gotOrigin = false;
        pager = new Pager(dsource, dsink);
        pas.setSeries(dsource.getSeries());
        vp.updateSize(dsource.getMaxY());
    }

    @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(pager != null ? pager.dsource.getMaxY() : 100);
        dsink.refresh();
    }

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

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

    @Override
    protected void onDraw(Canvas canvas) {
        boolean partial;

        if (dsink != null && dsink.ds != null) {
            partial = drawPlot(canvas, dsink.ds);
            if (chart != null)
                chart.partialShown(partial);
        }

        drawGrid(canvas);
    }

    /**
     * Draws the grinds on the canvas. Since they are "over" the plot, this
     * method should be called last
     * @param canvas the canvas
     */
    protected void drawGrid(Canvas canvas) {
        float f, dateLabelBaseline, levelupBaseline;
        Map<Integer, Pager.Marker> markers;
        Pager.Marker marker;
        int d, lo, hi, ascent;
        DateFormat df;
        String s;
        Calendar cal;

        canvas.drawLine(meas.plotArea.left, meas.plotArea.bottom, meas.plotArea.right, meas.plotArea.bottom,
                pas.axisPaint);
        f = vp.getRelPosition(0);
        lo = vp.leftmostDay();
        hi = vp.rightmostDay();
        cal = taxis.dayToCalendar(lo);

        markers = pager.dsource.getMarkers();

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

        dateLabelBaseline = meas.plotArea.bottom - ascent + meas.tickSize / 2;
        levelupBaseline = meas.plotArea.top - meas.tickSize / 2;

        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);

        for (d = lo; d <= hi; d++) {
            f = vp.getRelPosition(d);

            if (d == 0 || d == taxis.today)
                canvas.drawLine(f, meas.plotArea.top, f, meas.plotArea.bottom, pas.axisPaint);
            else if (cal.get(Calendar.DAY_OF_WEEK) == Calendar.MONDAY)
                canvas.drawLine(f, meas.plotArea.top, f, meas.plotArea.bottom, pas.gridPaint);

            if (cal.get(Calendar.DAY_OF_MONTH) == 1) {
                df = cal.get(Calendar.MONTH) == Calendar.JANUARY ? janf : datef;
                s = df.format(cal.getTime());
                canvas.drawLine(f, meas.plotArea.bottom - meas.tickSize / 2, f,
                        meas.plotArea.bottom + meas.tickSize / 2, pas.axisPaint);
                canvas.drawText(s, f, dateLabelBaseline, pas.dateLabels);
            }

            marker = markers.get(d);
            if (marker != null) {
                pas.levelup.setColor(marker.color);
                canvas.drawLine(f, meas.plotArea.top, f, meas.plotArea.bottom, pas.levelup);
                canvas.drawText(marker.name, f, levelupBaseline, pas.levelup);
            }

            cal.add(Calendar.DATE, 1);
        }
    }

    /**
     * Draws the plot
     * @param canvas the canvas
     * @param ds the samples
     * @return <tt>true</tt> if some partial data is present
     */
    protected boolean drawPlot(Canvas canvas, Pager.DataSet ds) {
        boolean ans;

        ans = false;

        for (Pager.Segment segment : ds.segments)
            ans |= drawSegment(canvas, segment);

        return ans;
    }

    /**
     * Draws a segment of the plot.
     * @param canvas the canvas
     * @param segment the segment
     * @return <tt>true</tt> if this is a partial segment (i.e. its type
     * is {@link Pager.SegmentType#MISSING}
     */
    protected boolean drawSegment(Canvas canvas, Pager.Segment segment) {
        float f[];
        int i;

        switch (segment.type) {
        case MISSING:
            return drawMissing(canvas, segment.interval);

        case VALID:
            f = new float[segment.interval.getSize()];
            for (i = 0; i < segment.data.length; i++)
                drawPlot(canvas, segment.series.get(i), segment.interval, f, segment.data[i]);

            break;
        }

        return false;
    }

    /**
     * Draws a segment without data
     * @param canvas the canvas
     * @param i the interval size
     * @return <tt>true</tt> unless this part entirely outside the viewport
     */
    protected boolean drawMissing(Canvas canvas, Pager.Interval i) {
        int from, to;

        if (!vp.visible(i))
            return false;

        from = i.start;
        to = Math.min(i.stop + 1, vp.today);

        canvas.drawRect(vp.getRelPosition(from), meas.plotArea.top, vp.getRelPosition(to), meas.plotArea.bottom,
                pas.partial);

        return true;
    }

    /**
     * Draws a segment containins samples
     * @param canvas the canvas
     * @param series the series
     * @param interval the interval
     * @param base a float array initially set to zero, and updated by this method
     * @param samples the samples 
     */
    protected void drawPlot(Canvas canvas, Pager.Series series, Pager.Interval interval, float base[],
            float samples[]) {
        Path path;
        Paint p;
        int i, n;

        p = pas.series.get(series);
        n = interval.stop - interval.start + 1;
        if (p == null || samples.length == 0 || n <= 0)
            return;

        path = new Path();

        path.moveTo(vp.getRelPosition(interval.start + n), vp.getY(base[n - 1]));

        for (i = n - 1; i >= 0; i--) {
            path.lineTo(vp.getRelPosition(interval.start + i), vp.getY(base[i]));
            base[i] += samples[i];
        }

        for (i = 0; i < n; i++)
            path.lineTo(vp.getRelPosition(interval.start + i), vp.getY(base[i]));

        path.lineTo(vp.getRelPosition(interval.start + n), vp.getY(base[n - 1]));

        path.close();

        canvas.drawPath(path, p);
    }

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

    public void refreshing(boolean enable) {
        if (chart != null) {
            if (enable)
                chart.startRefresh();
            else
                chart.dataAvailable();
        }
    }

    /**
     * Starts data reconstruction
     */
    public void fillPartial() {
        if (pager != null)
            pager.fillPartial();
    }

    /**
     * Called when the samples have been changed. Updates the Y scale and 
     * requests fresh data to the data source.
     */
    public void refresh() {
        /* The size may have changed */
        if (pager != null)
            vp.updateSize(pager.dsource.getMaxY());

        dsink.refresh();
    }
}