/**************************************************************************************** * Copyright (c) 2014 Michael Goldbach <michael@m-goldbach.net> * * * * 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/>. * ****************************************************************************************/ package com.ichi2.anki.stats; import android.graphics.Paint; import com.ichi2.anki.R; import com.ichi2.libanki.Collection; import com.ichi2.libanki.Stats; import com.ichi2.themes.Themes; import com.wildplot.android.rendering.BarGraph; import com.wildplot.android.rendering.LegendDrawable; import com.wildplot.android.rendering.Lines; import com.wildplot.android.rendering.PieChart; import com.wildplot.android.rendering.PlotSheet; import com.wildplot.android.rendering.XAxis; import com.wildplot.android.rendering.XGrid; import com.wildplot.android.rendering.YAxis; import com.wildplot.android.rendering.YGrid; import com.wildplot.android.rendering.graphics.wrapper.ColorWrap; import com.wildplot.android.rendering.graphics.wrapper.RectangleWrap; import timber.log.Timber; public class ChartBuilder { private static final float BAR_OPACITY = 0.7f; private static final double STARTING_BAR_THICKNESS = 0.6; private static final double Y_AXIS_STRETCH_FACTOR = 1.05; private final Stats.ChartType mChartType; private boolean mIsWholeCollection = false; private ChartView mChartView; private Collection mCollectionData; int mMaxCards = 0; private boolean mBackwards; private int[] mValueLabels; private int[] mColors; private int[] mAxisTitles; private double[][] mSeriesList; private double mLastElement = 0; private double[][] mCumulative = null; private double mFirstElement; private boolean mHasColoredCumulative; private double mMcount; private boolean mDynamicAxis; public ChartBuilder(ChartView chartView, Collection collectionData, boolean isWholeCollection, Stats.ChartType chartType){ mChartView = chartView; mCollectionData = collectionData; mIsWholeCollection = isWholeCollection; mChartType = chartType; } private void calcStats(Stats.AxisType type){ Stats stats = new Stats(mCollectionData, mIsWholeCollection); switch (mChartType){ case FORECAST: stats.calculateDue(mChartView.getContext(), type); break; case REVIEW_COUNT: stats.calculateReviewCount(type); break; case REVIEW_TIME: stats.calculateReviewTime(type); break; case INTERVALS: stats.calculateIntervals(mChartView.getContext(), type); break; case HOURLY_BREAKDOWN: stats.calculateBreakdown(type); break; case WEEKLY_BREAKDOWN: stats.calculateWeeklyBreakdown(type); break; case ANSWER_BUTTONS: stats.calculateAnswerButtons(type); break; case CARDS_TYPES: stats.calculateCardTypes(type); break; } mCumulative = stats.getCumulative(); mSeriesList = stats.getSeriesList(); Object[] metaData = stats.getMetaInfo(); mBackwards = (Boolean) metaData[2]; mValueLabels = (int[]) metaData[3]; mColors = (int[]) metaData[4]; mAxisTitles = (int[]) metaData[5]; mMaxCards = (Integer) metaData[7]; mLastElement = (Double) metaData[10]; mFirstElement = (Double) metaData[9]; mHasColoredCumulative = (Boolean) metaData[19]; mMcount = (Double) metaData[18]; mDynamicAxis = (Boolean) metaData[20]; } public PlotSheet renderChart(Stats.AxisType type){ calcStats(type); Paint paint = new Paint(Paint.LINEAR_TEXT_FLAG | Paint.ANTI_ALIAS_FLAG); paint.setStyle(Paint.Style.STROKE); int height = mChartView.getMeasuredHeight(); int width = mChartView.getMeasuredWidth(); Timber.d("height: %d, width: %d, %d", height, width, mChartView.getWidth()); if (height <= 0 || width <= 0) { return null; } RectangleWrap rect = new RectangleWrap(width, height); float textSize = AnkiStatsTaskHandler.getInstance().getmStandardTextSize() * 0.85f; paint.setTextSize(textSize); float FontHeight = paint.getTextSize(); int desiredPixelDistanceBetweenTicks = Math.round(paint.measureText("100000") * 2.6f); int frameThickness = Math.round(FontHeight * 4.0f); //System.out.println("frame thickness: " + mFrameThickness); PlotSheet plotSheet = new PlotSheet(mFirstElement - 0.5, mLastElement + 0.5, 0, mMaxCards * Y_AXIS_STRETCH_FACTOR); plotSheet.setFrameThickness(frameThickness * 0.66f, frameThickness * 0.66f, frameThickness, frameThickness * 0.9f); plotSheet.setFontSize(textSize); int backgroundColor = Themes.getColorFromAttr(mChartView.getContext(), android.R.attr.colorBackground); plotSheet.setBackgroundColor(new ColorWrap(backgroundColor)); int textColor = Themes.getColorFromAttr(mChartView.getContext(), android.R.attr.textColor); plotSheet.setTextColor(new ColorWrap(textColor)); plotSheet.setIsBackwards(mBackwards); if (mChartType == Stats.ChartType.CARDS_TYPES) { return createPieChart(plotSheet); } PlotSheet hiddenPlotSheet = new PlotSheet(mFirstElement - 0.5, mLastElement + 0.5, 0, mMcount * Y_AXIS_STRETCH_FACTOR); //for second y-axis hiddenPlotSheet.setFrameThickness(frameThickness * 0.66f, frameThickness * 0.66f, frameThickness, frameThickness * 0.9f); setupCumulative(plotSheet, hiddenPlotSheet); setupBarGraphs(plotSheet, hiddenPlotSheet); double xTicks = ticksCalcX(desiredPixelDistanceBetweenTicks, rect, mFirstElement, mLastElement); setupXaxis(plotSheet, xTicks, true); double yTicks = ticksCalcY(desiredPixelDistanceBetweenTicks, rect, 0, mMaxCards * Y_AXIS_STRETCH_FACTOR); setupYaxis(plotSheet, hiddenPlotSheet, yTicks, mAxisTitles[1], false, true); //0 = X-axis title //1 = Y-axis title left //2 = Y-axis title right (optional) if(mAxisTitles.length == 3) { double rightYtics = ticsCalc(desiredPixelDistanceBetweenTicks, rect, mMcount * Y_AXIS_STRETCH_FACTOR); setupYaxis(plotSheet, hiddenPlotSheet, rightYtics, mAxisTitles[2], true, true); } setupGrid(plotSheet, yTicks * 0.5, xTicks * 0.5); return plotSheet; } private PlotSheet createPieChart(PlotSheet plotSheet) { ColorWrap[] colors = {new ColorWrap(Themes.getColorFromAttr(mChartView.getContext(), mColors[0])), new ColorWrap(Themes.getColorFromAttr(mChartView.getContext(), mColors[1])), new ColorWrap(Themes.getColorFromAttr(mChartView.getContext(), mColors[2])), new ColorWrap(Themes.getColorFromAttr(mChartView.getContext(), mColors[3]))}; PieChart pieChart = new PieChart(plotSheet, mSeriesList[0], colors); pieChart.setName(mChartView.getResources().getString(mValueLabels[0]) + ": " + (int) mSeriesList[0][0]); LegendDrawable legendDrawable1 = new LegendDrawable(); LegendDrawable legendDrawable2 = new LegendDrawable(); LegendDrawable legendDrawable3 = new LegendDrawable(); legendDrawable1.setColor(new ColorWrap(Themes.getColorFromAttr(mChartView.getContext(), mColors[1]))); legendDrawable2.setColor(new ColorWrap(Themes.getColorFromAttr(mChartView.getContext(), mColors[2]))); legendDrawable3.setColor(new ColorWrap(Themes.getColorFromAttr(mChartView.getContext(), mColors[3]))); legendDrawable1.setName(mChartView.getResources().getString(mValueLabels[1]) + ": " + (int) mSeriesList[0][1]); legendDrawable2.setName(mChartView.getResources().getString(mValueLabels[2]) + ": " + (int) mSeriesList[0][2]); legendDrawable3.setName(mChartView.getResources().getString(mValueLabels[3]) + ": " + (int) mSeriesList[0][3]); plotSheet.unsetBorder(); plotSheet.addDrawable(pieChart); plotSheet.addDrawable(legendDrawable1); plotSheet.addDrawable(legendDrawable2); plotSheet.addDrawable(legendDrawable3); return plotSheet; } private void setupBarGraphs(PlotSheet plotSheet, PlotSheet hiddenPlotSheet) { int length = mSeriesList.length; if (mChartType == Stats.ChartType.HOURLY_BREAKDOWN || mChartType == Stats.ChartType.WEEKLY_BREAKDOWN) { length--; //there is data in hourly breakdown that is never used (even in Anki-Desktop) } for (int i = 1; i < length; i++) { double[][] bars = new double[2][]; bars[0] = mSeriesList[0]; bars[1] = mSeriesList[i]; PlotSheet usedPlotSheet = plotSheet; double barThickness = STARTING_BAR_THICKNESS; if ((mChartType == Stats.ChartType.HOURLY_BREAKDOWN || mChartType == Stats.ChartType.WEEKLY_BREAKDOWN)) { barThickness = 0.8; if (i == 2) { usedPlotSheet = hiddenPlotSheet; barThickness = 0.2; } } ColorWrap color; switch (mChartType) { case ANSWER_BUTTONS: case HOURLY_BREAKDOWN: case WEEKLY_BREAKDOWN: case INTERVALS: color = new ColorWrap(Themes.getColorFromAttr(mChartView.getContext(), mColors[i - 1]), BAR_OPACITY); break; case REVIEW_COUNT: case REVIEW_TIME: case FORECAST: if (i == 1) { color = new ColorWrap(Themes.getColorFromAttr(mChartView.getContext(), mColors[i - 1]), BAR_OPACITY); break; } default: color = new ColorWrap(Themes.getColorFromAttr(mChartView.getContext(), mColors[i - 1])); } BarGraph barGraph = new BarGraph(usedPlotSheet, barThickness, bars, color); barGraph.setFilling(true); barGraph.setName(mChartView.getResources().getString(mValueLabels[i - 1])); //barGraph.setFillColor(Color.GREEN.darker()); barGraph.setFillColor(color); plotSheet.addDrawable(barGraph); } } private void setupCumulative(PlotSheet plotSheet, PlotSheet hiddenPlotSheet){ if (mCumulative == null) { return; } for (int i = 1; i < mCumulative.length; i++) { double[][] cumulative = {mCumulative[0], mCumulative[i]}; ColorWrap usedColor = new ColorWrap(Themes.getColorFromAttr(mChartView.getContext(), R.attr.stats_cumulative)); String name = mChartView.getResources().getString(R.string.stats_cumulative); if (mHasColoredCumulative) { //also non colored Cumulatives have names! usedColor = new ColorWrap(Themes.getColorFromAttr(mChartView.getContext(), mColors[i - 1])); } else { if (mChartType == Stats.ChartType.INTERVALS) { name = mChartView.getResources().getString(R.string.stats_cumulative_percentage); } } Lines lines = new Lines(hiddenPlotSheet, cumulative, usedColor); lines.setSize(3f); lines.setShadow(5f, 2f, 2f, ColorWrap.BLACK); if (!mHasColoredCumulative) { lines.setName(name); } plotSheet.addDrawable(lines); } } private void setupXaxis(PlotSheet plotSheet, double xTicks, boolean hasName) { XAxis xAxis = new XAxis(plotSheet, 0, xTicks, xTicks / 2.0); xAxis.setOnFrame(); if (hasName) { if (mDynamicAxis) { xAxis.setName(mChartView.getResources().getStringArray(R.array.due_x_axis_title)[mAxisTitles[0]]); } else { xAxis.setName(mChartView.getResources().getString(mAxisTitles[0])); } } double[] timePositions; //some explicit x-axis naming: switch (mChartType) { case ANSWER_BUTTONS: timePositions = new double[]{1, 2, 3, 6, 7, 8, 9, 11, 12, 13, 14}; xAxis.setExplicitTicks(timePositions, mChartView.getResources().getStringArray(R.array.stats_eases_ticks)); break; case HOURLY_BREAKDOWN: timePositions = new double[]{0, 6, 12, 18, 23}; xAxis.setExplicitTicks(timePositions, mChartView.getResources().getStringArray(R.array.stats_day_time_strings)); break; case WEEKLY_BREAKDOWN: timePositions = new double[]{0, 1, 2, 3, 4, 5, 6}; xAxis.setExplicitTicks(timePositions, mChartView.getResources().getStringArray(R.array.stats_week_days)); break; } xAxis.setIntegerNumbering(true); plotSheet.addDrawable(xAxis); } private void setupYaxis(PlotSheet plotSheet, PlotSheet hiddenPlotSheet, double yTicks, int title, boolean isOnRight, boolean hasName) { YAxis yAxis; if (isOnRight && hiddenPlotSheet != null) { yAxis = new YAxis(hiddenPlotSheet, 0, yTicks, yTicks / 2.0); } else { yAxis = new YAxis(plotSheet, 0, yTicks, yTicks / 2.0); } yAxis.setIntegerNumbering(true); if (hasName) { yAxis.setName(mChartView.getResources().getString(title)); } if (isOnRight) { yAxis.setOnRightSideFrame(); } else { yAxis.setOnFrame(); } yAxis.setHasNumbersRotated(); plotSheet.addDrawable(yAxis); } private void setupGrid(PlotSheet plotSheet, double yTicks, double xTicks) { int red = ColorWrap.LIGHT_GRAY.getRed(); int green = ColorWrap.LIGHT_GRAY.getGreen(); int blue = ColorWrap.LIGHT_GRAY.getBlue(); ColorWrap newGridColor = new ColorWrap(red, green, blue, 222); XGrid xGrid = new XGrid(plotSheet, 0, yTicks); //ticks are not wrong, xgrid is vertical to yaxis -> yticks YGrid yGrid = new YGrid(plotSheet, 0, xTicks); double[] timePositions; //some explicit x-axis naming: switch (mChartType) { case ANSWER_BUTTONS: timePositions = new double[]{1, 2, 3, 6, 7, 8, 9, 11, 12, 13, 14}; yGrid.setExplicitTicks(timePositions); break; case HOURLY_BREAKDOWN: timePositions = new double[]{0, 6, 12, 18, 23}; yGrid.setExplicitTicks(timePositions); break; case WEEKLY_BREAKDOWN: timePositions = new double[]{0, 1, 2, 3, 4, 5, 6}; yGrid.setExplicitTicks(timePositions); break; } xGrid.setColor(newGridColor); yGrid.setColor(newGridColor); plotSheet.addDrawable(xGrid); plotSheet.addDrawable(yGrid); } public double ticksCalcX(int pixelDistance, RectangleWrap field, double start, double end) { double deltaRange = end - start; int ticlimit = field.width / pixelDistance; double tics = Math.pow(10, (int) Math.log10(deltaRange / ticlimit)); while (2.0 * (deltaRange / (tics)) <= ticlimit) { tics /= 2.0; } while ((deltaRange / (tics)) / 2 >= ticlimit) { tics *= 2.0; } return tics; } public double ticksCalcY(int pixelDistance, RectangleWrap field, double start, double end) { double size = ticsCalc(pixelDistance, field, end - start); Timber.d("ChartBuilder ticksCalcY: pixelDistance: %d, ticks: %,.2f, start: %,.2f, end: %,.2f, height: %d", pixelDistance, size, start, end, field.height); return size; } public double ticsCalc(int pixelDistance, RectangleWrap field, double deltaRange) { //Make approximation of number of ticks based on desired number of pixels per tick double numTicks = field.height / pixelDistance; //Compute size of one tick in graph-units double delta = deltaRange / numTicks; //Write size of one tick in the form norm * magn double dec = Math.floor(Math.log(delta) / Math.log(10)); double magn = Math.pow(10, dec); double norm = delta / magn; // norm is between 1.0 and 10.0 //Write size of one tick in the form size * magn //Where size in (1, 2, 2.5, 5, 10) double size; if (norm < 1.5) { size = 1; } else if (norm < 3) { size = 2; // special case for 2.5, requires an extra decimal if (norm > 2.25) { size = 2.5; } } else if (norm < 7.5) { size = 5; } else { size = 10; } //Compute size * magn so that we return one number size *= magn; Timber.d("ChartBuilder ticksCalc : pixelDistance: %d, ticks: %,.2f, deltaRange: %,.2f, height: %d", pixelDistance, size, deltaRange, field.height); return size; } }