From ae4f9565bd8b7e0f92f48370f999ca1996bb82ff Mon Sep 17 00:00:00 2001 From: bartn Date: Wed, 29 Dec 2010 17:52:23 -0800 Subject: [PATCH] Chart drawing refactoring, part 1. Better handle label drawings for multiple data series, fix a few bugs and introduce fine grained zoom settings. --- MyTracks/res/layout/mytracks_elevation.xml | 4 +- .../apps/mytracks/ChartValueSeries.java | 151 ++++++++++----- .../android/apps/mytracks/ChartView.java | 181 +++++++++--------- .../apps/mytracks/ChartValueSeriesTest.java | 54 +++++- 4 files changed, 248 insertions(+), 142 deletions(-) diff --git a/MyTracks/res/layout/mytracks_elevation.xml b/MyTracks/res/layout/mytracks_elevation.xml index 823ee8be1..e758d30bd 100644 --- a/MyTracks/res/layout/mytracks_elevation.xml +++ b/MyTracks/res/layout/mytracks_elevation.xml @@ -30,13 +30,15 @@ limitations under the License. android:orientation="vertical" android:layout_width="fill_parent" android:layout_height="fill_parent" + android:padding="10dip" android:layout_weight="0"> + android:layout_centerHorizontal="true" + android:layout_alignParentRight="true"/> min) { + roundedMin -= zoomLevel; + } + double interval = (max - roundedMin) / intervals; + if (zoomLevel >= interval) { + return zoomLevel; + } + } + return zoomLevels[zoomLevels.length - 1]; + } + } /** * Constructs a new chart value series. @@ -56,14 +135,12 @@ public class ChartValueSeries { * @param formatString The format of the decimal format for this series * @param fill The paint for filling the chart * @param stroke The paint for stroking the outside the chart, optional - * @param rounding The factor to round the values by + * @param zoomSettings The settings related to zooming + * + * TODO: Get rid of Context and inject appropriate values instead. */ - public ChartValueSeries(Context context, - String formatString, - int fillColor, - int strokeColor, - int rounding, - int titleId) { + public ChartValueSeries(Context context, String formatString, int fillColor, int strokeColor, + ZoomSettings zoomSettings, int titleId) { this.format = new DecimalFormat(formatString); fillPaint = new Paint(); fillPaint.setStyle(Style.FILL); @@ -81,7 +158,7 @@ public class ChartValueSeries { strokePaint = null; labelPaint = fillPaint; } - this.rounding = rounding; + this.zoomSettings = zoomSettings; this.title = context.getString(titleId); } @@ -113,20 +190,7 @@ public class ChartValueSeries { * @return The interval between markers */ public int getInterval() { - // Try to find 5 even looking intervals. - int interval = (int) (spread / 5); - int minInterval = rounding / 4; - if (interval < minInterval) { - // We won't be able to find 5 even intervals. - return minInterval; - } else if (interval < rounding) { - // The desired interval is less than the rounding value. - // We will have less than 5 intervals. - return rounding; - } else { - // Round the interval. - return (interval / rounding) * rounding; - } + return interval; } /** @@ -141,12 +205,14 @@ public class ChartValueSeries { min = monitor.getMin(); max = monitor.getMax(); } + + this.interval = zoomSettings.calculateInterval(min, max); // Round it up. - effectiveMax = ((int) (max / rounding)) * rounding + rounding; + effectiveMax = ((int) (max / interval)) * interval + interval; // Round it down. - effectiveMin = ((int) (min / rounding)) * rounding; + effectiveMin = ((int) (min / interval)) * interval; if (min < 0) { - effectiveMin -= rounding; + effectiveMin -= interval; } spread = getMax() - effectiveMin; } @@ -169,15 +235,14 @@ public class ChartValueSeries { /** * @return The minimum of the rounded up max value and the effective max. + * + * TODO: Move to ZoomSettings. */ public int getMax() { // Return the effective max if no absolute is set // or the effective max is less than the absolute. - if ((absoluteMax == Integer.MIN_VALUE) || (effectiveMax < absoluteMax)) { - return effectiveMax; - } else { - return absoluteMax; - } + return zoomSettings.absoluteMax == Integer.MIN_VALUE || effectiveMax < zoomSettings.absoluteMax + ? effectiveMax : zoomSettings.absoluteMax; } /** @@ -205,9 +270,7 @@ public class ChartValueSeries { * @return The paint for this series */ Paint getPaint() { - return (strokePaint == null) - ? fillPaint - : strokePaint; + return strokePaint == null ? fillPaint : strokePaint; } public Paint getLabelPaint() { @@ -239,14 +302,6 @@ public class ChartValueSeries { this.enabled = enabled; } - public int getAbsoluteMax() { - return absoluteMax; - } - - public void setAbsoluteMax(int absoluteMax) { - this.absoluteMax = absoluteMax; - } - public boolean hasData() { return monitor.hasData(); } diff --git a/MyTracks/src/com/google/android/apps/mytracks/ChartView.java b/MyTracks/src/com/google/android/apps/mytracks/ChartView.java index f72a521fd..aebc19d4c 100644 --- a/MyTracks/src/com/google/android/apps/mytracks/ChartView.java +++ b/MyTracks/src/com/google/android/apps/mytracks/ChartView.java @@ -15,6 +15,7 @@ */ package com.google.android.apps.mytracks; +import com.google.android.apps.mytracks.ChartValueSeries.ZoomSettings; import com.google.android.apps.mytracks.content.Waypoint; import com.google.android.apps.mytracks.stats.ExtremityMonitor; import com.google.android.apps.mytracks.util.StringUtils; @@ -25,9 +26,11 @@ import android.content.Context; import android.content.Intent; import android.graphics.Canvas; import android.graphics.Color; +import android.graphics.DashPathEffect; import android.graphics.Paint; -import android.graphics.Paint.Style; import android.graphics.Path; +import android.graphics.Paint.Align; +import android.graphics.Paint.Style; import android.graphics.drawable.Drawable; import android.view.MotionEvent; import android.view.VelocityTracker; @@ -60,8 +63,10 @@ public class ChartView extends View { */ private int zoomLevel = 1; private final int minZoomLevel = 1; - private int maxZoomLevel = 5; + private int maxZoomLevel = 10; + private static final int MAX_INTERVALS = 5; + /* * Borders, margins, dimensions (in pixels): */ @@ -80,7 +85,7 @@ public class ChartView extends View { /** * Unscaled bottom border of the chart. */ - private static final float BOTTOM_BORDER = 100; + private static final float BOTTOM_BORDER = 40; /** * Device scaled bottom border of the chart. @@ -122,6 +127,7 @@ public class ChartView extends View { private final Paint borderPaint = new Paint(); private final Paint labelPaint = new Paint(); private final Paint gridPaint = new Paint(); + private final Paint gridBarPaint = new Paint(); private final Paint clearPaint = new Paint(); private final Drawable pointer; private final Drawable statsMarker; @@ -142,8 +148,6 @@ public class ChartView extends View { private boolean metricUnits = true; private boolean showPointer = false; - private boolean yLabelMask[] = null; - /** Display chart versus distance or time */ public enum Mode { BY_DISTANCE, BY_TIME @@ -168,6 +172,9 @@ public class ChartView extends View { gridPaint.setColor(context.getResources().getColor(R.color.gray)); gridPaint.setAntiAlias(false); + gridBarPaint.set(gridPaint); + gridBarPaint.setPathEffect(new DashPathEffect(new float[] {3, 2}, 0)); + clearPaint.setStyle(Style.FILL); clearPaint.setColor(context.getResources().getColor(R.color.white)); clearPaint.setAntiAlias(false); @@ -193,43 +200,43 @@ public class ChartView extends View { public void setUpChartValueSeries(Context context) { series = new ChartValueSeries[NUM_SERIES]; - // Create the value series + // Create the value series. series[ELEVATION_SERIES] = new ChartValueSeries(context, "###,###", R.color.elevation_fill, R.color.elevation_border, - 100, + new ZoomSettings(MAX_INTERVALS, + new int[] {5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000}), R.string.elevation); series[SPEED_SERIES] = new ChartValueSeries(context, - "###,###.0", + "###,###", R.color.blue_transparent, R.color.blue, - 5, + new ZoomSettings(MAX_INTERVALS, new int[] {5, 10, 20, 50}), R.string.speed); series[POWER_SERIES] = new ChartValueSeries(context, "###,###", R.color.power_fill, R.color.power_border, - 5, + new ZoomSettings(MAX_INTERVALS, 0, 1500, new int[] {5, 50, 100, 200}), R.string.power); - series[POWER_SERIES].setAbsoluteMax(1500); series[CADENCE_SERIES] = new ChartValueSeries(context, "###,###", R.color.cadence_fill, R.color.cadence_border, - 5, + new ZoomSettings(MAX_INTERVALS, new int[] {5, 10, 25, 50}), R.string.cadence); series[HEART_RATE_SERIES] = new ChartValueSeries(context, "###,###", R.color.heartrate_fill, R.color.heartrate_border, - 5, + new ZoomSettings(MAX_INTERVALS, new int[] {10, 50}), R.string.heart_rate); } @@ -543,7 +550,7 @@ public class ChartView extends View { @Override protected synchronized void onDraw(Canvas c) { if (w != c.getWidth() || h != c.getHeight()) { - // Dimensions have changed (for example due to orientation change): + // Dimensions have changed (for example due to orientation change). w = c.getWidth(); h = c.getHeight(); effectiveWidth = Math.max(0, w - leftBorder - RIGHT_BORDER); @@ -558,16 +565,21 @@ public class ChartView extends View { c.restore(); return; } + + c.save(); + c.clipRect(leftBorder + 1 + getScrollX(), topBorder + 1, + w - RIGHT_BORDER + getScrollX() - 1, h - bottomBorder - 1); - // Draw the data series + drawGrid(c); + + // Draw the data series. for (ChartValueSeries cvs : series) { if (cvs.isEnabled() && cvs.hasData()) { cvs.drawPath(c); } } - drawGrid(c); - - // Draw the waypoints: + + // Draw the waypoints. for (int i = 1; i < waypoints.size(); i++) { final Waypoint waypoint = waypoints.get(i); if (waypoint.getLocation() == null) { @@ -586,18 +598,21 @@ public class ChartView extends View { c.restore(); } - // Draw the axis and labels: + c.restore(); + + // Draw the axis and labels. drawXLabels(c); drawXAxis(c); drawSeriesTitles(c); - - yLabelMask = new boolean[effectiveHeight / FONT_HEIGHT + 1]; + c.translate(getScrollX(), 0); drawYAxis(c); + int x = leftBorder - 5; for (ChartValueSeries cvs : series) { if (cvs.isEnabled() && cvs.hasData()) { - drawYLabels(cvs, c); + drawYLabels(cvs, c, x); } + x -= 30; } c.restore(); if (showPointer && data.size() > 0) { @@ -618,8 +633,8 @@ public class ChartView extends View { int j = 0; for (ChartValueSeries cvs : series) { if (cvs.isEnabled() && cvs.hasData()) { - int y = (int) (w * zoomLevel * ((double) (++j) / sections)); - c.drawText(cvs.getTitle(), y, topBorder, cvs.getLabelPaint()); + int x = (int) (w * (double) ++j / sections) + getScrollX(); + c.drawText(cvs.getTitle(), x, topBorder, cvs.getLabelPaint()); } } } @@ -632,7 +647,7 @@ public class ChartView extends View { for (ChartValueSeries cvs : series) { cvs.getPath().reset(); } - if (data.size() < 1) { + if (data.isEmpty()) { return; } @@ -644,7 +659,11 @@ public class ChartView extends View { for (int j = 0; j < min; j++) { ChartValueSeries cvs = series[j]; Path path = cvs.getPath(); - path.lineTo(getX(d[0]), getY(cvs, d[j + 1])); + if (i == 0) { + path.moveTo(getX(d[0]), getY(cvs, d[j + 1])); + } else { + path.lineTo(getX(d[0]), getY(cvs, d[j + 1])); + } } } @@ -695,8 +714,7 @@ public class ChartView extends View { } int maxLength = 0; for (ChartValueSeries cvs : series) { - maxLength = Math.max(maxLength, - cvs.getMaxLabelLength()); + maxLength += cvs.getMaxLabelLength(); } leftBorder = 4 + 7 * maxLength; effectiveWidth = w - leftBorder - RIGHT_BORDER; @@ -716,23 +734,20 @@ public class ChartView extends View { } private void drawXLabels(Canvas c) { - int numLabels = 0; double interval = (int) (maxX / zoomLevel / 4); boolean shortFormat = false; if (interval < 1) { interval = .5; shortFormat = true; + } else if (interval < 5) { + interval = 2; + } else if (interval < 10) { + interval = 5; } else { - if (interval < 5) { - interval = 2; - } else if (interval < 10) { - interval = 5; - } else { - interval = (interval / 10) * 10; - } + interval = (interval / 10) * 10; } drawXLabel(c, 0, shortFormat); - numLabels++; + int numLabels = 1; for (int i = 1; i * interval < maxX; i++) { drawXLabel(c, i * interval, shortFormat); numLabels++; @@ -742,26 +757,17 @@ public class ChartView extends View { } } - private void drawYLabels(ChartValueSeries cvs, Canvas c) { - int numLabels = 0; - drawYLabel(cvs, c, 0); - final int y0 = getY(cvs, 0); - numLabels++; + private void drawYLabels(ChartValueSeries cvs, Canvas c, int x) { int interval = cvs.getInterval(); - for (int i = 0; i * interval < cvs.getSpread(); i++) { - final int y = i * interval + cvs.getMin(); - final int yi = getY(cvs, y); - if (Math.abs(y0 - yi) > 15) { - drawYLabel(cvs, c, y); - numLabels++; - } - } - if (numLabels < 3) { - drawYLabel(cvs, c, cvs.getMax()); + for (int i = 0; i < MAX_INTERVALS; ++i) { + drawYLabel(cvs, c, x, i * interval + cvs.getMin()); } } private void drawXLabel(Canvas c, double x, boolean shortFormat) { + if (x < 0) { + return; + } String s = (mode == Mode.BY_DISTANCE) ? (shortFormat ? xShortFormat.format(x) : xFormat.format(x)) @@ -772,36 +778,23 @@ public class ChartView extends View { labelPaint); } - private void drawYLabel(ChartValueSeries cvs, Canvas c, int y) { - int desiredY = - (int) ((y - cvs.getMin()) * effectiveHeight / cvs.getSpread()); - - // Make sure we don't write one label on top of another. - int slot = desiredY / FONT_HEIGHT; - if (slot < 0) { - return; - } - if (yLabelMask[slot]) { - desiredY = ++slot * FONT_HEIGHT; - } - desiredY = topBorder + effectiveHeight + 5 - desiredY; - if (slot < yLabelMask.length) { - yLabelMask[slot] = true; - } - c.drawText(cvs.getFormat().format(y), - 2, desiredY, - cvs.getLabelPaint()); + private void drawYLabel(ChartValueSeries cvs, Canvas c, int x, int y) { + int desiredY = (int) ((y - cvs.getMin()) * effectiveHeight / + (cvs.getInterval() * MAX_INTERVALS)); + desiredY = topBorder + effectiveHeight + FONT_HEIGHT / 2 - desiredY; + Paint p = new Paint(cvs.getLabelPaint()); + p.setTextAlign(Align.RIGHT); + c.drawText(cvs.getFormat().format(y), x, desiredY, p); } private void drawXAxis(Canvas canvas) { float rightEdge = getX(maxX); - canvas.drawLine(leftBorder, effectiveHeight + topBorder, rightEdge, - effectiveHeight + topBorder, borderPaint); + final int y = effectiveHeight + topBorder; + canvas.drawLine(leftBorder, y, rightEdge, y, borderPaint); Context c = getContext(); - String s = - (mode == Mode.BY_DISTANCE) - ? (metricUnits ? c.getString(R.string.kilometer) - : c.getString(R.string.mile)) : (c.getString(R.string.min)); + String s = (mode == Mode.BY_DISTANCE) + ? (metricUnits ? c.getString(R.string.kilometer) + : c.getString(R.string.mile)) : (c.getString(R.string.min)); canvas.drawText(s, rightEdge, effectiveHeight + .2f * UNIT_BORDER + topBorder, @@ -809,12 +802,18 @@ public class ChartView extends View { } private void drawYAxis(Canvas canvas) { + canvas.drawRect(0, 0, + leftBorder - 1, effectiveHeight + topBorder + UNIT_BORDER + 1, + clearPaint); canvas.drawLine(leftBorder, UNIT_BORDER + topBorder, leftBorder, effectiveHeight + topBorder, borderPaint); - canvas.drawRect(0, 0, - leftBorder - 1, effectiveHeight + topBorder + 1, - clearPaint); + final int intervals = getIntervals(); + for (int i = 1; i < intervals; ++i) { + int y = i * effectiveHeight / intervals + topBorder; + canvas.drawLine(leftBorder - 5, y, leftBorder, y, gridPaint); + } + Context c = getContext(); // TODO: This should really show units for all series. String s = metricUnits @@ -824,20 +823,20 @@ public class ChartView extends View { UNIT_BORDER * .8f + topBorder, labelPaint); } + private int getIntervals() { + return 5; + } + private synchronized void drawGrid(Canvas c) { - if (data.size() < 1) { + if (data.isEmpty()) { return; } - for (ChartValueSeries cvs : series) { - if (cvs.isEnabled()) { - int interval = cvs.getInterval(); - for (int i = 1; i * interval < cvs.getSpread(); i++) { - int y = getY(cvs, i * interval + cvs.getMin()); - c.drawLine(getX(data.get(0)[0]), y, - getX(data.get(data.size() - 1)[0]), y, - cvs.getPaint()); - } - } + + float rightEdge = getX(maxX); + final int intervals = getIntervals(); + for (int i = 1; i < intervals; ++i) { + int y = i * effectiveHeight / intervals + topBorder; + c.drawLine(leftBorder, y, rightEdge, y, gridBarPaint); } } diff --git a/MyTracksTest/src/com/google/android/apps/mytracks/ChartValueSeriesTest.java b/MyTracksTest/src/com/google/android/apps/mytracks/ChartValueSeriesTest.java index 775fd7971..7972d7c34 100644 --- a/MyTracksTest/src/com/google/android/apps/mytracks/ChartValueSeriesTest.java +++ b/MyTracksTest/src/com/google/android/apps/mytracks/ChartValueSeriesTest.java @@ -15,9 +15,9 @@ */ package com.google.android.apps.mytracks; +import com.google.android.apps.mytracks.ChartValueSeries.ZoomSettings; import com.google.android.maps.mytracks.R; -import android.graphics.Paint; import android.graphics.Paint.Style; import android.test.AndroidTestCase; @@ -33,7 +33,7 @@ public class ChartValueSeriesTest extends AndroidTestCase { "###,###", R.color.elevation_fill, R.color.elevation_border, - 100, + new ZoomSettings(5, new int[] {100}), R.string.elevation); } @@ -86,4 +86,54 @@ public class ChartValueSeriesTest extends AndroidTestCase { assertEquals(1500, series.getMax()); assertEquals(1000.0, series.getSpread()); } + + public void testZoomSettings_invalidArgs() { + try { + new ZoomSettings(0, new int[] {10, 50, 100}); + fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + // OK. + } + try { + new ZoomSettings(1, null); + fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + // OK. + } + try { + new ZoomSettings(1, new int[] {}); + fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + // OK. + } + try { + new ZoomSettings(1, new int[] {1, 3, 2}); + fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + // OK. + } + } + + public void testZoomSettings_minAligned() { + ZoomSettings settings = new ZoomSettings(5, new int[] {10, 50, 100}); + assertEquals(10, settings.calculateInterval(0, 15)); + assertEquals(10, settings.calculateInterval(0, 50)); + assertEquals(50, settings.calculateInterval(0, 111)); + assertEquals(50, settings.calculateInterval(0, 250)); + assertEquals(100, settings.calculateInterval(0, 251)); + assertEquals(100, settings.calculateInterval(0, 10000)); + } + + public void testZoomSettings_minNotAligned() { + ZoomSettings settings = new ZoomSettings(5, new int[] {10, 50, 100}); + assertEquals(50, settings.calculateInterval(5, 55)); + assertEquals(10, settings.calculateInterval(10, 60)); + assertEquals(50, settings.calculateInterval(7, 250)); + assertEquals(100, settings.calculateInterval(7, 257)); + assertEquals(100, settings.calculateInterval(11, 10000)); + + // A regression test. + settings = new ZoomSettings(5, new int[] {5, 10, 20}); + assertEquals(10, settings.calculateInterval(-37.14, -11.89)); + } }