From c3382353438edd2d036f7f345216d1e7bb4a6746 Mon Sep 17 00:00:00 2001 From: Nick Fellows Date: Sat, 3 Dec 2016 09:44:20 -0600 Subject: [PATCH 01/85] * fixed a bug causing points scrolled off-screen to accumulate and render along the left edge of the graph (#20) * fixed a bug that would cause render jitter when extreme zoom levels were applied * fixed a bug that prevented PanZoom from working properly on plots that did not specify outer limits. * added basic implementation of a normalizing xyseries wrapper class * added dual scale xy example * added rotation property to Widget * added graphRotation XML attr to XYPlot --- .../java/com/androidplot/SeriesRegistry.java | 12 ++ .../com/androidplot/ui/LayoutManager.java | 1 - .../com/androidplot/ui/widget/Widget.java | 63 ++++++- .../java/com/androidplot/util/AttrUtils.java | 10 ++ .../com/androidplot/util/SeriesUtils.java | 21 ++- .../androidplot/xy/LineAndPointRenderer.java | 85 ++++++---- .../com/androidplot/xy/NormedXYSeries.java | 136 +++++++++++++++ .../main/java/com/androidplot/xy/PanZoom.java | 11 +- .../com/androidplot/xy/SimpleXYSeries.java | 52 ++++-- .../com/androidplot/xy/XYGraphWidget.java | 5 +- .../main/java/com/androidplot/xy/XYPlot.java | 6 + .../src/main/res/values/attrs.xml | 13 +- .../test/java/com/androidplot/RegionTest.java | 2 +- .../com/androidplot/SeriesRegistryTest.java | 136 +++++++++++++++ .../java/com/androidplot/test/TestUtils.java | 16 +- .../xy/LineAndPointRendererTest.java | 92 +++++++++- .../androidplot/xy/NormedXYSeriesTest.java | 87 ++++++++++ .../androidplot/xy/SampledXYSeriesTest.java | 2 +- build.gradle | 2 +- demoapp/src/main/AndroidManifest.xml | 1 + .../androidplot/demos/DualScaleActivity.java | 158 ++++++++++++++++++ .../com/androidplot/demos/MainActivity.java | 8 + .../demos/TouchZoomExampleActivity.java | 6 +- .../main/res/layout/dual_scale_example.xml | 35 ++++ demoapp/src/main/res/layout/main.xml | 5 + docs/advanced_xy_plot.md | 90 +++++++++- docs/quickstart.md | 2 +- docs/release_notes.md | 8 + docs/xyplot.md | 71 +++++++- 29 files changed, 1045 insertions(+), 91 deletions(-) create mode 100644 androidplot-core/src/main/java/com/androidplot/xy/NormedXYSeries.java create mode 100644 androidplot-core/src/test/java/com/androidplot/SeriesRegistryTest.java create mode 100644 androidplot-core/src/test/java/com/androidplot/xy/NormedXYSeriesTest.java create mode 100644 demoapp/src/main/java/com/androidplot/demos/DualScaleActivity.java create mode 100644 demoapp/src/main/res/layout/dual_scale_example.xml diff --git a/androidplot-core/src/main/java/com/androidplot/SeriesRegistry.java b/androidplot-core/src/main/java/com/androidplot/SeriesRegistry.java index 948d1d11..78f3d805 100644 --- a/androidplot-core/src/main/java/com/androidplot/SeriesRegistry.java +++ b/androidplot-core/src/main/java/com/androidplot/SeriesRegistry.java @@ -54,6 +54,9 @@ public boolean isEmpty() { } public boolean add(SeriesType series, FormatterType formatter) { + if(series == null || formatter == null) { + throw new IllegalArgumentException("Neither series nor formatter param may be null."); + } return registry.add(newSeriesBundle(series, formatter)); } @@ -122,4 +125,13 @@ public List> getLegendEnabledItems() { } return sfList; } + + public boolean contains(SeriesType series, Class formatterClass) { + for(BundleType b : registry) { + if(b.getFormatter().getClass() == formatterClass && b.getSeries() == series) { + return true; + } + } + return false; + } } diff --git a/androidplot-core/src/main/java/com/androidplot/ui/LayoutManager.java b/androidplot-core/src/main/java/com/androidplot/ui/LayoutManager.java index 013cb003..d9107442 100644 --- a/androidplot-core/src/main/java/com/androidplot/ui/LayoutManager.java +++ b/androidplot-core/src/main/java/com/androidplot/ui/LayoutManager.java @@ -85,7 +85,6 @@ public void draw(Canvas canvas) throws PlotRenderException { drawSpacing(canvas, displayDims.marginatedRect, displayDims.paddedRect, paddingPaint); } for (Widget widget : elements()) { - //int canvasState = canvas.save(Canvas.ALL_SAVE_FLAG); // preserve clipping etc try { canvas.save(Canvas.ALL_SAVE_FLAG); PositionMetrics metrics = widget.getPositionMetrics(); diff --git a/androidplot-core/src/main/java/com/androidplot/ui/widget/Widget.java b/androidplot-core/src/main/java/com/androidplot/ui/widget/Widget.java index e12934f1..6009ce36 100644 --- a/androidplot-core/src/main/java/com/androidplot/ui/widget/Widget.java +++ b/androidplot-core/src/main/java/com/androidplot/ui/widget/Widget.java @@ -41,6 +41,15 @@ public abstract class Widget implements BoxModelable, Resizable { private PositionMetrics positionMetrics; private LayoutManager layoutManager; + private Rotation rotation = Rotation.NONE; + + public enum Rotation { + NINETY_DEGREES, + NEGATIVE_NINETY_DEGREES, + ONE_HUNDRED_EIGHTY_DEGREES, + NONE, + } + public Widget(LayoutManager layoutManager, SizeMetric heightMetric, SizeMetric widthMetric) { this(layoutManager, new Size(heightMetric, widthMetric)); } @@ -113,7 +122,6 @@ public void onPostInit() { * @return */ public boolean containsPoint(PointF point) { - //return outlineRect != null && outlineRect.contains(point.x, point.y); return widgetDimensions.canvasRect.contains(point.x, point.y); } @@ -344,14 +352,55 @@ public void draw(Canvas canvas) throws PlotRenderException { if (backgroundPaint != null) { drawBackground(canvas, widgetDimensions.canvasRect); } - doOnDraw(canvas, widgetDimensions.paddedRect); + canvas.save(); + final RectF paddedRect = applyRotation(canvas, widgetDimensions.paddedRect); + doOnDraw(canvas, paddedRect); + canvas.restore(); if (borderPaint != null) { - drawBorder(canvas, widgetDimensions.paddedRect); + drawBorder(canvas, paddedRect); } } } + protected RectF applyRotation(Canvas canvas, RectF rect) { + float rotationDegs = 0; + final float cx = widgetDimensions.paddedRect.centerX(); + final float cy = widgetDimensions.paddedRect.centerY(); + final float halfHeight = widgetDimensions.paddedRect.height() / 2; + final float halfWidth = widgetDimensions.paddedRect.width() / 2; + switch (rotation) { + case NINETY_DEGREES: + rotationDegs = 90; + rect = new RectF( + cx - halfHeight, + cy - halfWidth, + cx + halfHeight, + cy + halfWidth); + break; + case NEGATIVE_NINETY_DEGREES: + rotationDegs = -90; + rect = new RectF( + cx - halfHeight, + cy - halfWidth, + cx + halfHeight, + cy + halfWidth); + break; + case ONE_HUNDRED_EIGHTY_DEGREES: + rotationDegs = 180; + // fall through + case NONE: + break; + default: + throw new UnsupportedOperationException("Not yet implemented."); + + } + if(rotation != Rotation.NONE) { + canvas.rotate(rotationDegs, cx, cy); + } + return rect; + } + protected void drawBorder(Canvas canvas, RectF paddedRect) { canvas.drawRect(paddedRect, borderPaint); } @@ -405,4 +454,12 @@ public PositionMetrics getPositionMetrics() { public void setPositionMetrics(PositionMetrics positionMetrics) { this.positionMetrics = positionMetrics; } + + public Rotation getRotation() { + return rotation; + } + + public void setRotation(Rotation rotation) { + this.rotation = rotation; + } } diff --git a/androidplot-core/src/main/java/com/androidplot/util/AttrUtils.java b/androidplot-core/src/main/java/com/androidplot/util/AttrUtils.java index b8b6c7f7..b34e7d47 100644 --- a/androidplot-core/src/main/java/com/androidplot/util/AttrUtils.java +++ b/androidplot-core/src/main/java/com/androidplot/util/AttrUtils.java @@ -188,6 +188,12 @@ public static void configureWidget(TypedArray attrs, Widget widget, int heightSi } } + public static void configureWidgetRotation(TypedArray attrs, Widget widget, int rotationAttr) { + if(attrs != null) { + widget.setRotation(getWidgetRotation(attrs, rotationAttr, Widget.Rotation.NONE)); + } + } + /** * Configure a {@link Widget} from xml attrs. * @param attrs @@ -245,6 +251,10 @@ private static VerticalPositioning getYLayoutStyle(TypedArray attrs, int attr, V return VerticalPositioning.values()[attrs.getInt(attr, defaultValue.ordinal())]; } + private static Widget.Rotation getWidgetRotation(TypedArray attrs, int attr, Widget.Rotation defaultValue) { + return Widget.Rotation.values()[attrs.getInt(attr, defaultValue.ordinal())]; + } + private static Anchor getAnchorPosition(TypedArray attrs, int attr, Anchor defaultValue) { return Anchor.values()[attrs.getInt(attr, defaultValue.ordinal())]; } diff --git a/androidplot-core/src/main/java/com/androidplot/util/SeriesUtils.java b/androidplot-core/src/main/java/com/androidplot/util/SeriesUtils.java index 922e51b9..2b269757 100644 --- a/androidplot-core/src/main/java/com/androidplot/util/SeriesUtils.java +++ b/androidplot-core/src/main/java/com/androidplot/util/SeriesUtils.java @@ -34,6 +34,26 @@ public static RectRegion minMax(XYSeries... seriesList) { return minMax(null, seriesList); } + public static Region minMaxX(XYSeries... seriesList) { + final Region bounds = new Region(); + for (XYSeries series : seriesList) { + for (int i = 0; i < series.size(); i++) { + bounds.union(series.getX(i)); + } + } + return bounds; + } + + public static Region minMaxY(XYSeries... seriesList) { + final Region bounds = new Region(); + for (XYSeries series : seriesList) { + for (int i = 0; i < series.size(); i++) { + bounds.union(series.getY(i)); + } + } + return bounds; + } + /** * @param constraints may be null. * @param seriesList @@ -101,7 +121,6 @@ public static RectRegion minMax(XYConstraints constraints, XYSeries... seriesArr public static Region minMax(Region bounds, List... lists) { for (final List list : lists) { for (final Number i : list) { - //minMax(bounds, i); bounds.union(i); } } diff --git a/androidplot-core/src/main/java/com/androidplot/xy/LineAndPointRenderer.java b/androidplot-core/src/main/java/com/androidplot/xy/LineAndPointRenderer.java index 48d397be..bbe145d4 100644 --- a/androidplot-core/src/main/java/com/androidplot/xy/LineAndPointRenderer.java +++ b/androidplot-core/src/main/java/com/androidplot/xy/LineAndPointRenderer.java @@ -22,6 +22,8 @@ import android.graphics.PointF; import android.graphics.RectF; +import com.androidplot.Plot; +import com.androidplot.PlotListener; import com.androidplot.Region; import com.androidplot.exception.PlotRenderException; import com.androidplot.ui.RenderStack; @@ -29,6 +31,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.concurrent.ConcurrentHashMap; /** * Renders a point as a line with the vertices marked. Requires 2 or more points to @@ -41,8 +44,22 @@ public class LineAndPointRenderer e private final Path path = new Path(); + protected final ConcurrentHashMap> pointsCaches + = new ConcurrentHashMap<>(2, 0.75f, 2); + public LineAndPointRenderer(XYPlot plot) { super(plot); + plot.addListener(new PlotListener() { + @Override + public void onBeforeDraw(Plot source, Canvas canvas) { + cullPointsCache(); + } + + @Override + public void onAfterDraw(Plot source, Canvas canvas) { + + } + }); } @Override @@ -77,17 +94,38 @@ protected void appendToPath(Path path, PointF thisPoint, PointF lastPoint) { path.lineTo(thisPoint.x, thisPoint.y); } - final ArrayList points = new ArrayList<>(); + /** + * Retrieves or initializes a list for storing calculated screen-coords to render as points. + * Also handles automatic resizing and culling of unused caches. + * Should only be called once per render cycle. + * @param series + * @return + */ + protected ArrayList getPointsCache(XYSeries series) { + ArrayList pointsCache = pointsCaches.get(series); + final int seriesSize = series.size(); + if(pointsCache == null) { + pointsCache = new ArrayList<>(seriesSize); + pointsCaches.put(series, pointsCache); + } - // avoids needless new allocations of the points array - protected void resizePointsArray(int newSize) { - if(points.size() < newSize) { - while(points.size() < newSize) { - points.add(null); + if(pointsCache.size() < seriesSize) { + while(pointsCache.size() < seriesSize) { + pointsCache.add(null); } - } else if(points.size() > newSize) { - while(points.size() > newSize) { - points.remove(0); + } else if(pointsCache.size() > seriesSize) { + while(pointsCache.size() > seriesSize) { + pointsCache.remove(0); + } + } + return pointsCache; + } + + protected void cullPointsCache() { + for(XYSeries series : pointsCaches.keySet()) { + if(!getPlot().getRegistry().contains(series, LineAndPointFormatter.class)) { + //pointsCaches.put(series, null); + pointsCaches.remove(series); } } } @@ -96,37 +134,27 @@ protected void drawSeries(Canvas canvas, RectF plotArea, XYSeries series, LineAn PointF thisPoint; PointF lastPoint = null; PointF firstPoint = null; - final int seriesSize = series.size(); path.reset(); - resizePointsArray(seriesSize); + final List points = getPointsCache(series); int iStart = 0; - int iEnd = seriesSize; + int iEnd = series.size(); if(SeriesUtils.getXYOrder(series) == OrderedXYSeries.XOrder.ASCENDING) { final Region iBounds = SeriesUtils.iBounds(series, getPlot().getBounds()); iStart = iBounds.getMin().intValue(); if(iStart > 0) { iStart--; } - iEnd = iBounds.getMax().intValue(); - if(iEnd < seriesSize - 1) { + iEnd = iBounds.getMax().intValue() + 1; + if(iEnd < series.size() - 1) { iEnd++; } } - final double minX = getPlot().getBounds().getMinX().doubleValue(); - final double maxX = getPlot().getBounds().getMaxX().doubleValue(); for (int i = iStart; i < iEnd; i++) { final Number y = series.getY(i); final Number x = series.getX(i); PointF iPoint = points.get(i); - final double dx = x.doubleValue(); - if(i > 0 && i < seriesSize - 1) { - if (dx < minX || dx > maxX) { - continue; - } - } - if (y != null && x != null) { if(iPoint == null) { iPoint = new PointF(); @@ -187,7 +215,7 @@ protected void drawSeries(Canvas canvas, RectF plotArea, XYSeries series, LineAn renderPath(canvas, plotArea, path, firstPoint, lastPoint, formatter); } } - renderPoints(canvas, plotArea, series, points, formatter); + renderPoints(canvas, plotArea, series, iStart, iEnd, points, formatter); } /** @@ -209,16 +237,15 @@ protected PointF convertPoint(XYCoords coord, RectF plotArea) { return getPlot().getBounds().transformScreen(coord, plotArea); } - protected void renderPoints(Canvas canvas, RectF plotArea, XYSeries series, List points, + protected void renderPoints(Canvas canvas, RectF plotArea, XYSeries series, int iStart, int iEnd, List points, LineAndPointFormatter formatter) { - //PointLabelFormatter plf = formatter.getPointLabelFormatter(); if (formatter.hasVertexPaint() || formatter.hasPointLabelFormatter()) { - int i = 0; final Paint vertexPaint = formatter.hasVertexPaint() ? formatter.getVertexPaint() : null; final boolean hasPointLabelFormatter = formatter.hasPointLabelFormatter(); final PointLabelFormatter plf = hasPointLabelFormatter ? formatter.getPointLabelFormatter() : null; final PointLabeler pointLabeler = hasPointLabelFormatter ? formatter.getPointLabeler() : null; - for (PointF p : points) { + for(int i = iStart; i < iEnd; i++) { + PointF p = points.get(i); // if vertexPaint is available, draw vertex: if (vertexPaint != null) { @@ -227,11 +254,9 @@ protected void renderPoints(Canvas canvas, RectF plotArea, XYSeries series, List // if textPaint and pointLabeler are available, draw point's text label: if (pointLabeler != null) { - //final PointLabelFormatter plf = formatter.getPointLabelFormatter(); canvas.drawText(pointLabeler.getLabel(series, i), p.x + plf.hOffset, p.y + plf.vOffset, plf.getTextPaint()); } - i++; } } } diff --git a/androidplot-core/src/main/java/com/androidplot/xy/NormedXYSeries.java b/androidplot-core/src/main/java/com/androidplot/xy/NormedXYSeries.java new file mode 100644 index 00000000..03760b39 --- /dev/null +++ b/androidplot-core/src/main/java/com/androidplot/xy/NormedXYSeries.java @@ -0,0 +1,136 @@ +package com.androidplot.xy; + +import com.androidplot.Region; +import com.androidplot.util.SeriesUtils; + +/** + * Wrapper implementation of {@link XYSeries} that wraps another XYSeries, normalizing values in the range of 0 to 1. + * Note that it's possible to push normed values outside of the standard 0, 1 range by applying + * a sufficiently large offset. + */ +public class NormedXYSeries implements XYSeries { + + private XYSeries rawData; + + private Region minMaxX; + private Region minMaxY; + + private Region transformX; + private Region transformY; + + public static class Norm { + + final Region minMax; + final double offset; + final boolean useOffsetCompression; + + public Norm(Region minMax) { + this(minMax, 0, false); + } + + /** + * + * @param minMax Boundary to use when calculating the norm coefficient. Set to null to let + * Androidplot auto calculate the bounds. (Very inefficient) + * @param offset An extra offset to apply, generally within the range of -1 and 1. + * This value is useful for adjusting the positioning of a series relative to another normalized series. + * @param useOffsetCompression If true, the offset value will result in further scaling down + * of the series data in order to ensure that all points within the specified bounds remain + * visible on the screen. If set to true, the specified offset MUST be > -1 and < 1. Will be + * ignored if bounds != null. + */ + public Norm(Region minMax, double offset, boolean useOffsetCompression) { + this.minMax = minMax; + this.offset = offset; + this.useOffsetCompression = useOffsetCompression; + + if (useOffsetCompression && (offset <= -1 || offset >= 1)) { + throw new IllegalArgumentException( + "When useOffsetCompression is true, offset must be > -1 and < 1."); + } + } + } + + /** + * Normalizes yVals only, auto calculating min/max. + * @param rawData + */ + public NormedXYSeries(XYSeries rawData) { + this(rawData, null, new Norm(null, 0, false)); + } + + /** + * + * @param rawData The XYSeries to be normalized. + * @param x Normalization to apply to xVals. Set to null to disable normalization on the x axis. + * @param y Normalization to apply to yVals. Set to null to disable normalization on the y axis. + */ + public NormedXYSeries(XYSeries rawData, Norm x, Norm y) { + this.rawData = rawData; + normalize(x, y); + } + + protected void normalize(Norm x, Norm y) { + if( x != null) { + this.minMaxX = x.minMax != null ? x.minMax : SeriesUtils.minMaxX(rawData); + this.transformX = calculateTransform(x); + } + + if( y != null) { + this.minMaxY = y.minMax != null ? y.minMax : SeriesUtils.minMaxY(rawData); + this.transformY = calculateTransform(y); + } + } + + protected Region calculateTransform(Norm norm) { + if(norm.useOffsetCompression) { + return new Region( + norm.offset > 0 ? norm.offset : 0, + norm.offset < 0 ? 1 + norm.offset : 1); + } else { + return new Region(0 + norm.offset, 1 + norm.offset); + } + } + + @Override + public String getTitle() { + return rawData.getTitle(); + } + + @Override + public int size() { + return rawData.size(); + } + + public Number denormalizeXVal(Number xVal) { + if(xVal != null) { + return transformX.transform(xVal.doubleValue(), minMaxX); + } + return null; + } + + public Number denormalizeYVal(Number yVal) { + if(yVal != null) { + return transformY.transform(yVal.doubleValue(), minMaxY); + } + return null; + } + + @Override + public Number getX(int index) { + final Number xVal = rawData.getX(index); + if(xVal != null && transformX != null) { + return minMaxX.transform(xVal.doubleValue(), transformX); + } + return xVal; + } + + @Override + public Number getY(int index) { + final Number yVal = rawData.getY(index); + if(yVal != null && transformY != null) { + return minMaxY.transform(yVal.doubleValue(), transformY); + } + return yVal; + } +} diff --git a/androidplot-core/src/main/java/com/androidplot/xy/PanZoom.java b/androidplot-core/src/main/java/com/androidplot/xy/PanZoom.java index 441edb6f..00faa739 100644 --- a/androidplot-core/src/main/java/com/androidplot/xy/PanZoom.java +++ b/androidplot-core/src/main/java/com/androidplot/xy/PanZoom.java @@ -186,12 +186,13 @@ protected void pan(final MotionEvent motionEvent) { final PointF oldFirstFinger = firstFingerPos; //save old position of finger firstFingerPos = new PointF(motionEvent.getX(), motionEvent.getY()); //update finger position - Region newBounds = new Region(); if (EnumSet.of(Pan.HORIZONTAL, Pan.BOTH).contains(pan)) { + Region newBounds = new Region(); calculatePan(oldFirstFinger, newBounds, true); plot.setDomainBoundaries(newBounds.getMin(), newBounds.getMax(), BoundaryMode.FIXED); } if (EnumSet.of(Pan.VERTICAL, Pan.BOTH).contains(pan)) { + Region newBounds = new Region(); calculatePan(oldFirstFinger, newBounds, false); plot.setRangeBoundaries(newBounds.getMin(), newBounds.getMax(), BoundaryMode.FIXED); } @@ -335,9 +336,9 @@ protected void calculateZoom(RectF newRect, float scale, boolean isHorizontal) { if (isHorizontal ) { final RectRegion limits = plot.getOuterLimits(); + newRect.left = midPoint - offset; + newRect.right = midPoint + offset; if(limits.isFullyDefined()) { - newRect.left = midPoint - offset; - newRect.right = midPoint + offset; if (newRect.left < limits.getMinX().floatValue()) { newRect.left = limits.getMinX().floatValue(); } @@ -347,9 +348,9 @@ protected void calculateZoom(RectF newRect, float scale, boolean isHorizontal) { } } else { final RectRegion limits = plot.getOuterLimits(); + newRect.top = midPoint - offset; + newRect.bottom = midPoint + offset; if(limits.isFullyDefined()) { - newRect.top = midPoint - offset; - newRect.bottom = midPoint + offset; if (newRect.top < limits.getMinY().floatValue()) { newRect.top = limits.getMinY().floatValue(); } diff --git a/androidplot-core/src/main/java/com/androidplot/xy/SimpleXYSeries.java b/androidplot-core/src/main/java/com/androidplot/xy/SimpleXYSeries.java index 2032f301..f5ed5458 100644 --- a/androidplot-core/src/main/java/com/androidplot/xy/SimpleXYSeries.java +++ b/androidplot-core/src/main/java/com/androidplot/xy/SimpleXYSeries.java @@ -19,7 +19,6 @@ import android.graphics.Canvas; import com.androidplot.Plot; import com.androidplot.PlotListener; -import com.androidplot.util.*; import java.util.*; import java.util.concurrent.locks.ReentrantReadWriteLock; @@ -28,31 +27,22 @@ /** * A convenience class used to create instances of XYPlot generated from Lists of Numbers. */ -public class SimpleXYSeries implements EditableXYSeries, PlotListener { +public class SimpleXYSeries implements EditableXYSeries, OrderedXYSeries, PlotListener { private static final String TAG = SimpleXYSeries.class.getName(); - @Override - public void onBeforeDraw(Plot source, Canvas canvas) { - lock.readLock().lock(); - } + private volatile LinkedList xVals = new LinkedList<>(); + private volatile LinkedList yVals = new LinkedList<>(); + private volatile String title = null; - @Override - public void onAfterDraw(Plot source, Canvas canvas) { - lock.readLock().unlock(); - } + private ReentrantReadWriteLock lock = new ReentrantReadWriteLock(true); + private XOrder xOrder = XOrder.NONE; public enum ArrayFormat { Y_VALS_ONLY, XY_VALS_INTERLEAVED } - private volatile LinkedList xVals = new LinkedList<>(); - private volatile LinkedList yVals = new LinkedList<>(); - private volatile String title = null; - - private ReentrantReadWriteLock lock = new ReentrantReadWriteLock(true); - public SimpleXYSeries(String title) { this.title = title; } @@ -61,6 +51,36 @@ public SimpleXYSeries(ArrayFormat format, String title, Number... model) { this(asNumberList(model), format, title); } + /** + * Retrieve the current x-ordering specified for this series. Default is + * {@link com.androidplot.xy.OrderedXYSeries.XOrder#NONE}. + * @return + */ + @Override + public XOrder getXOrder() { + return xOrder; + } + + /** + * If XVals are in strict ascending order, use this method to set + * {@link com.androidplot.xy.OrderedXYSeries.XOrder#ASCENDING} to provide an optimization + * hint to the renderer. + * @param xOrder + */ + public void setXOrder(XOrder xOrder) { + this.xOrder = xOrder; + } + + @Override + public void onBeforeDraw(Plot source, Canvas canvas) { + lock.readLock().lock(); + } + + @Override + public void onAfterDraw(Plot source, Canvas canvas) { + lock.readLock().unlock(); + } + protected static List asNumberList(Number... model) { List numbers = new ArrayList<>(); for(Number n : model) { diff --git a/androidplot-core/src/main/java/com/androidplot/xy/XYGraphWidget.java b/androidplot-core/src/main/java/com/androidplot/xy/XYGraphWidget.java index 63a74f5b..b474addc 100644 --- a/androidplot-core/src/main/java/com/androidplot/xy/XYGraphWidget.java +++ b/androidplot-core/src/main/java/com/androidplot/xy/XYGraphWidget.java @@ -355,7 +355,10 @@ public void processAttrs(TypedArray attrs) { R.styleable.xy_XYPlot_rangeTitleVerticalPositioning, R.styleable.xy_XYPlot_rangeTitleVerticalPosition, R.styleable.xy_XYPlot_rangeTitleAnchor, R.styleable.xy_XYPlot_rangeTitleVisible); - // graphWidget + // rotation + AttrUtils.configureWidgetRotation(attrs, this, R.styleable.xy_XYPlot_graphRotation); + + // padding & margin AttrUtils.configureBoxModelable(attrs, this, R.styleable.xy_XYPlot_graphMarginTop, R.styleable.xy_XYPlot_graphMarginBottom, R.styleable.xy_XYPlot_graphMarginLeft, R.styleable.xy_XYPlot_graphMarginRight, diff --git a/androidplot-core/src/main/java/com/androidplot/xy/XYPlot.java b/androidplot-core/src/main/java/com/androidplot/xy/XYPlot.java index 5563f29b..d1230ea1 100644 --- a/androidplot-core/src/main/java/com/androidplot/xy/XYPlot.java +++ b/androidplot-core/src/main/java/com/androidplot/xy/XYPlot.java @@ -93,6 +93,12 @@ public class XYPlot extends Plot + + + + + + + + + @@ -327,6 +336,7 @@ + @@ -383,8 +393,7 @@ - + diff --git a/androidplot-core/src/test/java/com/androidplot/RegionTest.java b/androidplot-core/src/test/java/com/androidplot/RegionTest.java index 1dbc6dc9..c38ded3c 100644 --- a/androidplot-core/src/test/java/com/androidplot/RegionTest.java +++ b/androidplot-core/src/test/java/com/androidplot/RegionTest.java @@ -20,7 +20,7 @@ import org.junit.Before; import org.junit.Test; -import static junit.framework.Assert.fail; +import static junit.framework.Assert.assertNotSame; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; diff --git a/androidplot-core/src/test/java/com/androidplot/SeriesRegistryTest.java b/androidplot-core/src/test/java/com/androidplot/SeriesRegistryTest.java new file mode 100644 index 00000000..7352193f --- /dev/null +++ b/androidplot-core/src/test/java/com/androidplot/SeriesRegistryTest.java @@ -0,0 +1,136 @@ +/* + * Copyright 2015 AndroidPlot.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.androidplot; + +import com.androidplot.test.AndroidplotTest; +import com.androidplot.ui.Formatter; +import com.androidplot.xy.BarFormatter; +import com.androidplot.xy.LineAndPointFormatter; +import com.androidplot.xy.SimpleXYSeries; +import com.androidplot.xy.XYSeriesRegistry; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class SeriesRegistryTest extends AndroidplotTest { + + SeriesRegistry seriesRegistry; + + @Before + public void setUp() throws Exception { + seriesRegistry = new XYSeriesRegistry(); + } + + @After + public void tearDown() throws Exception { + + } + + @Test + public void testAdd() { + assertEquals(0, seriesRegistry.size()); + seriesRegistry.add(new SimpleXYSeries("s1"), new LineAndPointFormatter()); + assertEquals(1, seriesRegistry.size()); + } + + @Test + public void testAdd_failsOnNullArgument() throws Exception { + try { + seriesRegistry.add(null, null); + fail("IllegalArgumentException expected."); + } catch(IllegalArgumentException e) { + // expected + } + + try { + seriesRegistry.add(new SimpleXYSeries("s1"), null); + fail("IllegalArgumentException expected."); + } catch(IllegalArgumentException e) { + // expected + } + + try { + seriesRegistry.add(null, new LineAndPointFormatter()); + fail("IllegalArgumentException expected."); + } catch(IllegalArgumentException e) { + // expected + } + } + + @Test + public void testGet() { + Series s1 = new SimpleXYSeries("s1"); + Formatter f1 = new LineAndPointFormatter(); + Series s2 = new SimpleXYSeries("s2"); + Formatter f2 = new LineAndPointFormatter(); + Formatter f3 = new LineAndPointFormatter(); + seriesRegistry.add(s1, f1); + seriesRegistry.add(s1, f3); + seriesRegistry.add(s2, f2); + + + assertEquals(2, seriesRegistry.get(s1).size()); + assertEquals(1, seriesRegistry.get(s2).size()); + } + + @Test + public void testRemove() { + Series series = new SimpleXYSeries("s1"); + seriesRegistry.add(series, new LineAndPointFormatter()); + assertEquals(1, seriesRegistry.size()); + + seriesRegistry.remove(new SimpleXYSeries("s2")); + assertEquals(1, seriesRegistry.size()); + + seriesRegistry.remove(series); + assertEquals(0, seriesRegistry.size()); + } + + @Test + public void testClear() { + seriesRegistry.add(new SimpleXYSeries("s1"), new LineAndPointFormatter()); + seriesRegistry.add(new SimpleXYSeries("s2"), new LineAndPointFormatter()); + assertEquals(2, seriesRegistry.size()); + + seriesRegistry.clear(); + assertEquals(0, seriesRegistry.size()); + + } + + @Test + public void testContains() { + Series s1 = new SimpleXYSeries("s1"); + Series s2 = new SimpleXYSeries("s1"); + Series s3 = new SimpleXYSeries("s1"); + + seriesRegistry.add(s1, new LineAndPointFormatter()); + seriesRegistry.add(s2, new LineAndPointFormatter()); + + assertTrue(seriesRegistry.contains(s1, LineAndPointFormatter.class)); + assertFalse(seriesRegistry.contains(s1, BarFormatter.class)); + assertTrue(seriesRegistry.contains(s2, LineAndPointFormatter.class)); + assertFalse(seriesRegistry.contains(s3, LineAndPointFormatter.class)); + + } + +} diff --git a/androidplot-core/src/test/java/com/androidplot/test/TestUtils.java b/androidplot-core/src/test/java/com/androidplot/test/TestUtils.java index 93e9143d..ecb78333 100644 --- a/androidplot-core/src/test/java/com/androidplot/test/TestUtils.java +++ b/androidplot-core/src/test/java/com/androidplot/test/TestUtils.java @@ -10,14 +10,26 @@ import static org.robolectric.Shadows.shadowOf; /** - * Created by halfhp on 10/8/16. + * Utilities to help with unit testing */ public abstract class TestUtils { public static XYSeries generateXYSeries(String title, int size) { + return generateXYSeries(title, size, 0, 1); + } + + /** + * Generate a series of random numbers within a min/max range + * @param title + * @param size + * @param min + * @param max + * @return + */ + public static XYSeries generateXYSeries(String title, int size, double min, double max) { SimpleXYSeries series = new SimpleXYSeries(title); for(int i = 0; i < size; i++) { - series.addLast(i, Math.random()); + series.addLast(i, Math.random() * max - min); } return series; } diff --git a/androidplot-core/src/test/java/com/androidplot/xy/LineAndPointRendererTest.java b/androidplot-core/src/test/java/com/androidplot/xy/LineAndPointRendererTest.java index e42eef65..ef67bb36 100644 --- a/androidplot-core/src/test/java/com/androidplot/xy/LineAndPointRendererTest.java +++ b/androidplot-core/src/test/java/com/androidplot/xy/LineAndPointRendererTest.java @@ -21,7 +21,6 @@ import com.androidplot.test.*; import org.junit.*; -import org.junit.runner.*; import org.mockito.*; import java.util.*; @@ -60,12 +59,10 @@ public void testDrawSeries_withInterpolation() throws Exception { * @throws Exception */ @Test - public void testRenderPoints() throws Exception { + public void testDrawSeries() throws Exception { // 100x100 plot space: - RectF plotArea = new RectF(0, 0, 99, 99); - XYPlot plot = new XYPlot(getContext(), "Test"); - + plotArea = new RectF(0, 0, 99, 99); FastLineAndPointRenderer.Formatter formatter = new FastLineAndPointRenderer.Formatter(Color.RED, Color.RED, null); @@ -73,12 +70,12 @@ public void testRenderPoints() throws Exception { XYSeries series = new SimpleXYSeries( SimpleXYSeries.ArrayFormat.Y_VALS_ONLY, "some data", 1, 2, 3, 4, 5, 6, 7, 8, 9, 10); - LineAndPointRenderer renderer = Mockito.spy(new LineAndPointRenderer(plot)); + LineAndPointRenderer renderer = Mockito.spy(new LineAndPointRenderer(xyPlot)); - plot.addSeries(series, formatter); + xyPlot.addSeries(series, formatter); - plot.calculateMinMaxVals(); - renderer.onRender(canvas, plotArea, series, formatter, null); + xyPlot.calculateMinMaxVals(); + renderer.drawSeries(canvas, plotArea, series, formatter); PointF[] expectedPoints = new PointF[] { new PointF(0, 99), @@ -98,6 +95,8 @@ public void testRenderPoints() throws Exception { eq(canvas), eq(plotArea), eq(series), + eq(0), + eq(expectedPoints.length), capturedPoints.capture(), eq(formatter)); @@ -143,4 +142,79 @@ public void testRenderPoints() throws Exception { assertEquals(expectedPoints[9].x, pList.get(9).x); assertEquals(expectedPoints[9].y, pList.get(9).y); } + + @Test + public void testDrawSeries_supportsOrderedXYSeries() throws Exception { + // 100x100 plot space: + //RectF plotArea = new RectF(0, 0, 99, 99); + FastLineAndPointRenderer.Formatter formatter = + new FastLineAndPointRenderer.Formatter(Color.RED, Color.RED, null); + + // create a series composed of 3 "segments"; series portions separated by null values: + SimpleXYSeries series = new SimpleXYSeries( + SimpleXYSeries.ArrayFormat.Y_VALS_ONLY, "some data", 1, 2, 3, 4, 5, 6, 7, 8, 9, 10); + + LineAndPointRenderer renderer = Mockito.spy(new LineAndPointRenderer(xyPlot)); + + xyPlot.addSeries(series, formatter); + + xyPlot.calculateMinMaxVals(); + renderer.drawSeries(canvas, plotArea, series, formatter); + + verify(renderer, times(1)).renderPoints( + eq(canvas), + eq(plotArea), + eq(series), + eq(0), + eq(series.size()), + any(List.class), + eq(formatter)); + + xyPlot.setDomainBoundaries(5, 6, BoundaryMode.FIXED); + series.setXOrder(OrderedXYSeries.XOrder.ASCENDING); + xyPlot.calculateMinMaxVals(); + renderer.drawSeries(canvas, plotArea, series, formatter); + + verify(renderer, times(1)).renderPoints( + eq(canvas), + eq(plotArea), + eq(series), + eq(4), + eq(8), + any(List.class), + eq(formatter)); + + } + + @Test + public void testCullPointsCache() throws Exception { + LineAndPointFormatter formatter = + new LineAndPointFormatter(0, 0, 0, null); + + // create a series composed of 3 "segments"; series portions separated by null values: + SimpleXYSeries series = new SimpleXYSeries( + SimpleXYSeries.ArrayFormat.Y_VALS_ONLY, "some data", 1, 2, 3, 4, 5, 6, 7, 8, 9, 10); + + xyPlot.addSeries(series, formatter); + LineAndPointRenderer renderer = xyPlot.getRenderer(LineAndPointRenderer.class); + + assertEquals(0, renderer.pointsCaches.size()); + + // should generate a new pointCache: + renderer.getPointsCache(series); + assertEquals(1, renderer.pointsCaches.size()); + + // culling should not delete it since it is + // registered in the series registry: + renderer.getPointsCache(series); + assertEquals(1, renderer.pointsCaches.size()); + renderer.cullPointsCache(); + assertEquals(1, renderer.pointsCaches.size()); + + // unregister the series. this time, culling should remove the series + // from the points cache: + xyPlot.removeSeries(series); + renderer.cullPointsCache(); + assertEquals(0, renderer.pointsCaches.size()); + } } diff --git a/androidplot-core/src/test/java/com/androidplot/xy/NormedXYSeriesTest.java b/androidplot-core/src/test/java/com/androidplot/xy/NormedXYSeriesTest.java new file mode 100644 index 00000000..8e8b341d --- /dev/null +++ b/androidplot-core/src/test/java/com/androidplot/xy/NormedXYSeriesTest.java @@ -0,0 +1,87 @@ +package com.androidplot.xy; + +import com.androidplot.test.AndroidplotTest; +import com.androidplot.test.TestUtils; + +import org.junit.Ignore; +import org.junit.Test; + +import static junit.framework.Assert.assertEquals; +import static org.mockito.Mockito.spy; + +/** + * Tests {@link NormedXYSeries}. + */ +public class NormedXYSeriesTest extends AndroidplotTest { + + // account for precision issues inherent in floating point math: + private static final double DELTA = 0.0000001; + + @Test + public void testConstructor_withNoOffset() { + XYSeries rawData = new SimpleXYSeries(SimpleXYSeries.ArrayFormat.Y_VALS_ONLY, "s1", 0, 2, 4, 6, 8, 10); + NormedXYSeries normedData = new NormedXYSeries(rawData, + new NormedXYSeries.Norm(null), + new NormedXYSeries.Norm(null)); + + assertEquals(0d, normedData.getY(0).doubleValue(), DELTA); + assertEquals(0.2d, normedData.getY(1).doubleValue(), DELTA); + assertEquals(0.4d, normedData.getY(2).doubleValue(), DELTA); + assertEquals(0.6d, normedData.getY(3).doubleValue(), DELTA); + assertEquals(0.8d, normedData.getY(4).doubleValue(), DELTA); + assertEquals(1.0d, normedData.getY(5).doubleValue(), DELTA); + } + + @Test + public void testConstructor_withNullYVals() { + XYSeries rawData = new SimpleXYSeries( + SimpleXYSeries.ArrayFormat.Y_VALS_ONLY, + "s1", + 0, null, 4, null, 8, 10); + NormedXYSeries normedData = new NormedXYSeries(rawData, + new NormedXYSeries.Norm(null), + new NormedXYSeries.Norm(null)); + + assertEquals(0d, normedData.getY(0).doubleValue(), DELTA); + assertEquals(0.4d, normedData.getY(2).doubleValue(), DELTA); + assertEquals(0.8d, normedData.getY(4).doubleValue(), DELTA); + assertEquals(1.0d, normedData.getY(5).doubleValue(), DELTA); + } + + @Test + public void testConstructor_withPositiveOffsetAndOffsetCompression() { + XYSeries rawData = new SimpleXYSeries(SimpleXYSeries.ArrayFormat.Y_VALS_ONLY, "s1", 0, 2, 4, 6, 8, 10); + NormedXYSeries normedData = new NormedXYSeries(rawData, + new NormedXYSeries.Norm(null, 0.5, true), + new NormedXYSeries.Norm(null, 0.5, true)); + + assertEquals(0.5d, normedData.getY(0).doubleValue(), DELTA); + assertEquals(0.6d, normedData.getY(1).doubleValue(), DELTA); + assertEquals(1.0d, normedData.getY(5).doubleValue(), DELTA); + } + + @Test + public void testConstructor_withNegativeOffsetAndOffsetCompression() { + XYSeries rawData = new SimpleXYSeries(SimpleXYSeries.ArrayFormat.Y_VALS_ONLY, "s1", 0, 2, 4, 6, 8, 10); + NormedXYSeries normedData = new NormedXYSeries(rawData, + new NormedXYSeries.Norm(null, -0.5, true), + new NormedXYSeries.Norm(null, -0.5, true)); + + assertEquals(0d, normedData.getY(0).doubleValue(), DELTA); + assertEquals(0.1d, normedData.getY(1).doubleValue(), DELTA); + assertEquals(0.2d, normedData.getY(2).doubleValue(), DELTA); + assertEquals(0.5d, normedData.getY(5).doubleValue(), DELTA); + } + + @Test + public void testConstructor_withOffsetAndNoOffsetCompression() { + XYSeries rawData = new SimpleXYSeries(SimpleXYSeries.ArrayFormat.Y_VALS_ONLY, "s1", 0, 2, 4, 6, 8, 10); + NormedXYSeries normedData = new NormedXYSeries(rawData, + new NormedXYSeries.Norm(null, 0.5, false), + new NormedXYSeries.Norm(null, 0.5, false)); + + assertEquals(0.5d, normedData.getY(0).doubleValue(), DELTA); + assertEquals(0.7d, normedData.getY(1).doubleValue(), DELTA); + assertEquals(1.5d, normedData.getY(5).doubleValue(), DELTA); + } +} diff --git a/androidplot-core/src/test/java/com/androidplot/xy/SampledXYSeriesTest.java b/androidplot-core/src/test/java/com/androidplot/xy/SampledXYSeriesTest.java index df07b4f5..95cea342 100644 --- a/androidplot-core/src/test/java/com/androidplot/xy/SampledXYSeriesTest.java +++ b/androidplot-core/src/test/java/com/androidplot/xy/SampledXYSeriesTest.java @@ -9,7 +9,7 @@ import static org.mockito.Mockito.verify; /** - * Created by halfhp on 10/8/16. + * Tests {@link SampledXYSeries} */ public class SampledXYSeriesTest extends AndroidplotTest { diff --git a/build.gradle b/build.gradle index d74e02dc..0cd58cd4 100644 --- a/build.gradle +++ b/build.gradle @@ -27,7 +27,7 @@ ext { theCompileSdkVersion = 24 theTargetSdkVersion = 24 theMinSdkVersion = 5 - theVersionName = '1.3.0' + theVersionName = '1.3.1' theVersionCode = 0 } diff --git a/demoapp/src/main/AndroidManifest.xml b/demoapp/src/main/AndroidManifest.xml index 09d9e761..f7ac2f6f 100644 --- a/demoapp/src/main/AndroidManifest.xml +++ b/demoapp/src/main/AndroidManifest.xml @@ -99,6 +99,7 @@ + + + + + + + \ No newline at end of file diff --git a/demoapp/src/main/res/layout/main.xml b/demoapp/src/main/res/layout/main.xml index 7295e172..82ca3831 100644 --- a/demoapp/src/main/res/layout/main.xml +++ b/demoapp/src/main/res/layout/main.xml @@ -60,6 +60,11 @@ style="@style/toc_button" android:text="Realtime Orientation Sensor Plot"/> +