diff --git a/app/build.gradle b/app/build.gradle index bb72725052..51246830f1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -170,6 +170,7 @@ dependencies { wearApp project(':wear') implementation project(':iconify') + implementation project(':graphview') implementation project(':shared') implementation project(':core') implementation project(':automation') diff --git a/automation/build.gradle b/automation/build.gradle index e1107c676f..f778782309 100644 --- a/automation/build.gradle +++ b/automation/build.gradle @@ -17,4 +17,5 @@ dependencies { implementation project(':core') implementation project(':database') implementation project(':shared') + implementation project(':graphview') } \ No newline at end of file diff --git a/core/build.gradle b/core/build.gradle index f1b51650c2..567918dc1b 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -14,6 +14,7 @@ apply from: "${project.rootDir}/core/jacoco_global.gradle" dependencies { implementation project(':shared') implementation project(':database') + implementation project(':graphview') } android { diff --git a/core/core_dependencies.gradle b/core/core_dependencies.gradle index 320e025b5c..60ef09a37d 100644 --- a/core/core_dependencies.gradle +++ b/core/core_dependencies.gradle @@ -46,9 +46,6 @@ dependencies { api 'com.madgag.spongycastle:core:1.58.0.0' api "com.google.crypto.tink:tink-android:$tink_version" - // Graphview cannot be upgraded - api "com.jjoe64:graphview:4.0.1" - //db api "com.j256.ormlite:ormlite-core:$ormLite_version" api "com.j256.ormlite:ormlite-android:$ormLite_version" diff --git a/graphview/.gitignore b/graphview/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/graphview/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/graphview/build.gradle b/graphview/build.gradle new file mode 100644 index 0000000000..e1138b7d40 --- /dev/null +++ b/graphview/build.gradle @@ -0,0 +1,20 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-kapt' +apply plugin: 'kotlin-allopen' +apply plugin: 'com.hiya.jacoco-android' +apply plugin: 'kotlinx-serialization' + +apply from: "${project.rootDir}/core/android_dependencies.gradle" +apply from: "${project.rootDir}/core/android_module_dependencies.gradle" +apply from: "${project.rootDir}/core/test_dependencies.gradle" +apply from: "${project.rootDir}/core/jacoco_global.gradle" + +android { + + namespace 'com.jjoe64.graphview' +} + +dependencies { + api "androidx.core:core-ktx:$core_version" +} \ No newline at end of file diff --git a/graphview/consumer-rules.pro b/graphview/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/graphview/proguard-rules.pro b/graphview/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/graphview/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/graphview/src/main/AndroidManifest.xml b/graphview/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..a5918e68ab --- /dev/null +++ b/graphview/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/graphview/src/main/java/com/jjoe64/graphview/DefaultLabelFormatter.java b/graphview/src/main/java/com/jjoe64/graphview/DefaultLabelFormatter.java new file mode 100644 index 0000000000..0a761e2d73 --- /dev/null +++ b/graphview/src/main/java/com/jjoe64/graphview/DefaultLabelFormatter.java @@ -0,0 +1,105 @@ +/** + * GraphView + * Copyright (C) 2014 Jonas Gehring + * + * 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 2 of the License, + * with the "Linking Exception", which can be found at the license.txt + * file in this program. + * + * 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 + * with the "Linking Exception" along with this program; if not, + * write to the author Jonas Gehring . + */ +package com.jjoe64.graphview; + +import java.text.NumberFormat; + +/** + * The label formatter that will be used + * by default. + * It will use the NumberFormat from Android + * and sets the maximal fraction digits + * depending on the range between min and max + * value of the current viewport. + * + * It is recommended to use this label formatter + * as base class to implement a custom formatter. + * + * @author jjoe64 + */ +public class DefaultLabelFormatter implements LabelFormatter { + /** + * number formatter for x and y values + */ + protected NumberFormat[] mNumberFormatter = new NumberFormat[2]; + + /** + * reference to the viewport of the + * graph. + * Will be used to calculate the current + * range of values. + */ + protected Viewport mViewport; + + /** + * uses the default number format for the labels + */ + public DefaultLabelFormatter() { + } + + /** + * use custom number format + * + * @param xFormat the number format for the x labels + * @param yFormat the number format for the y labels + */ + public DefaultLabelFormatter(NumberFormat xFormat, NumberFormat yFormat) { + mNumberFormatter[0] = yFormat; + mNumberFormatter[1] = xFormat; + } + + /** + * @param viewport the viewport of the graph + */ + @Override + public void setViewport(Viewport viewport) { + mViewport = viewport; + } + + /** + * Formats the raw value to a nice + * looking label, depending on the + * current range of the viewport. + * + * @param value raw value + * @param isValueX true if it's a x value, otherwise false + * @return the formatted value as string + */ + public String formatLabel(double value, boolean isValueX) { + int i = isValueX ? 1 : 0; + if (mNumberFormatter[i] == null) { + mNumberFormatter[i] = NumberFormat.getNumberInstance(); + double highestvalue = isValueX ? mViewport.getMaxX(false) : mViewport.getMaxY(false); + double lowestvalue = isValueX ? mViewport.getMinX(false) : mViewport.getMinY(false); + if (highestvalue - lowestvalue < 0.1) { + mNumberFormatter[i].setMaximumFractionDigits(6); + } else if (highestvalue - lowestvalue < 1) { + mNumberFormatter[i].setMaximumFractionDigits(4); + } else if (highestvalue - lowestvalue < 20) { + mNumberFormatter[i].setMaximumFractionDigits(3); + } else if (highestvalue - lowestvalue < 100) { + mNumberFormatter[i].setMaximumFractionDigits(1); + } else { + mNumberFormatter[i].setMaximumFractionDigits(0); + } + } + return mNumberFormatter[i].format(value); + } +} diff --git a/graphview/src/main/java/com/jjoe64/graphview/GraphView.java b/graphview/src/main/java/com/jjoe64/graphview/GraphView.java new file mode 100644 index 0000000000..51debe1eef --- /dev/null +++ b/graphview/src/main/java/com/jjoe64/graphview/GraphView.java @@ -0,0 +1,548 @@ +/** + * GraphView + * Copyright (C) 2014 Jonas Gehring + * + * 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 2 of the License, + * with the "Linking Exception", which can be found at the license.txt + * file in this program. + * + * 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 + * with the "Linking Exception" along with this program; if not, + * write to the author Jonas Gehring . + */ +package com.jjoe64.graphview; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Point; +import android.graphics.PointF; +import android.util.AttributeSet; +import android.util.Log; +import android.view.MotionEvent; +import android.view.View; + +import com.jjoe64.graphview.series.Series; + +import java.util.ArrayList; +import java.util.List; + +/** + * @author jjoe64 + * @version 4.0.0 + */ +public class GraphView extends View { + /** + * Class to wrap style options that are general + * to graphs. + * + * @author jjoe64 + */ + private static final class Styles { + /** + * The font size of the title that can be displayed + * above the graph. + * + * @see GraphView#setTitle(String) + */ + float titleTextSize; + + /** + * The font color of the title that can be displayed + * above the graph. + * + * @see GraphView#setTitle(String) + */ + int titleColor; + } + + /** + * Helper class to detect tap events on the + * graph. + * + * @author jjoe64 + */ + private class TapDetector { + /** + * save the time of the last down event + */ + private long lastDown; + + /** + * point of the tap down event + */ + private PointF lastPoint; + + /** + * to be called to process the events + * + * @param event + * @return true if there was a tap event. otherwise returns false. + */ + public boolean onTouchEvent(MotionEvent event) { + if (event.getAction() == MotionEvent.ACTION_DOWN) { + lastDown = System.currentTimeMillis(); + lastPoint = new PointF(event.getX(), event.getY()); + } else if (lastDown > 0 && event.getAction() == MotionEvent.ACTION_MOVE) { + if (Math.abs(event.getX() - lastPoint.x) > 60 + || Math.abs(event.getY() - lastPoint.y) > 60) { + lastDown = 0; + } + } else if (event.getAction() == MotionEvent.ACTION_UP) { + if (System.currentTimeMillis() - lastDown < 400) { + return true; + } + } + return false; + } + } + + /** + * our series (this does not contain the series + * that can be displayed on the right side. The + * right side series is a special feature of + * the {@link SecondScale} feature. + */ + private List mSeries; + + /** + * the renderer for the grid and labels + */ + private GridLabelRenderer mGridLabelRenderer; + + /** + * viewport that holds the current bounds of + * view. + */ + private Viewport mViewport; + + /** + * title of the graph that will be shown above + */ + private String mTitle; + + /** + * wraps the general styles + */ + private Styles mStyles; + + /** + * feature to have a second scale e.g. on the + * right side + */ + protected SecondScale mSecondScale; + + /** + * tap detector + */ + private TapDetector mTapDetector; + + /** + * renderer for the legend + */ + private LegendRenderer mLegendRenderer; + + /** + * paint for the graph title + */ + private Paint mPaintTitle; + + /** + * paint for the preview (in the SDK) + */ + private Paint mPreviewPaint; + + /** + * Initialize the GraphView view + * @param context + */ + public GraphView(Context context) { + super(context); + init(); + } + + /** + * Initialize the GraphView view. + * + * @param context + * @param attrs + */ + public GraphView(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + /** + * Initialize the GraphView view + * + * @param context + * @param attrs + * @param defStyle + */ + public GraphView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + init(); + } + + /** + * initialize the internal objects. + * This method has to be called directly + * in the constructors. + */ + protected void init() { + mPreviewPaint = new Paint(); + mPreviewPaint.setTextAlign(Paint.Align.CENTER); + mPreviewPaint.setColor(Color.BLACK); + mPreviewPaint.setTextSize(50); + + mStyles = new Styles(); + mViewport = new Viewport(this); + mGridLabelRenderer = new GridLabelRenderer(this); + mLegendRenderer = new LegendRenderer(this); + + mSeries = new ArrayList(); + mPaintTitle = new Paint(); + + mTapDetector = new TapDetector(); + + loadStyles(); + } + + /** + * loads the font + */ + protected void loadStyles() { + mStyles.titleColor = mGridLabelRenderer.getHorizontalLabelsColor(); + mStyles.titleTextSize = mGridLabelRenderer.getTextSize(); + } + + /** + * @return the renderer for the grid and labels + */ + public GridLabelRenderer getGridLabelRenderer() { + return mGridLabelRenderer; + } + + /** + * Add a new series to the graph. This will + * automatically redraw the graph. + * @param s the series to be added + */ + public void addSeries(Series s) { + s.onGraphViewAttached(this); + mSeries.add(s); + onDataChanged(false, false); + } + + /** + * important: do not do modifications on the list + * object that will be returned. + * Use {@link #removeSeries(com.jjoe64.graphview.series.Series)} and {@link #addSeries(com.jjoe64.graphview.series.Series)} + * + * @return all series + */ + public List getSeries() { + // TODO immutable array + return mSeries; + } + + /** + * call this to let the graph redraw and + * recalculate the viewport. + * This will be called when a new series + * was added or removed and when data + * was appended via {@link com.jjoe64.graphview.series.BaseSeries#appendData(com.jjoe64.graphview.series.DataPointInterface, boolean, int)} + * or {@link com.jjoe64.graphview.series.BaseSeries#resetData(com.jjoe64.graphview.series.DataPointInterface[])}. + * + * @param keepLabelsSize true if you don't want + * to recalculate the size of + * the labels. It is recommended + * to use "true" because this will + * improve performance and prevent + * a flickering. + * @param keepViewport true if you don't want that + * the viewport will be recalculated. + * It is recommended to use "true" for + * performance. + */ + public void onDataChanged(boolean keepLabelsSize, boolean keepViewport) { + // adjust grid system + mViewport.calcCompleteRange(); + mGridLabelRenderer.invalidate(keepLabelsSize, keepViewport); + invalidate(); + } + + /** + * will be called from Android system. + * + * @param canvas Canvas + */ + @Override + protected void onDraw(Canvas canvas) { + if (isInEditMode()) { + canvas.drawColor(Color.rgb(200, 200, 200)); + canvas.drawText("GraphView: No Preview available", canvas.getWidth()/2, canvas.getHeight()/2, mPreviewPaint); + } else { + drawTitle(canvas); + mViewport.drawFirst(canvas); + mGridLabelRenderer.draw(canvas); + for (Series s : mSeries) { + s.draw(this, canvas, false); + } + if (mSecondScale != null) { + for (Series s : mSecondScale.getSeries()) { + s.draw(this, canvas, true); + } + } + mViewport.draw(canvas); + mLegendRenderer.draw(canvas); + } + } + + /** + * Draws the Graphs title that will be + * shown above the viewport. + * Will be called by GraphView. + * + * @param canvas Canvas + */ + protected void drawTitle(Canvas canvas) { + if (mTitle != null && mTitle.length()>0) { + mPaintTitle.setColor(mStyles.titleColor); + mPaintTitle.setTextSize(mStyles.titleTextSize); + mPaintTitle.setTextAlign(Paint.Align.CENTER); + float x = canvas.getWidth()/2; + float y = mPaintTitle.getTextSize(); + canvas.drawText(mTitle, x, y, mPaintTitle); + } + } + + /** + * Calculates the height of the title. + * + * @return the actual size of the title. + * if there is no title, 0 will be + * returned. + */ + protected int getTitleHeight() { + if (mTitle != null && mTitle.length()>0) { + return (int) mPaintTitle.getTextSize(); + } else { + return 0; + } + } + + /** + * @return the viewport of the Graph. + * @see com.jjoe64.graphview.Viewport + */ + public Viewport getViewport() { + return mViewport; + } + + /** + * Called by Android system if the size + * of the view was changed. Will recalculate + * the viewport and labels. + * + * @param w + * @param h + * @param oldw + * @param oldh + */ + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + onDataChanged(false, false); + } + + /** + * @return the space on the left side of the + * view from the left border to the + * beginning of the graph viewport. + */ + public int getGraphContentLeft() { + int border = getGridLabelRenderer().getStyles().padding; + return border + getGridLabelRenderer().getLabelVerticalWidth() + getGridLabelRenderer().getVerticalAxisTitleWidth(); + } + + /** + * @return the space on the top of the + * view from the top border to the + * beginning of the graph viewport. + */ + public int getGraphContentTop() { + int border = getGridLabelRenderer().getStyles().padding + getTitleHeight(); + return border; + } + + /** + * @return the height of the graph viewport. + */ + public int getGraphContentHeight() { + int border = getGridLabelRenderer().getStyles().padding; + int graphheight = getHeight() - (2 * border) - getGridLabelRenderer().getLabelHorizontalHeight() - getTitleHeight(); + graphheight -= getGridLabelRenderer().getHorizontalAxisTitleHeight(); + return graphheight; + } + + /** + * @return the width of the graph viewport. + */ + public int getGraphContentWidth() { + int border = getGridLabelRenderer().getStyles().padding; + int graphwidth = getWidth() - (2 * border) - getGridLabelRenderer().getLabelVerticalWidth(); + if (mSecondScale != null) { + graphwidth -= getGridLabelRenderer().getLabelVerticalSecondScaleWidth(); + } + return graphwidth; + } + + /** + * will be called from Android system. + * + * @param event + * @return + */ + @Override + public boolean onTouchEvent(MotionEvent event) { + boolean b = mViewport.onTouchEvent(event); + boolean a = super.onTouchEvent(event); + + // is it a click? + if (mTapDetector.onTouchEvent(event)) { + for (Series s : mSeries) { + s.onTap(event.getX(), event.getY()); + } + if (mSecondScale != null) { + for (Series s : mSecondScale.getSeries()) { + s.onTap(event.getX(), event.getY()); + } + } + } + + return b || a; + } + + /** + * + */ + @Override + public void computeScroll() { + super.computeScroll(); + mViewport.computeScroll(); + } + + /** + * @return the legend renderer. + * @see com.jjoe64.graphview.LegendRenderer + */ + public LegendRenderer getLegendRenderer() { + return mLegendRenderer; + } + + /** + * use a specific legend renderer + * + * @param mLegendRenderer the new legend renderer + */ + public void setLegendRenderer(LegendRenderer mLegendRenderer) { + this.mLegendRenderer = mLegendRenderer; + } + + /** + * @return the title that will be shown + * above the graph. + */ + public String getTitle() { + return mTitle; + } + + /** + * Set the title of the graph that will + * be shown above the graph's viewport. + * + * @param mTitle the title + * @see #setTitleColor(int) to set the font color + * @see #setTitleTextSize(float) to set the font size + */ + public void setTitle(String mTitle) { + this.mTitle = mTitle; + } + + /** + * @return the title font size + */ + public float getTitleTextSize() { + return mStyles.titleTextSize; + } + + /** + * Set the title's font size + * + * @param titleTextSize font size + * @see #setTitle(String) + */ + public void setTitleTextSize(float titleTextSize) { + mStyles.titleTextSize = titleTextSize; + } + + /** + * @return font color of the title + */ + public int getTitleColor() { + return mStyles.titleColor; + } + + /** + * Set the title's font color + * + * @param titleColor font color of the title + * @see #setTitle(String) + */ + public void setTitleColor(int titleColor) { + mStyles.titleColor = titleColor; + } + + /** + * + * @return + */ + public SecondScale getSecondScale() { + if (mSecondScale == null) { + mSecondScale = new SecondScale(mViewport); + } + return mSecondScale; + } + + /** + * Removes all series of the graph. + */ + public void removeAllSeries() { + mSeries.clear(); + onDataChanged(false, false); + } + + /** + * Remove a specific series of the graph. + * This will also re-render the graph, but + * without recalculating the viewport and + * label sizes. + * If you want this, you have to call {@link #onDataChanged(boolean, boolean)} + * manually. + * + * @param series + */ + public void removeSeries(Series series) { + mSeries.remove(series); + onDataChanged(false, false); + } +} diff --git a/graphview/src/main/java/com/jjoe64/graphview/GridLabelRenderer.java b/graphview/src/main/java/com/jjoe64/graphview/GridLabelRenderer.java new file mode 100644 index 0000000000..e6060c9b50 --- /dev/null +++ b/graphview/src/main/java/com/jjoe64/graphview/GridLabelRenderer.java @@ -0,0 +1,1467 @@ +/** + * GraphView + * Copyright (C) 2014 Jonas Gehring + * + * 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 2 of the License, + * with the "Linking Exception", which can be found at the license.txt + * file in this program. + * + * 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 + * with the "Linking Exception" along with this program; if not, + * write to the author Jonas Gehring . + */ +package com.jjoe64.graphview; + +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Rect; +import android.util.TypedValue; + +import androidx.core.view.ViewCompat; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * The default renderer for the grid + * and the labels. + * + * @author jjoe64 + */ +public class GridLabelRenderer { + /** + * wrapper for the styles regarding + * to the grid and the labels + */ + public final class Styles { + /** + * the general text size of the axis titles. + * can be overwritten with #verticalAxisTitleTextSize + * and #horizontalAxisTitleTextSize + */ + public float textSize; + + /** + * the alignment of the vertical labels + */ + public Paint.Align verticalLabelsAlign; + + /** + * the alignment of the labels on the right side + */ + public Paint.Align verticalLabelsSecondScaleAlign; + + /** + * the color of the vertical labels + */ + public int verticalLabelsColor; + + /** + * the color of the labels on the right side + */ + public int verticalLabelsSecondScaleColor; + + /** + * the color of the horizontal labels + */ + public int horizontalLabelsColor; + + /** + * the color of the grid lines + */ + public int gridColor; + + /** + * flag whether the zero-lines (vertical+ + * horizontal) shall be highlighted + */ + public boolean highlightZeroLines; + + /** + * the padding around the graph and labels + */ + public int padding; + + /** + * font size of the vertical axis title + */ + public float verticalAxisTitleTextSize; + + /** + * font color of the vertical axis title + */ + public int verticalAxisTitleColor; + + /** + * font size of the horizontal axis title + */ + public float horizontalAxisTitleTextSize; + + /** + * font color of the horizontal axis title + */ + public int horizontalAxisTitleColor; + + /** + * flag whether the horizontal labels are + * visible + */ + boolean horizontalLabelsVisible; + + /** + * flag whether the vertical labels are + * visible + */ + boolean verticalLabelsVisible; + + /** + * defines which lines will be drawn in the background + */ + GridStyle gridStyle; + + /** + * the space between the labels text and the graph content + */ + int labelsSpace; + } + + /** + * Definition which lines will be drawn in the background + */ + public enum GridStyle { + BOTH, VERTICAL, HORIZONTAL, NONE; + + public boolean drawVertical() { return this == BOTH || this == VERTICAL && this != NONE; } + public boolean drawHorizontal() { return this == BOTH || this == HORIZONTAL && this != NONE; } + } + + /** + * wraps the styles regarding the + * grid and labels + */ + protected Styles mStyles; + + /** + * reference to graphview + */ + private final GraphView mGraphView; + + /** + * cache of the vertical steps + * (horizontal lines and vertical labels) + * Key = Pixel (y) + * Value = y-value + */ + private Map mStepsVertical; + + /** + * cache of the vertical steps for the + * second scale, which is on the right side + * (horizontal lines and vertical labels) + * Key = Pixel (y) + * Value = y-value + */ + private Map mStepsVerticalSecondScale; + + /** + * cache of the horizontal steps + * (vertical lines and horizontal labels) + * Key = Pixel (x) + * Value = x-value + */ + private Map mStepsHorizontal; + + /** + * the paint to draw the grid lines + */ + private Paint mPaintLine; + + /** + * the paint to draw the labels + */ + private Paint mPaintLabel; + + /** + * the paint to draw axis titles + */ + private Paint mPaintAxisTitle; + + /** + * flag whether is bounds are automatically + * adjusted for nice human-readable numbers + */ + private boolean mIsAdjusted; + + /** + * the width of the vertical labels + */ + private Integer mLabelVerticalWidth; + + /** + * indicates if the width was set manually + */ + private boolean mLabelVerticalWidthFixed; + + /** + * the height of the vertical labels + */ + private Integer mLabelVerticalHeight; + + /** + * indicates if the height was set manually + */ + private boolean mLabelHorizontalHeightFixed; + + /** + * the width of the vertical labels + * of the second scale + */ + private Integer mLabelVerticalSecondScaleWidth; + + /** + * the height of the vertical labels + * of the second scale + */ + private Integer mLabelVerticalSecondScaleHeight; + + /** + * the width of the horizontal labels + */ + private Integer mLabelHorizontalWidth; + + /** + * the height of the horizontal labels + */ + private Integer mLabelHorizontalHeight; + + /** + * the label formatter, that converts + * the raw numbers to strings + */ + private LabelFormatter mLabelFormatter; + + /** + * the title of the horizontal axis + */ + private String mHorizontalAxisTitle; + + /** + * the title of the vertical axis + */ + private String mVerticalAxisTitle; + + /** + * count of the vertical labels, that + * will be shown at one time. + */ + private int mNumVerticalLabels; + + /** + * count of the horizontal labels, that + * will be shown at one time. + */ + private int mNumHorizontalLabels; + + /** + * create the default grid label renderer. + * + * @param graphView the corresponding graphview object + */ + public GridLabelRenderer(GraphView graphView) { + mGraphView = graphView; + setLabelFormatter(new DefaultLabelFormatter()); + mStyles = new Styles(); + resetStyles(); + mNumVerticalLabels = 5; + mNumHorizontalLabels = 5; + } + + /** + * resets the styles. This loads the style + * from reading the values of the current + * theme. + */ + public void resetStyles() { + // get matching styles from theme + TypedValue typedValue = new TypedValue(); + mGraphView.getContext().getTheme().resolveAttribute(android.R.attr.textAppearanceSmall, typedValue, true); + + int color1; + int color2; + int size; + int size2; + + TypedArray array = null; + try { + array = mGraphView.getContext().obtainStyledAttributes(typedValue.data, new int[]{ + android.R.attr.textColorPrimary + , android.R.attr.textColorSecondary + , android.R.attr.textSize + , android.R.attr.horizontalGap}); + color1 = array.getColor(0, Color.BLACK); + color2 = array.getColor(1, Color.GRAY); + size = array.getDimensionPixelSize(2, 20); + size2 = array.getDimensionPixelSize(3, 20); + array.recycle(); + } catch (Exception e) { + color1 = Color.BLACK; + color2 = Color.GRAY; + size = 20; + size2 = 20; + } + + mStyles.verticalLabelsColor = color1; + mStyles.verticalLabelsSecondScaleColor = color1; + mStyles.horizontalLabelsColor = color1; + mStyles.gridColor = color2; + mStyles.textSize = size; + mStyles.padding = size2; + mStyles.labelsSpace = (int) mStyles.textSize/5; + + mStyles.verticalLabelsAlign = Paint.Align.RIGHT; + mStyles.verticalLabelsSecondScaleAlign = Paint.Align.LEFT; + mStyles.highlightZeroLines = true; + + mStyles.verticalAxisTitleColor = mStyles.verticalLabelsColor; + mStyles.horizontalAxisTitleColor = mStyles.horizontalLabelsColor; + mStyles.verticalAxisTitleTextSize = mStyles.textSize; + mStyles.horizontalAxisTitleTextSize = mStyles.textSize; + + mStyles.horizontalLabelsVisible = true; + mStyles.verticalLabelsVisible = true; + + mStyles.gridStyle = GridStyle.BOTH; + + reloadStyles(); + } + + /** + * will load the styles to the internal + * paint objects (color, text size, text align) + */ + public void reloadStyles() { + mPaintLine = new Paint(); + mPaintLine.setColor(mStyles.gridColor); + mPaintLine.setStrokeWidth(0); + + mPaintLabel = new Paint(); + mPaintLabel.setTextSize(getTextSize()); + + mPaintAxisTitle = new Paint(); + mPaintAxisTitle.setTextSize(getTextSize()); + mPaintAxisTitle.setTextAlign(Paint.Align.CENTER); + } + + /** + * @return the general text size for the axis titles + */ + public float getTextSize() { + return mStyles.textSize; + } + + /** + * @return the font color of the vertical labels + */ + public int getVerticalLabelsColor() { + return mStyles.verticalLabelsColor; + } + + /** + * @return the alignment of the text of the + * vertical labels + */ + public Paint.Align getVerticalLabelsAlign() { + return mStyles.verticalLabelsAlign; + } + + /** + * @return the font color of the horizontal labels + */ + public int getHorizontalLabelsColor() { + return mStyles.horizontalLabelsColor; + } + + /** + * clears the internal cache and forces + * to redraw the grid and labels. + * Normally you should always call {@link GraphView#onDataChanged(boolean, boolean)} + * which will call this method. + * + * @param keepLabelsSize true if you don't want + * to recalculate the size of + * the labels. It is recommended + * to use "true" because this will + * improve performance and prevent + * a flickering. + * @param keepViewport true if you don't want that + * the viewport will be recalculated. + * It is recommended to use "true" for + * performance. + */ + public void invalidate(boolean keepLabelsSize, boolean keepViewport) { + if (!keepViewport) { + mIsAdjusted = false; + } + if (!keepLabelsSize) { + if (!mLabelVerticalWidthFixed) { + mLabelVerticalWidth = null; + } + mLabelVerticalHeight = null; + mLabelVerticalSecondScaleWidth = null; + mLabelVerticalSecondScaleHeight = null; + } + //reloadStyles(); + } + + /** + * calculates the vertical steps of + * the second scale. + * This will not do any automatically update + * of the bounds. + * Use always manual bounds for the second scale. + * + * @return true if it is ready + */ + protected boolean adjustVerticalSecondScale() { + if (mLabelHorizontalHeight == null) { + return false; + } + if (mGraphView.mSecondScale == null) { + return true; + } + + double minY = mGraphView.mSecondScale.getMinY(); + double maxY = mGraphView.mSecondScale.getMaxY(); + + // TODO find the number of labels + int numVerticalLabels = mNumVerticalLabels; + + double newMinY; + double exactSteps; + + if (mGraphView.mSecondScale.isYAxisBoundsManual()) { + newMinY = minY; + double rangeY = maxY - newMinY; + exactSteps = rangeY / (numVerticalLabels - 1); + } else { + // TODO auto adjusting + throw new IllegalStateException("Not yet implemented"); + } + + double newMaxY = newMinY + (numVerticalLabels - 1) * exactSteps; + + // TODO auto adjusting + //mGraphView.getViewport().setMinY(newMinY); + //mGraphView.getViewport().setMaxY(newMaxY); + + //if (!mGraphView.getViewport().isYAxisBoundsManual()) { + // mGraphView.getViewport().setYAxisBoundsStatus(Viewport.AxisBoundsStatus.AUTO_ADJUSTED); + //} + + if (mStepsVerticalSecondScale != null) { + mStepsVerticalSecondScale.clear(); + } else { + mStepsVerticalSecondScale = new LinkedHashMap(numVerticalLabels); + } + int height = mGraphView.getGraphContentHeight(); + double v = newMaxY; + int p = mGraphView.getGraphContentTop(); // start + int pixelStep = height / (numVerticalLabels - 1); + for (int i = 0; i < numVerticalLabels; i++) { + mStepsVerticalSecondScale.put(p, v); + p += pixelStep; + v -= exactSteps; + } + + return true; + } + + /** + * calculates the vertical steps. This will + * automatically change the bounds to nice + * human-readable min/max. + * + * @return true if it is ready + */ + protected boolean adjustVertical() { + if (mLabelHorizontalHeight == null) { + return false; + } + + double minY = mGraphView.getViewport().getMinY(false); + double maxY = mGraphView.getViewport().getMaxY(false); + + if (minY == maxY) { + return false; + } + + // TODO find the number of labels + int numVerticalLabels = mNumVerticalLabels; + + double newMinY; + double exactSteps; + + if (mGraphView.getViewport().isYAxisBoundsManual()) { + newMinY = minY; + double rangeY = maxY - newMinY; + exactSteps = rangeY / (numVerticalLabels - 1); + } else { + // find good steps + boolean adjusting = true; + newMinY = minY; + exactSteps = 0d; + while (adjusting) { + double rangeY = maxY - newMinY; + exactSteps = rangeY / (numVerticalLabels - 1); + exactSteps = humanRound(exactSteps, true); + + // adjust viewport + // wie oft passt STEP in minY rein? + int count = 0; + if (newMinY >= 0d) { + // positive number + while (newMinY - exactSteps >= 0) { + newMinY -= exactSteps; + count++; + } + newMinY = exactSteps * count; + } else { + // negative number + count++; + while (newMinY + exactSteps < 0) { + newMinY += exactSteps; + count++; + } + newMinY = exactSteps * count * -1; + } + + // wenn minY sich geändert hat, steps nochmal berechnen + // wenn nicht, fertig + if (newMinY == minY) { + adjusting = false; + } else { + minY = newMinY; + } + } + } + + double newMaxY = newMinY + (numVerticalLabels - 1) * exactSteps; + mGraphView.getViewport().setMinY(newMinY); + mGraphView.getViewport().setMaxY(newMaxY); + + if (!mGraphView.getViewport().isYAxisBoundsManual()) { + mGraphView.getViewport().setYAxisBoundsStatus(Viewport.AxisBoundsStatus.AUTO_ADJUSTED); + } + + if (mStepsVertical != null) { + mStepsVertical.clear(); + } else { + mStepsVertical = new LinkedHashMap(numVerticalLabels); + } + int height = mGraphView.getGraphContentHeight(); + double v = newMaxY; + int p = mGraphView.getGraphContentTop(); // start + int pixelStep = height / (numVerticalLabels - 1); + for (int i = 0; i < numVerticalLabels; i++) { + mStepsVertical.put(p, v); + p += pixelStep; + v -= exactSteps; + } + + return true; + } + + /** + * calculates the horizontal steps. This will + * automatically change the bounds to nice + * human-readable min/max. + * + * @return true if it is ready + */ + protected boolean adjustHorizontal() { + if (mLabelVerticalWidth == null) { + return false; + } + + double minX = mGraphView.getViewport().getMinX(false); + double maxX = mGraphView.getViewport().getMaxX(false); + if (minX == maxX) return false; + + // TODO find the number of labels + int numHorizontalLabels = mNumHorizontalLabels; + + double newMinX; + double exactSteps; + + float scalingOffset = 0f; + if (mGraphView.getViewport().isXAxisBoundsManual() && mGraphView.getViewport().getXAxisBoundsStatus() != Viewport.AxisBoundsStatus.READJUST_AFTER_SCALE) { + // scaling + if (mGraphView.getViewport().mScalingActive) { + minX = mGraphView.getViewport().mScalingBeginLeft; + maxX = minX + mGraphView.getViewport().mScalingBeginWidth; + + //numHorizontalLabels *= (mGraphView.getViewport().mCurrentViewport.width()+oldStep)/(mGraphView.getViewport().mScalingBeginWidth+oldStep); + //numHorizontalLabels = (float) Math.ceil(numHorizontalLabels); + } + + newMinX = minX; + double rangeX = maxX - newMinX; + exactSteps = rangeX / (numHorizontalLabels - 1); + } else { + // find good steps + boolean adjusting = true; + newMinX = minX; + exactSteps = 0d; + while (adjusting) { + double rangeX = maxX - newMinX; + exactSteps = rangeX / (numHorizontalLabels - 1); + + boolean roundAlwaysUp = true; + if (mGraphView.getViewport().getXAxisBoundsStatus() == Viewport.AxisBoundsStatus.READJUST_AFTER_SCALE) { + // if viewports gets smaller, round down + if (mGraphView.getViewport().mCurrentViewport.width() < mGraphView.getViewport().mScalingBeginWidth) { + roundAlwaysUp = false; + } + } + exactSteps = humanRound(exactSteps, roundAlwaysUp); + + // adjust viewport + // wie oft passt STEP in minX rein? + int count = 0; + if (newMinX >= 0d) { + // positive number + while (newMinX - exactSteps >= 0) { + newMinX -= exactSteps; + count++; + } + newMinX = exactSteps * count; + } else { + // negative number + count++; + while (newMinX + exactSteps < 0) { + newMinX += exactSteps; + count++; + } + newMinX = exactSteps * count * -1; + } + + // wenn minX sich geändert hat, steps nochmal berechnen + // wenn nicht, fertig + if (newMinX == minX) { + adjusting = false; + } else { + minX = newMinX; + } + } + + double newMaxX = newMinX + (numHorizontalLabels - 1) * exactSteps; + mGraphView.getViewport().setMinX(newMinX); + mGraphView.getViewport().setMaxX(newMaxX); + if (mGraphView.getViewport().getXAxisBoundsStatus() == Viewport.AxisBoundsStatus.READJUST_AFTER_SCALE) { + mGraphView.getViewport().setXAxisBoundsStatus(Viewport.AxisBoundsStatus.FIX); + } else { + mGraphView.getViewport().setXAxisBoundsStatus(Viewport.AxisBoundsStatus.AUTO_ADJUSTED); + } + } + + if (mStepsHorizontal != null) { + mStepsHorizontal.clear(); + } else { + mStepsHorizontal = new LinkedHashMap((int) numHorizontalLabels); + } + int width = mGraphView.getGraphContentWidth(); + + float scrolled = 0; + float scrolledPixels = 0; + + double v = newMinX; + int p = mGraphView.getGraphContentLeft(); // start + float pixelStep = width / (numHorizontalLabels - 1); + + if (mGraphView.getViewport().mScalingActive) { + float oldStep = mGraphView.getViewport().mScalingBeginWidth / (numHorizontalLabels - 1); + float factor = (mGraphView.getViewport().mCurrentViewport.width() + oldStep) / (mGraphView.getViewport().mScalingBeginWidth + oldStep); + pixelStep *= 1f / factor; + + //numHorizontalLabels *= (mGraphView.getViewport().mCurrentViewport.width()+oldStep)/(mGraphView.getViewport().mScalingBeginWidth+oldStep); + //numHorizontalLabels = (float) Math.ceil(numHorizontalLabels); + + //scrolled = ((float) mGraphView.getViewport().getMinX(false) - mGraphView.getViewport().mScalingBeginLeft)*2; + float newWidth = width * 1f / factor; + scrolledPixels = (newWidth - width) * -0.5f; + + } + + // scrolling + if (!Float.isNaN(mGraphView.getViewport().mScrollingReferenceX)) { + scrolled = mGraphView.getViewport().mScrollingReferenceX - (float) newMinX; + scrolledPixels += scrolled * (pixelStep / (float) exactSteps); + + if (scrolled < 0 - exactSteps) { + mGraphView.getViewport().mScrollingReferenceX += exactSteps; + } else if (scrolled > exactSteps) { + mGraphView.getViewport().mScrollingReferenceX -= exactSteps; + } + } + p += scrolledPixels; + v += scrolled; + + for (int i = 0; i < numHorizontalLabels; i++) { + // don't draw steps before 0 (scrolling) + if (p >= mGraphView.getGraphContentLeft()) { + mStepsHorizontal.put(p, v); + } + p += pixelStep; + v += exactSteps; + } + + return true; + } + + /** + * adjusts the grid and labels to match to the data + * this will automatically change the bounds to + * nice human-readable values, except the bounds + * are manual. + */ + protected void adjust() { + mIsAdjusted = adjustVertical(); + mIsAdjusted &= adjustVerticalSecondScale(); + mIsAdjusted &= adjustHorizontal(); + } + + /** + * calculates the vertical label size + * @param canvas canvas + */ + protected void calcLabelVerticalSize(Canvas canvas) { + // test label with first and last label + String testLabel = mLabelFormatter.formatLabel(mGraphView.getViewport().getMaxY(false), false); + if (testLabel == null) testLabel = ""; + + Rect textBounds = new Rect(); + mPaintLabel.getTextBounds(testLabel, 0, testLabel.length(), textBounds); + mLabelVerticalWidth = textBounds.width(); + mLabelVerticalHeight = textBounds.height(); + + testLabel = mLabelFormatter.formatLabel(mGraphView.getViewport().getMinY(false), false); + if (testLabel == null) testLabel = ""; + + mPaintLabel.getTextBounds(testLabel, 0, testLabel.length(), textBounds); + mLabelVerticalWidth = Math.max(mLabelVerticalWidth, textBounds.width()); + + // add some pixel to get a margin + mLabelVerticalWidth += 6; + + // space between text and graph content + mLabelVerticalWidth += mStyles.labelsSpace; + + // multiline + int lines = 1; + for (byte c : testLabel.getBytes()) { + if (c == '\n') lines++; + } + mLabelVerticalHeight *= lines; + } + + /** + * calculates the vertical second scale + * label size + * @param canvas canvas + */ + protected void calcLabelVerticalSecondScaleSize(Canvas canvas) { + if (mGraphView.mSecondScale == null) { + mLabelVerticalSecondScaleWidth = 0; + mLabelVerticalSecondScaleHeight = 0; + return; + } + + // test label + double testY = ((mGraphView.mSecondScale.getMaxY() - mGraphView.mSecondScale.getMinY()) * 0.783) + mGraphView.mSecondScale.getMinY(); + String testLabel = mGraphView.mSecondScale.getLabelFormatter().formatLabel(testY, false); + Rect textBounds = new Rect(); + mPaintLabel.getTextBounds(testLabel, 0, testLabel.length(), textBounds); + mLabelVerticalSecondScaleWidth = textBounds.width(); + mLabelVerticalSecondScaleHeight = textBounds.height(); + + // multiline + int lines = 1; + for (byte c : testLabel.getBytes()) { + if (c == '\n') lines++; + } + mLabelVerticalSecondScaleHeight *= lines; + } + + /** + * calculates the horizontal label size + * @param canvas canvas + */ + protected void calcLabelHorizontalSize(Canvas canvas) { + // test label + double testX = ((mGraphView.getViewport().getMaxX(false) - mGraphView.getViewport().getMinX(false)) * 0.783) + mGraphView.getViewport().getMinX(false); + String testLabel = mLabelFormatter.formatLabel(testX, true); + if (testLabel == null) { + testLabel = ""; + } + Rect textBounds = new Rect(); + mPaintLabel.getTextBounds(testLabel, 0, testLabel.length(), textBounds); + mLabelHorizontalWidth = textBounds.width(); + + if (!mLabelHorizontalHeightFixed) { + mLabelHorizontalHeight = textBounds.height(); + + // multiline + int lines = 1; + for (byte c : testLabel.getBytes()) { + if (c == '\n') lines++; + } + mLabelHorizontalHeight *= lines; + + mLabelHorizontalHeight = (int) Math.max(mLabelHorizontalHeight, mStyles.textSize); + } + + // space between text and graph content + mLabelHorizontalHeight += mStyles.labelsSpace; + } + + /** + * do the drawing of the grid + * and labels + * @param canvas canvas + */ + public void draw(Canvas canvas) { + boolean labelSizeChanged = false; + if (mLabelHorizontalWidth == null) { + calcLabelHorizontalSize(canvas); + labelSizeChanged = true; + } + if (mLabelVerticalWidth == null) { + calcLabelVerticalSize(canvas); + labelSizeChanged = true; + } + if (mLabelVerticalSecondScaleWidth == null) { + calcLabelVerticalSecondScaleSize(canvas); + labelSizeChanged = true; + } + if (labelSizeChanged) { + // redraw + ViewCompat.postInvalidateOnAnimation(mGraphView); + return; + } + + if (!mIsAdjusted) { + adjust(); + } + + if (mIsAdjusted) { + drawVerticalSteps(canvas); + drawVerticalStepsSecondScale(canvas); + drawHorizontalSteps(canvas); + } else { + // we can not draw anything + return; + } + + drawHorizontalAxisTitle(canvas); + drawVerticalAxisTitle(canvas); + } + + /** + * draws the horizontal axis title if + * it is set + * @param canvas canvas + */ + protected void drawHorizontalAxisTitle(Canvas canvas) { + if (mHorizontalAxisTitle != null && mHorizontalAxisTitle.length() > 0) { + mPaintAxisTitle.setColor(getHorizontalAxisTitleColor()); + mPaintAxisTitle.setTextSize(getHorizontalAxisTitleTextSize()); + float x = canvas.getWidth() / 2; + float y = canvas.getHeight() - mStyles.padding; + canvas.drawText(mHorizontalAxisTitle, x, y, mPaintAxisTitle); + } + } + + /** + * draws the vertical axis title if + * it is set + * @param canvas canvas + */ + protected void drawVerticalAxisTitle(Canvas canvas) { + if (mVerticalAxisTitle != null && mVerticalAxisTitle.length() > 0) { + mPaintAxisTitle.setColor(getVerticalAxisTitleColor()); + mPaintAxisTitle.setTextSize(getVerticalAxisTitleTextSize()); + float x = getVerticalAxisTitleWidth(); + float y = canvas.getHeight() / 2; + canvas.save(); + canvas.rotate(-90, x, y); + canvas.drawText(mVerticalAxisTitle, x, y, mPaintAxisTitle); + canvas.restore(); + } + } + + /** + * @return the horizontal axis title height + * or 0 if there is no title + */ + public int getHorizontalAxisTitleHeight() { + if (mHorizontalAxisTitle != null && mHorizontalAxisTitle.length() > 0) { + return (int) getHorizontalAxisTitleTextSize(); + } else { + return 0; + } + } + + /** + * @return the vertical axis title width + * or 0 if there is no title + */ + public int getVerticalAxisTitleWidth() { + if (mVerticalAxisTitle != null && mVerticalAxisTitle.length() > 0) { + return (int) getVerticalAxisTitleTextSize(); + } else { + return 0; + } + } + + /** + * draws the horizontal steps + * vertical lines and horizontal labels + * + * @param canvas canvas + */ + protected void drawHorizontalSteps(Canvas canvas) { + // draw horizontal steps (vertical lines and horizontal labels) + mPaintLabel.setColor(getHorizontalLabelsColor()); + int i = 0; + for (Map.Entry e : mStepsHorizontal.entrySet()) { + // draw line + if (mStyles.highlightZeroLines) { + if (e.getValue() == 0d) { + mPaintLine.setStrokeWidth(5); + } else { + mPaintLine.setStrokeWidth(0); + } + } + if (mStyles.gridStyle.drawVertical()) { + canvas.drawLine(e.getKey(), mGraphView.getGraphContentTop(), e.getKey(), mGraphView.getGraphContentTop() + mGraphView.getGraphContentHeight(), mPaintLine); + } + + // draw label + if (isHorizontalLabelsVisible()) { + mPaintLabel.setTextAlign(Paint.Align.CENTER); + if (i == mStepsHorizontal.size() - 1) + mPaintLabel.setTextAlign(Paint.Align.RIGHT); + if (i == 0) + mPaintLabel.setTextAlign(Paint.Align.LEFT); + + // multiline labels + String label = mLabelFormatter.formatLabel(e.getValue(), true); + if (label == null) { + label = ""; + } + String[] lines = label.split("\n"); + for (int li = 0; li < lines.length; li++) { + // for the last line y = height + float y = (canvas.getHeight() - mStyles.padding - getHorizontalAxisTitleHeight()) - (lines.length - li - 1) * getTextSize() * 1.1f + mStyles.labelsSpace; + canvas.drawText(lines[li], e.getKey(), y, mPaintLabel); + } + } + i++; + } + } + + /** + * draws the vertical steps for the + * second scale on the right side + * + * @param canvas canvas + */ + protected void drawVerticalStepsSecondScale(Canvas canvas) { + if (mGraphView.mSecondScale == null) { + return; + } + + // draw only the vertical labels on the right + float startLeft = mGraphView.getGraphContentLeft() + mGraphView.getGraphContentWidth(); + mPaintLabel.setColor(getVerticalLabelsSecondScaleColor()); + mPaintLabel.setTextAlign(getVerticalLabelsSecondScaleAlign()); + for (Map.Entry e : mStepsVerticalSecondScale.entrySet()) { + // draw label + int labelsWidth = mLabelVerticalSecondScaleWidth; + int labelsOffset = (int) startLeft; + if (getVerticalLabelsSecondScaleAlign() == Paint.Align.RIGHT) { + labelsOffset += labelsWidth; + } else if (getVerticalLabelsSecondScaleAlign() == Paint.Align.CENTER) { + labelsOffset += labelsWidth / 2; + } + + float y = e.getKey(); + + String[] lines = mGraphView.mSecondScale.mLabelFormatter.formatLabel(e.getValue(), false).split("\n"); + y += (lines.length * getTextSize() * 1.1f) / 2; // center text vertically + for (int li = 0; li < lines.length; li++) { + // for the last line y = height + float y2 = y - (lines.length - li - 1) * getTextSize() * 1.1f; + canvas.drawText(lines[li], labelsOffset, y2, mPaintLabel); + } + } + } + + /** + * draws the vertical steps + * horizontal lines and vertical labels + * + * @param canvas canvas + */ + protected void drawVerticalSteps(Canvas canvas) { + // draw vertical steps (horizontal lines and vertical labels) + float startLeft = mGraphView.getGraphContentLeft(); + mPaintLabel.setColor(getVerticalLabelsColor()); + mPaintLabel.setTextAlign(getVerticalLabelsAlign()); + for (Map.Entry e : mStepsVertical.entrySet()) { + // draw line + if (mStyles.highlightZeroLines) { + if (e.getValue() == 0d) { + mPaintLine.setStrokeWidth(5); + } else { + mPaintLine.setStrokeWidth(0); + } + } + if (mStyles.gridStyle.drawHorizontal()) { + canvas.drawLine(startLeft, e.getKey(), startLeft + mGraphView.getGraphContentWidth(), e.getKey(), mPaintLine); + } + + // draw label + if (isVerticalLabelsVisible()) { + int labelsWidth = mLabelVerticalWidth; + int labelsOffset = 0; + if (getVerticalLabelsAlign() == Paint.Align.RIGHT) { + labelsOffset = labelsWidth; + labelsOffset -= mStyles.labelsSpace; + } else if (getVerticalLabelsAlign() == Paint.Align.CENTER) { + labelsOffset = labelsWidth / 2; + } + labelsOffset += mStyles.padding + getVerticalAxisTitleWidth(); + + float y = e.getKey(); + + String label = mLabelFormatter.formatLabel(e.getValue(), false); + if (label == null) { + label = ""; + } + String[] lines = label.split("\n"); + y += (lines.length * getTextSize() * 1.1f) / 2; // center text vertically + for (int li = 0; li < lines.length; li++) { + // for the last line y = height + float y2 = y - (lines.length - li - 1) * getTextSize() * 1.1f; + canvas.drawText(lines[li], labelsOffset, y2, mPaintLabel); + } + } + } + } + + /** + * this will do rounding to generate + * nice human-readable bounds. + * + * @param in the raw value that is to be rounded + * @param roundAlwaysUp true if it shall always round up (ceil) + * @return the rounded number + */ + protected double humanRound(double in, boolean roundAlwaysUp) { + // round-up to 1-steps, 2-steps or 5-steps + int ten = 0; + while (in >= 10d) { + in /= 10d; + ten++; + } + while (in < 1d) { + in *= 10d; + ten--; + } + if (roundAlwaysUp) { + if (in == 1d) { + } else if (in <= 2d) { + in = 2d; + } else if (in <= 5d) { + in = 5d; + } else if (in < 10d) { + in = 10d; + } + } else { // always round down + if (in == 1d) { + } else if (in <= 4.9d) { + in = 2d; + } else if (in <= 9.9d) { + in = 5d; + } else if (in < 15d) { + in = 10d; + } + } + return in * Math.pow(10d, ten); + } + + /** + * @return the wrapped styles + */ + public Styles getStyles() { + return mStyles; + } + + /** + * @return the vertical label width + * 0 if there are no vertical labels + */ + public int getLabelVerticalWidth() { + return mLabelVerticalWidth == null || !isVerticalLabelsVisible() ? 0 : mLabelVerticalWidth; + } + + /** + * sets a manual and fixed with of the space for + * the vertical labels. This will prevent GraphView to + * calculate the width automatically. + * + * @param width the width of the space for the vertical labels. + * Use null to let GraphView automatically calculate the width. + */ + public void setLabelVerticalWidth(Integer width) { + mLabelVerticalWidth = width; + mLabelVerticalWidthFixed = mLabelVerticalWidth != null; + } + + /** + * @return the horizontal label height + * 0 if there are no horizontal labels + */ + public int getLabelHorizontalHeight() { + return mLabelHorizontalHeight == null || !isHorizontalLabelsVisible() ? 0 : mLabelHorizontalHeight; + } + + /** + * sets a manual and fixed height of the space for + * the horizontal labels. This will prevent GraphView to + * calculate the height automatically. + * + * @param height the height of the space for the horizontal labels. + * Use null to let GraphView automatically calculate the height. + */ + public void setLabelHorizontalHeight(Integer height) { + mLabelHorizontalHeight = height; + mLabelHorizontalHeightFixed = mLabelHorizontalHeight != null; + } + + /** + * @return the grid line color + */ + public int getGridColor() { + return mStyles.gridColor; + } + + /** + * @return whether the line at 0 are highlighted + */ + public boolean isHighlightZeroLines() { + return mStyles.highlightZeroLines; + } + + /** + * @return the padding around the grid and labels + */ + public int getPadding() { + return mStyles.padding; + } + + /** + * @param textSize the general text size of the axis titles. + * can be overwritten with {@link #setVerticalAxisTitleTextSize(float)} + * and {@link #setHorizontalAxisTitleTextSize(float)} + */ + public void setTextSize(float textSize) { + mStyles.textSize = textSize; + } + + /** + * @param verticalLabelsAlign the alignment of the vertical labels + */ + public void setVerticalLabelsAlign(Paint.Align verticalLabelsAlign) { + mStyles.verticalLabelsAlign = verticalLabelsAlign; + } + + /** + * @param verticalLabelsColor the color of the vertical labels + */ + public void setVerticalLabelsColor(int verticalLabelsColor) { + mStyles.verticalLabelsColor = verticalLabelsColor; + } + + /** + * @param horizontalLabelsColor the color of the horizontal labels + */ + public void setHorizontalLabelsColor(int horizontalLabelsColor) { + mStyles.horizontalLabelsColor = horizontalLabelsColor; + } + + /** + * @param gridColor the color of the grid lines + */ + public void setGridColor(int gridColor) { + mStyles.gridColor = gridColor; + } + + /** + * @param highlightZeroLines flag whether the zero-lines (vertical+ + * horizontal) shall be highlighted + */ + public void setHighlightZeroLines(boolean highlightZeroLines) { + mStyles.highlightZeroLines = highlightZeroLines; + } + + /** + * @param padding the padding around the graph and labels + */ + public void setPadding(int padding) { + mStyles.padding = padding; + } + + /** + * @return the label formatter, that converts + * the raw numbers to strings + */ + public LabelFormatter getLabelFormatter() { + return mLabelFormatter; + } + + /** + * @param mLabelFormatter the label formatter, that converts + * the raw numbers to strings + */ + public void setLabelFormatter(LabelFormatter mLabelFormatter) { + this.mLabelFormatter = mLabelFormatter; + mLabelFormatter.setViewport(mGraphView.getViewport()); + } + + /** + * @return the title of the horizontal axis + */ + public String getHorizontalAxisTitle() { + return mHorizontalAxisTitle; + } + + /** + * @param mHorizontalAxisTitle the title of the horizontal axis + */ + public void setHorizontalAxisTitle(String mHorizontalAxisTitle) { + this.mHorizontalAxisTitle = mHorizontalAxisTitle; + } + + /** + * @return the title of the vertical axis + */ + public String getVerticalAxisTitle() { + return mVerticalAxisTitle; + } + + /** + * @param mVerticalAxisTitle the title of the vertical axis + */ + public void setVerticalAxisTitle(String mVerticalAxisTitle) { + this.mVerticalAxisTitle = mVerticalAxisTitle; + } + + /** + * @return font size of the vertical axis title + */ + public float getVerticalAxisTitleTextSize() { + return mStyles.verticalAxisTitleTextSize; + } + + /** + * @param verticalAxisTitleTextSize font size of the vertical axis title + */ + public void setVerticalAxisTitleTextSize(float verticalAxisTitleTextSize) { + mStyles.verticalAxisTitleTextSize = verticalAxisTitleTextSize; + } + + /** + * @return font color of the vertical axis title + */ + public int getVerticalAxisTitleColor() { + return mStyles.verticalAxisTitleColor; + } + + /** + * @param verticalAxisTitleColor font color of the vertical axis title + */ + public void setVerticalAxisTitleColor(int verticalAxisTitleColor) { + mStyles.verticalAxisTitleColor = verticalAxisTitleColor; + } + + /** + * @return font size of the horizontal axis title + */ + public float getHorizontalAxisTitleTextSize() { + return mStyles.horizontalAxisTitleTextSize; + } + + /** + * @param horizontalAxisTitleTextSize font size of the horizontal axis title + */ + public void setHorizontalAxisTitleTextSize(float horizontalAxisTitleTextSize) { + mStyles.horizontalAxisTitleTextSize = horizontalAxisTitleTextSize; + } + + /** + * @return font color of the horizontal axis title + */ + public int getHorizontalAxisTitleColor() { + return mStyles.horizontalAxisTitleColor; + } + + /** + * @param horizontalAxisTitleColor font color of the horizontal axis title + */ + public void setHorizontalAxisTitleColor(int horizontalAxisTitleColor) { + mStyles.horizontalAxisTitleColor = horizontalAxisTitleColor; + } + + /** + * @return the alignment of the labels on the right side + */ + public Paint.Align getVerticalLabelsSecondScaleAlign() { + return mStyles.verticalLabelsSecondScaleAlign; + } + + /** + * @param verticalLabelsSecondScaleAlign the alignment of the labels on the right side + */ + public void setVerticalLabelsSecondScaleAlign(Paint.Align verticalLabelsSecondScaleAlign) { + mStyles.verticalLabelsSecondScaleAlign = verticalLabelsSecondScaleAlign; + } + + /** + * @return the color of the labels on the right side + */ + public int getVerticalLabelsSecondScaleColor() { + return mStyles.verticalLabelsSecondScaleColor; + } + + /** + * @param verticalLabelsSecondScaleColor the color of the labels on the right side + */ + public void setVerticalLabelsSecondScaleColor(int verticalLabelsSecondScaleColor) { + mStyles.verticalLabelsSecondScaleColor = verticalLabelsSecondScaleColor; + } + + /** + * @return the width of the vertical labels + * of the second scale + */ + public int getLabelVerticalSecondScaleWidth() { + return mLabelVerticalSecondScaleWidth==null?0:mLabelVerticalSecondScaleWidth; + } + + /** + * @return flag whether the horizontal labels are + * visible + */ + public boolean isHorizontalLabelsVisible() { + return mStyles.horizontalLabelsVisible; + } + + /** + * @param horizontalTitleVisible flag whether the horizontal labels are + * visible + */ + public void setHorizontalLabelsVisible(boolean horizontalTitleVisible) { + mStyles.horizontalLabelsVisible = horizontalTitleVisible; + } + + /** + * @return flag whether the vertical labels are + * visible + */ + public boolean isVerticalLabelsVisible() { + return mStyles.verticalLabelsVisible; + } + + /** + * @param verticalTitleVisible flag whether the vertical labels are + * visible + */ + public void setVerticalLabelsVisible(boolean verticalTitleVisible) { + mStyles.verticalLabelsVisible = verticalTitleVisible; + } + + /** + * @return count of the vertical labels, that + * will be shown at one time. + */ + public int getNumVerticalLabels() { + return mNumVerticalLabels; + } + + /** + * @param mNumVerticalLabels count of the vertical labels, that + * will be shown at one time. + */ + public void setNumVerticalLabels(int mNumVerticalLabels) { + this.mNumVerticalLabels = mNumVerticalLabels; + } + + /** + * @return count of the horizontal labels, that + * will be shown at one time. + */ + public int getNumHorizontalLabels() { + return mNumHorizontalLabels; + } + + /** + * @param mNumHorizontalLabels count of the horizontal labels, that + * will be shown at one time. + */ + public void setNumHorizontalLabels(int mNumHorizontalLabels) { + this.mNumHorizontalLabels = mNumHorizontalLabels; + } + + /** + * @return the grid style + */ + public GridStyle getGridStyle() { + return mStyles.gridStyle; + } + + /** + * Define which grid lines shall be drawn + * + * @param gridStyle the grid style + */ + public void setGridStyle(GridStyle gridStyle) { + mStyles.gridStyle = gridStyle; + } + + /** + * @return the space between the labels text and the graph content + */ + public int getLabelsSpace() { + return mStyles.labelsSpace; + } + + /** + * the space between the labels text and the graph content + * + * @param labelsSpace the space between the labels text and the graph content + */ + public void setLabelsSpace(int labelsSpace) { + mStyles.labelsSpace = labelsSpace; + } +} diff --git a/graphview/src/main/java/com/jjoe64/graphview/LabelFormatter.java b/graphview/src/main/java/com/jjoe64/graphview/LabelFormatter.java new file mode 100644 index 0000000000..80aad2990a --- /dev/null +++ b/graphview/src/main/java/com/jjoe64/graphview/LabelFormatter.java @@ -0,0 +1,54 @@ +/** + * GraphView + * Copyright (C) 2014 Jonas Gehring + * + * 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 2 of the License, + * with the "Linking Exception", which can be found at the license.txt + * file in this program. + * + * 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 + * with the "Linking Exception" along with this program; if not, + * write to the author Jonas Gehring . + */ +package com.jjoe64.graphview; + +/** + * Interface to use as label formatter. + * Implement this in order to generate + * your own labels format. + * It is recommended to override {@link com.jjoe64.graphview.DefaultLabelFormatter}. + * + * @author jjoe64 + */ +public interface LabelFormatter { + /** + * converts a raw number as input to + * a formatted string for the label. + * + * @param value raw input number + * @param isValueX true if it is a value for the x axis + * false if it is a value for the y axis + * @return the formatted number as string + */ + public String formatLabel(double value, boolean isValueX); + + /** + * will be called in order to have a + * reference to the current viewport. + * This is useful if you need the bounds + * to generate your labels. + * You store this viewport in as member variable + * and access it e.g. in the {@link #formatLabel(double, boolean)} + * method. + * + * @param viewport the used viewport + */ + public void setViewport(Viewport viewport); +} diff --git a/graphview/src/main/java/com/jjoe64/graphview/LegendRenderer.java b/graphview/src/main/java/com/jjoe64/graphview/LegendRenderer.java new file mode 100644 index 0000000000..db901577f7 --- /dev/null +++ b/graphview/src/main/java/com/jjoe64/graphview/LegendRenderer.java @@ -0,0 +1,394 @@ +/** + * GraphView + * Copyright (C) 2014 Jonas Gehring + * + * 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 2 of the License, + * with the "Linking Exception", which can be found at the license.txt + * file in this program. + * + * 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 + * with the "Linking Exception" along with this program; if not, + * write to the author Jonas Gehring . + */ +package com.jjoe64.graphview; + +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Point; +import android.graphics.Rect; +import android.graphics.RectF; +import android.util.TypedValue; + +import com.jjoe64.graphview.series.Series; + +import java.util.ArrayList; +import java.util.List; + +/** + * The default renderer for the legend box + * + * @author jjoe64 + */ +public class LegendRenderer { + /** + * wrapped styles regarding to the + * legend + */ + private final class Styles { + float textSize; + int spacing; + int padding; + int width; + int backgroundColor; + int textColor; + int margin; + LegendAlign align; + Point fixedPosition; + } + + /** + * alignment of the legend + */ + public enum LegendAlign { + /** + * top right corner + */ + TOP, + + /** + * middle right + */ + MIDDLE, + + /** + * bottom right corner + */ + BOTTOM + } + + /** + * wrapped styles + */ + private Styles mStyles; + + /** + * reference to the graphview + */ + private final GraphView mGraphView; + + /** + * flag whether legend will be + * drawn + */ + private boolean mIsVisible; + + /** + * paint for the drawing + */ + private Paint mPaint; + + /** + * cached legend width + * this will be filled in the drawing. + * Can be cleared via {@link #resetStyles()} + */ + private int cachedLegendWidth; + + /** + * creates legend renderer + * + * @param graphView regarding graphview + */ + public LegendRenderer(GraphView graphView) { + mGraphView = graphView; + mIsVisible = false; + mPaint = new Paint(); + mPaint.setTextAlign(Paint.Align.LEFT); + mStyles = new Styles(); + cachedLegendWidth = 0; + resetStyles(); + } + + /** + * resets the styles to the defaults + * and clears the legend width cache + */ + public void resetStyles() { + mStyles.align = LegendAlign.MIDDLE; + mStyles.textSize = mGraphView.getGridLabelRenderer().getTextSize(); + mStyles.spacing = (int) (mStyles.textSize / 5); + mStyles.padding = (int) (mStyles.textSize / 2); + mStyles.width = 0; + mStyles.backgroundColor = Color.argb(180, 100, 100, 100); + mStyles.margin = (int) (mStyles.textSize / 5); + + // get matching styles from theme + TypedValue typedValue = new TypedValue(); + mGraphView.getContext().getTheme().resolveAttribute(android.R.attr.textAppearanceSmall, typedValue, true); + + int color1; + + try { + TypedArray array = mGraphView.getContext().obtainStyledAttributes(typedValue.data, new int[]{ + android.R.attr.textColorPrimary}); + color1 = array.getColor(0, Color.BLACK); + array.recycle(); + } catch (Exception e) { + color1 = Color.BLACK; + } + + mStyles.textColor = color1; + + cachedLegendWidth = 0; + } + + /** + * draws the legend if it is visible + * + * @param canvas canvas + * @see #setVisible(boolean) + */ + public void draw(Canvas canvas) { + if (!mIsVisible) return; + + mPaint.setTextSize(mStyles.textSize); + + int shapeSize = (int) (mStyles.textSize*0.8d); + + List allSeries = new ArrayList(); + allSeries.addAll(mGraphView.getSeries()); + if (mGraphView.mSecondScale != null) { + allSeries.addAll(mGraphView.getSecondScale().getSeries()); + } + + // width + int legendWidth = mStyles.width; + if (legendWidth == 0) { + // auto + legendWidth = cachedLegendWidth; + + if (legendWidth == 0) { + Rect textBounds = new Rect(); + for (Series s : allSeries) { + if (s.getTitle() != null) { + mPaint.getTextBounds(s.getTitle(), 0, s.getTitle().length(), textBounds); + legendWidth = Math.max(legendWidth, textBounds.width()); + } + } + if (legendWidth == 0) legendWidth = 1; + + // add shape size + legendWidth += shapeSize+mStyles.padding*2 + mStyles.spacing; + cachedLegendWidth = legendWidth; + } + } + + // rect + float legendHeight = (mStyles.textSize+mStyles.spacing)*allSeries.size() -mStyles.spacing; + float lLeft; + float lTop; + if (mStyles.fixedPosition != null) { + // use fied position + lLeft = mGraphView.getGraphContentLeft() + mStyles.margin + mStyles.fixedPosition.x; + lTop = mGraphView.getGraphContentTop() + mStyles.margin + mStyles.fixedPosition.y; + } else { + lLeft = mGraphView.getGraphContentLeft() + mGraphView.getGraphContentWidth() - legendWidth - mStyles.margin; + switch (mStyles.align) { + case TOP: + lTop = mGraphView.getGraphContentTop() + mStyles.margin; + break; + case MIDDLE: + lTop = mGraphView.getHeight() / 2 - legendHeight / 2; + break; + default: + lTop = mGraphView.getGraphContentTop() + mGraphView.getGraphContentHeight() - mStyles.margin - legendHeight - 2*mStyles.padding; + } + } + float lRight = lLeft+legendWidth; + float lBottom = lTop+legendHeight+2*mStyles.padding; + mPaint.setColor(mStyles.backgroundColor); + canvas.drawRoundRect(new RectF(lLeft, lTop, lRight, lBottom), 8, 8, mPaint); + + int i=0; + for (Series series : allSeries) { + mPaint.setColor(series.getColor()); + canvas.drawRect(new RectF(lLeft+mStyles.padding, lTop+mStyles.padding+(i*(mStyles.textSize+mStyles.spacing)), lLeft+mStyles.padding+shapeSize, lTop+mStyles.padding+(i*(mStyles.textSize+mStyles.spacing))+shapeSize), mPaint); + if (series.getTitle() != null) { + mPaint.setColor(mStyles.textColor); + canvas.drawText(series.getTitle(), lLeft+mStyles.padding+shapeSize+mStyles.spacing, lTop+mStyles.padding+mStyles.textSize+(i*(mStyles.textSize+mStyles.spacing)), mPaint); + } + i++; + } + } + + /** + * @return the flag whether the legend will be drawn + */ + public boolean isVisible() { + return mIsVisible; + } + + /** + * set the flag whether the legend will be drawn + * + * @param mIsVisible visible flag + */ + public void setVisible(boolean mIsVisible) { + this.mIsVisible = mIsVisible; + } + + /** + * @return font size + */ + public float getTextSize() { + return mStyles.textSize; + } + + /** + * sets the font size. this will clear + * the internal legend width cache + * + * @param textSize font size + */ + public void setTextSize(float textSize) { + mStyles.textSize = textSize; + cachedLegendWidth = 0; + } + + /** + * @return the spacing between the text lines + */ + public int getSpacing() { + return mStyles.spacing; + } + + /** + * set the spacing between the text lines + * + * @param spacing the spacing between the text lines + */ + public void setSpacing(int spacing) { + mStyles.spacing = spacing; + } + + /** + * padding is the space between the edge of the box + * and the beginning of the text + * + * @return padding from edge to text + */ + public int getPadding() { + return mStyles.padding; + } + + /** + * padding is the space between the edge of the box + * and the beginning of the text + * + * @param padding padding from edge to text + */ + public void setPadding(int padding) { + mStyles.padding = padding; + } + + /** + * the width of the box exclusive padding + * + * @return the width of the box + * 0 => auto + */ + public int getWidth() { + return mStyles.width; + } + + /** + * the width of the box exclusive padding + * @param width the width of the box exclusive padding + * 0 => auto + */ + public void setWidth(int width) { + mStyles.width = width; + } + + /** + * @return background color of the box + * it is recommended to use semi-transparent + * color. + */ + public int getBackgroundColor() { + return mStyles.backgroundColor; + } + + /** + * @param backgroundColor background color of the box + * it is recommended to use semi-transparent + * color. + */ + public void setBackgroundColor(int backgroundColor) { + mStyles.backgroundColor = backgroundColor; + } + + /** + * @return margin from the edge of the box + * to the corner of the graphview + */ + public int getMargin() { + return mStyles.margin; + } + + /** + * @param margin margin from the edge of the box + * to the corner of the graphview + */ + public void setMargin(int margin) { + mStyles.margin = margin; + } + + /** + * @return the vertical alignment of the box + */ + public LegendAlign getAlign() { + return mStyles.align; + } + + /** + * @param align the vertical alignment of the box + */ + public void setAlign(LegendAlign align) { + mStyles.align = align; + } + + /** + * @return font color + */ + public int getTextColor() { + return mStyles.textColor; + } + + /** + * @param textColor font color + */ + public void setTextColor(int textColor) { + mStyles.textColor = textColor; + } + + /** + * Use fixed coordinates to position the legend. + * This will override the align setting. + * + * @param x x coordinates in pixel + * @param y y coordinates in pixel + */ + public void setFixedPosition(int x, int y) { + mStyles.fixedPosition = new Point(x, y); + } +} diff --git a/graphview/src/main/java/com/jjoe64/graphview/SecondScale.java b/graphview/src/main/java/com/jjoe64/graphview/SecondScale.java new file mode 100644 index 0000000000..1fff8ee8a9 --- /dev/null +++ b/graphview/src/main/java/com/jjoe64/graphview/SecondScale.java @@ -0,0 +1,165 @@ +/** + * GraphView + * Copyright (C) 2014 Jonas Gehring + * + * 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 2 of the License, + * with the "Linking Exception", which can be found at the license.txt + * file in this program. + * + * 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 + * with the "Linking Exception" along with this program; if not, + * write to the author Jonas Gehring . + */ +package com.jjoe64.graphview; + +import com.jjoe64.graphview.series.Series; + +import java.util.ArrayList; +import java.util.List; + +/** + * To be used to plot a second scale + * on the graph. + * The second scale has always to have + * manual bounds. + * Use {@link #setMinY(double)} and {@link #setMaxY(double)} + * to set them. + * The second scale has it's own array of series. + * + * @author jjoe64 + */ +public class SecondScale { + /** + * reference to the viewport of the graph + */ + protected final Viewport mViewport; + + /** + * array of series for the second + * scale + */ + protected List mSeries; + + /** + * flag whether the y axis bounds + * are manual. + * For the current version this is always + * true. + */ + private boolean mYAxisBoundsManual = true; + + /** + * min y value for the y axis bounds + */ + private double mMinY; + + /** + * max y value for the y axis bounds + */ + private double mMaxY; + + /** + * label formatter for the y labels + * on the right side + */ + protected LabelFormatter mLabelFormatter; + + /** + * creates the second scale. + * normally you do not call this contructor. + * Use {@link com.jjoe64.graphview.GraphView#getSecondScale()} + * in order to get the instance. + */ + SecondScale(Viewport viewport) { + mViewport = viewport; + mSeries = new ArrayList(); + mLabelFormatter = new DefaultLabelFormatter(); + mLabelFormatter.setViewport(mViewport); + } + + /** + * add a series to the second scale. + * Don't add this series also to the GraphView + * object. + * + * @param s the series + */ + public void addSeries(Series s) { + mSeries.add(s); + } + + //public void setYAxisBoundsManual(boolean mYAxisBoundsManual) { + // this.mYAxisBoundsManual = mYAxisBoundsManual; + //} + + /** + * set the min y bounds + * + * @param d min y value + */ + public void setMinY(double d) { + mMinY = d; + } + + /** + * set the max y bounds + * + * @param d max y value + */ + public void setMaxY(double d) { + mMaxY = d; + } + + /** + * @return the series of the second scale + */ + public List getSeries() { + return mSeries; + } + + /** + * @return min y bound + */ + public double getMinY() { + return mMinY; + } + + /** + * @return max y bound + */ + public double getMaxY() { + return mMaxY; + } + + /** + * @return always true for the current implementation + */ + public boolean isYAxisBoundsManual() { + return mYAxisBoundsManual; + } + + /** + * @return label formatter for the y labels on the right side + */ + public LabelFormatter getLabelFormatter() { + return mLabelFormatter; + } + + /** + * Set a custom label formatter that is used + * for the y labels on the right side. + * + * @param formatter label formatter for the y labels + */ + public void setLabelFormatter(LabelFormatter formatter) { + mLabelFormatter = formatter; + mLabelFormatter.setViewport(mViewport); + } +} diff --git a/graphview/src/main/java/com/jjoe64/graphview/ValueDependentColor.java b/graphview/src/main/java/com/jjoe64/graphview/ValueDependentColor.java new file mode 100644 index 0000000000..a02f22a21e --- /dev/null +++ b/graphview/src/main/java/com/jjoe64/graphview/ValueDependentColor.java @@ -0,0 +1,41 @@ +/** + * GraphView + * Copyright (C) 2014 Jonas Gehring + * + * 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 2 of the License, + * with the "Linking Exception", which can be found at the license.txt + * file in this program. + * + * 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 + * with the "Linking Exception" along with this program; if not, + * write to the author Jonas Gehring . + */ + +package com.jjoe64.graphview; + +import com.jjoe64.graphview.series.DataPointInterface; + +/** + * you can change the color depending on the value. + * takes only effect for BarGraphSeries. + * + * @see com.jjoe64.graphview.series.BarGraphSeries#setValueDependentColor(ValueDependentColor) + */ +public interface ValueDependentColor { + /** + * this is called when a bar is about to draw + * and the color is be loaded. + * + * @param data the current input value + * @return the color that the bar should be drawn with + * Generate the int via the android.graphics.Color class. + */ + public int get(T data); +} diff --git a/graphview/src/main/java/com/jjoe64/graphview/Viewport.java b/graphview/src/main/java/com/jjoe64/graphview/Viewport.java new file mode 100644 index 0000000000..de90c05ea2 --- /dev/null +++ b/graphview/src/main/java/com/jjoe64/graphview/Viewport.java @@ -0,0 +1,996 @@ +/** + * GraphView + * Copyright (C) 2014 Jonas Gehring + * + * 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 2 of the License, + * with the "Linking Exception", which can be found at the license.txt + * file in this program. + * + * 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 + * with the "Linking Exception" along with this program; if not, + * write to the author Jonas Gehring . + */ +package com.jjoe64.graphview; + +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.RectF; +import android.util.Log; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.ScaleGestureDetector; +import android.widget.OverScroller; + +import androidx.core.view.ViewCompat; +import androidx.core.widget.EdgeEffectCompat; + +import com.jjoe64.graphview.compat.OverScrollerCompat; +import com.jjoe64.graphview.series.DataPointInterface; +import com.jjoe64.graphview.series.Series; + +import java.util.Iterator; +import java.util.List; + +/** + * This is the default implementation for the viewport. + * This implementation so for a normal viewport + * where there is a horizontal x-axis and a + * vertical y-axis. + * This viewport is compatible with + * - {@link com.jjoe64.graphview.series.BarGraphSeries} + * - {@link com.jjoe64.graphview.series.LineGraphSeries} + * - {@link com.jjoe64.graphview.series.PointsGraphSeries} + * + * @author jjoe64 + */ +public class Viewport { + /** + * listener for the scale gesture + */ + private final ScaleGestureDetector.OnScaleGestureListener mScaleGestureListener + = new ScaleGestureDetector.OnScaleGestureListener() { + /** + * called by android + * @param detector detector + * @return always true + */ + @Override + public boolean onScale(ScaleGestureDetector detector) { + float viewportWidth = mCurrentViewport.width(); + float center = mCurrentViewport.left + viewportWidth / 2; + viewportWidth /= detector.getScaleFactor(); + mCurrentViewport.left = center - viewportWidth / 2; + mCurrentViewport.right = mCurrentViewport.left+viewportWidth; + + // viewportStart must not be < minX + float minX = (float) getMinX(true); + if (mCurrentViewport.left < minX) { + mCurrentViewport.left = minX; + mCurrentViewport.right = mCurrentViewport.left+viewportWidth; + } + + // viewportStart + viewportSize must not be > maxX + float maxX = (float) getMaxX(true); + if (viewportWidth == 0) { + mCurrentViewport.right = maxX; + } + double overlap = mCurrentViewport.left + viewportWidth - maxX; + if (overlap > 0) { + // scroll left + if (mCurrentViewport.left-overlap > minX) { + mCurrentViewport.left -= overlap; + mCurrentViewport.right = mCurrentViewport.left+viewportWidth; + } else { + // maximal scale + mCurrentViewport.left = minX; + mCurrentViewport.right = maxX; + } + } + + // adjust viewport, labels, etc. + mGraphView.onDataChanged(true, false); + + ViewCompat.postInvalidateOnAnimation(mGraphView); + + return true; + } + + /** + * called when scaling begins + * + * @param detector detector + * @return true if it is scalable + */ + @Override + public boolean onScaleBegin(ScaleGestureDetector detector) { + if (mIsScalable) { + mScalingBeginWidth = mCurrentViewport.width(); + mScalingBeginLeft = mCurrentViewport.left; + mScalingActive = true; + return true; + } else { + return false; + } + } + + /** + * called when sacling ends + * This will re-adjust the viewport. + * + * @param detector detector + */ + @Override + public void onScaleEnd(ScaleGestureDetector detector) { + mScalingActive = false; + + // re-adjust + mXAxisBoundsStatus = AxisBoundsStatus.READJUST_AFTER_SCALE; + + mScrollingReferenceX = Float.NaN; + + // adjust viewport, labels, etc. + mGraphView.onDataChanged(true, false); + + ViewCompat.postInvalidateOnAnimation(mGraphView); + } + }; + + /** + * simple gesture listener to track scroll events + */ + private final GestureDetector.SimpleOnGestureListener mGestureListener + = new GestureDetector.SimpleOnGestureListener() { + @Override + public boolean onDown(MotionEvent e) { + if (!mIsScrollable || mScalingActive) return false; + + // Initiates the decay phase of any active edge effects. + releaseEdgeEffects(); + mScrollerStartViewport.set(mCurrentViewport); + // Aborts any active scroll animations and invalidates. + mScroller.forceFinished(true); + ViewCompat.postInvalidateOnAnimation(mGraphView); + return true; + } + + @Override + public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { + if (!mIsScrollable || mScalingActive) return false; + + if (Float.isNaN(mScrollingReferenceX)) { + mScrollingReferenceX = mCurrentViewport.left; + } + + // Scrolling uses math based on the viewport (as opposed to math using pixels). + /** + * Pixel offset is the offset in screen pixels, while viewport offset is the + * offset within the current viewport. For additional information on surface sizes + * and pixel offsets, see the docs for {@link computeScrollSurfaceSize()}. For + * additional information about the viewport, see the comments for + * {@link mCurrentViewport}. + */ + float viewportOffsetX = distanceX * mCurrentViewport.width() / mGraphView.getGraphContentWidth(); + float viewportOffsetY = -distanceY * mCurrentViewport.height() / mGraphView.getGraphContentHeight(); + + int completeWidth = (int)((mCompleteRange.width()/mCurrentViewport.width()) * (float) mGraphView.getGraphContentWidth()); + int completeHeight = (int)((mCompleteRange.height()/mCurrentViewport.height()) * (float) mGraphView.getGraphContentHeight()); + + int scrolledX = (int) (completeWidth + * (mCurrentViewport.left + viewportOffsetX - mCompleteRange.left) + / mCompleteRange.width()); + int scrolledY = (int) (completeHeight + * (mCompleteRange.bottom - mCurrentViewport.bottom - viewportOffsetY) + / mCompleteRange.height()); + boolean canScrollX = mCurrentViewport.left > mCompleteRange.left + || mCurrentViewport.right < mCompleteRange.right; + boolean canScrollY = mCurrentViewport.bottom > mCompleteRange.bottom + || mCurrentViewport.top < mCompleteRange.top; + + if (canScrollX) { + if (viewportOffsetX < 0) { + float tooMuch = mCurrentViewport.left+viewportOffsetX - mCompleteRange.left; + if (tooMuch < 0) { + viewportOffsetX -= tooMuch; + } + } else { + float tooMuch = mCurrentViewport.right+viewportOffsetX - mCompleteRange.right; + if (tooMuch > 0) { + viewportOffsetX -= tooMuch; + } + } + mCurrentViewport.left += viewportOffsetX; + mCurrentViewport.right += viewportOffsetX; + } + if (canScrollY) { + //mCurrentViewport.top += viewportOffsetX; + //mCurrentViewport.bottom -= viewportOffsetX; + } + + if (canScrollX && scrolledX < 0) { + mEdgeEffectLeft.onPull(scrolledX / (float) mGraphView.getGraphContentWidth()); + mEdgeEffectLeftActive = true; + } + if (canScrollY && scrolledY < 0) { + mEdgeEffectBottom.onPull(scrolledY / (float) mGraphView.getGraphContentHeight()); + mEdgeEffectBottomActive = true; + } + if (canScrollX && scrolledX > completeWidth - mGraphView.getGraphContentWidth()) { + mEdgeEffectRight.onPull((scrolledX - completeWidth + mGraphView.getGraphContentWidth()) + / (float) mGraphView.getGraphContentWidth()); + mEdgeEffectRightActive = true; + } + //if (canScrollY && scrolledY > mSurfaceSizeBuffer.y - mContentRect.height()) { + // mEdgeEffectTop.onPull((scrolledY - mSurfaceSizeBuffer.y + mContentRect.height()) + // / (float) mContentRect.height()); + // mEdgeEffectTopActive = true; + //} + + // adjust viewport, labels, etc. + mGraphView.onDataChanged(true, false); + + ViewCompat.postInvalidateOnAnimation(mGraphView); + return true; + } + + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, + float velocityX, float velocityY) { + //fling((int) -velocityX, (int) -velocityY); + return true; + } + }; + + /** + * the state of the axis bounds + */ + public enum AxisBoundsStatus { + /** + * initial means that the bounds gets + * auto adjusted if they are not manual. + * After adjusting the status comes to + * #AUTO_ADJUSTED. + */ + INITIAL, + + /** + * after the bounds got auto-adjusted, + * this status will set. + */ + AUTO_ADJUSTED, + + /** + * this flags the status that a scale was + * done and the bounds has to be auto-adjusted + * afterwards. + */ + READJUST_AFTER_SCALE, + + /** + * means that the bounds are fix (manually) and + * are not to be auto-adjusted. + */ + FIX + } + + /** + * paint to draw background + */ + private Paint mPaint; + + /** + * reference to the graphview + */ + private final GraphView mGraphView; + + /** + * this holds the current visible viewport + * left = minX, right = maxX + * bottom = minY, top = maxY + */ + protected RectF mCurrentViewport = new RectF(); + + /** + * this holds the whole range of the data + * left = minX, right = maxX + * bottom = minY, top = maxY + */ + protected RectF mCompleteRange = new RectF(); + + /** + * flag whether scaling is currently active + */ + protected boolean mScalingActive; + + /** + * stores the width of the viewport at the time + * of beginning of the scaling. + */ + protected float mScalingBeginWidth; + + /** + * stores the viewport left at the time of + * beginning of the scaling. + */ + protected float mScalingBeginLeft; + + /** + * flag whether the viewport is scrollable + */ + private boolean mIsScrollable; + + /** + * flag whether the viewport is scalable + */ + private boolean mIsScalable; + + /** + * gesture detector to detect scrolling + */ + protected GestureDetector mGestureDetector; + + /** + * detect scaling + */ + protected ScaleGestureDetector mScaleGestureDetector; + + /** + * not used - for fling + */ + protected OverScroller mScroller; + + /** + * not used + */ + private EdgeEffectCompat mEdgeEffectTop; + + /** + * not used + */ + private EdgeEffectCompat mEdgeEffectBottom; + + /** + * glow effect when scrolling left + */ + private EdgeEffectCompat mEdgeEffectLeft; + + /** + * glow effect when scrolling right + */ + private EdgeEffectCompat mEdgeEffectRight; + + /** + * not used + */ + private boolean mEdgeEffectTopActive; + + /** + * not used + */ + private boolean mEdgeEffectBottomActive; + + /** + * glow effect when scrolling left + */ + private boolean mEdgeEffectLeftActive; + + /** + * glow effect when scrolling right + */ + private boolean mEdgeEffectRightActive; + + /** + * stores the viewport at the time of + * the beginning of scaling + */ + private RectF mScrollerStartViewport = new RectF(); + + /** + * stores the viewport left value at the + * time of beginning of the scrolling + */ + protected float mScrollingReferenceX = Float.NaN; + + /** + * state of the x axis + */ + private AxisBoundsStatus mXAxisBoundsStatus; + + /** + * state of the y axis + */ + private AxisBoundsStatus mYAxisBoundsStatus; + + /** + * flag whether the x axis bounds are manual + */ + private boolean mXAxisBoundsManual; + + /** + * flag whether the y axis bounds are manual + */ + private boolean mYAxisBoundsManual; + + /** + * background color of the viewport area + * it is recommended to use a semi-transparent color + */ + private int mBackgroundColor; + + /** + * creates the viewport + * + * @param graphView graphview + */ + Viewport(GraphView graphView) { + mScroller = new OverScroller(graphView.getContext()); + mEdgeEffectTop = new EdgeEffectCompat(graphView.getContext()); + mEdgeEffectBottom = new EdgeEffectCompat(graphView.getContext()); + mEdgeEffectLeft = new EdgeEffectCompat(graphView.getContext()); + mEdgeEffectRight = new EdgeEffectCompat(graphView.getContext()); + mGestureDetector = new GestureDetector(graphView.getContext(), mGestureListener); + mScaleGestureDetector = new ScaleGestureDetector(graphView.getContext(), mScaleGestureListener); + + mGraphView = graphView; + mXAxisBoundsStatus = AxisBoundsStatus.INITIAL; + mYAxisBoundsStatus = AxisBoundsStatus.INITIAL; + mBackgroundColor = Color.TRANSPARENT; + mPaint = new Paint(); + } + + /** + * will be called on a touch event. + * needed to use scaling and scrolling + * + * @param event + * @return true if it was consumed + */ + public boolean onTouchEvent(MotionEvent event) { + boolean b = mScaleGestureDetector.onTouchEvent(event); + b |= mGestureDetector.onTouchEvent(event); + return b; + } + + /** + * change the state of the x axis. + * normally you do not call this method. + * If you want to set manual axis use + * {@link #setXAxisBoundsManual(boolean)} and {@link #setYAxisBoundsManual(boolean)} + * + * @param s state + */ + public void setXAxisBoundsStatus(AxisBoundsStatus s) { + mXAxisBoundsStatus = s; + } + + /** + * change the state of the y axis. + * normally you do not call this method. + * If you want to set manual axis use + * {@link #setXAxisBoundsManual(boolean)} and {@link #setYAxisBoundsManual(boolean)} + * + * @param s state + */ + public void setYAxisBoundsStatus(AxisBoundsStatus s) { + mYAxisBoundsStatus = s; + } + + /** + * @return whether the viewport is scrollable + */ + public boolean isScrollable() { + return mIsScrollable; + } + + /** + * @param mIsScrollable whether is viewport is scrollable + */ + public void setScrollable(boolean mIsScrollable) { + this.mIsScrollable = mIsScrollable; + } + + /** + * @return the x axis state + */ + public AxisBoundsStatus getXAxisBoundsStatus() { + return mXAxisBoundsStatus; + } + + /** + * @return the y axis state + */ + public AxisBoundsStatus getYAxisBoundsStatus() { + return mYAxisBoundsStatus; + } + + /** + * caches the complete range (minX, maxX, minY, maxY) + * by iterating all series and all datapoints and + * stores it into #mCompleteRange + */ + public void calcCompleteRange() { + List series = mGraphView.getSeries(); + mCompleteRange.set(0, 0, 0, 0); + if (!series.isEmpty() && !series.get(0).isEmpty()) { + double d = series.get(0).getLowestValueX(); + for (Series s : series) { + if (!s.isEmpty() && d > s.getLowestValueX()) { + d = s.getLowestValueX(); + } + } + mCompleteRange.left = (float) d; + + d = series.get(0).getHighestValueX(); + for (Series s : series) { + if (!s.isEmpty() && d < s.getHighestValueX()) { + d = s.getHighestValueX(); + } + } + mCompleteRange.right = (float) d; + + d = series.get(0).getLowestValueY(); + for (Series s : series) { + if (!s.isEmpty() && d > s.getLowestValueY()) { + d = s.getLowestValueY(); + } + } + mCompleteRange.bottom = (float) d; + + d = series.get(0).getHighestValueY(); + for (Series s : series) { + if (!s.isEmpty() && d < s.getHighestValueY()) { + d = s.getHighestValueY(); + } + } + mCompleteRange.top = (float) d; + } + + // calc current viewport bounds + if (mYAxisBoundsStatus == AxisBoundsStatus.AUTO_ADJUSTED) { + mYAxisBoundsStatus = AxisBoundsStatus.INITIAL; + } + if (mYAxisBoundsStatus == AxisBoundsStatus.INITIAL) { + mCurrentViewport.top = mCompleteRange.top; + mCurrentViewport.bottom = mCompleteRange.bottom; + } + + if (mXAxisBoundsStatus == AxisBoundsStatus.AUTO_ADJUSTED) { + mXAxisBoundsStatus = AxisBoundsStatus.INITIAL; + } + if (mXAxisBoundsStatus == AxisBoundsStatus.INITIAL) { + mCurrentViewport.left = mCompleteRange.left; + mCurrentViewport.right = mCompleteRange.right; + } else if (mXAxisBoundsManual && !mYAxisBoundsManual && mCompleteRange.width() != 0) { + // get highest/lowest of current viewport + // lowest + double d = Double.MAX_VALUE; + for (Series s : series) { + Iterator values = s.getValues(mCurrentViewport.left, mCurrentViewport.right); + while (values.hasNext()) { + double v = values.next().getY(); + if (d > v) { + d = v; + } + } + } + + mCurrentViewport.bottom = (float) d; + + // highest + d = Double.MIN_VALUE; + for (Series s : series) { + Iterator values = s.getValues(mCurrentViewport.left, mCurrentViewport.right); + while (values.hasNext()) { + double v = values.next().getY(); + if (d < v) { + d = v; + } + } + } + mCurrentViewport.top = (float) d; + } + + // fixes blank screen when range is zero + if (mCurrentViewport.left == mCurrentViewport.right) mCurrentViewport.right++; + if (mCurrentViewport.top == mCurrentViewport.bottom) mCurrentViewport.top++; + } + + /** + * @param completeRange if true => minX of the complete range of all series + * if false => minX of the current visible viewport + * @return the min x value + */ + public double getMinX(boolean completeRange) { + if (completeRange) { + return (double) mCompleteRange.left; + } else { + return (double) mCurrentViewport.left; + } + } + + /** + * @param completeRange if true => maxX of the complete range of all series + * if false => maxX of the current visible viewport + * @return the max x value + */ + public double getMaxX(boolean completeRange) { + if (completeRange) { + return (double) mCompleteRange.right; + } else { + return mCurrentViewport.right; + } + } + + /** + * @param completeRange if true => minY of the complete range of all series + * if false => minY of the current visible viewport + * @return the min y value + */ + public double getMinY(boolean completeRange) { + if (completeRange) { + return (double) mCompleteRange.bottom; + } else { + return mCurrentViewport.bottom; + } + } + + /** + * @param completeRange if true => maxY of the complete range of all series + * if false => maxY of the current visible viewport + * @return the max y value + */ + public double getMaxY(boolean completeRange) { + if (completeRange) { + return (double) mCompleteRange.top; + } else { + return mCurrentViewport.top; + } + } + + /** + * set the maximal y value for the current viewport. + * Make sure to set the y bounds to manual via + * {@link #setYAxisBoundsManual(boolean)} + * @param y max / highest value + */ + public void setMaxY(double y) { + mCurrentViewport.top = (float) y; + } + + /** + * set the minimal y value for the current viewport. + * Make sure to set the y bounds to manual via + * {@link #setYAxisBoundsManual(boolean)} + * @param y min / lowest value + */ + public void setMinY(double y) { + mCurrentViewport.bottom = (float) y; + } + + /** + * set the maximal x value for the current viewport. + * Make sure to set the x bounds to manual via + * {@link #setXAxisBoundsManual(boolean)} + * @param x max / highest value + */ + public void setMaxX(double x) { + mCurrentViewport.right = (float) x; + } + + /** + * set the minimal x value for the current viewport. + * Make sure to set the x bounds to manual via + * {@link #setXAxisBoundsManual(boolean)} + * @param x min / lowest value + */ + public void setMinX(double x) { + mCurrentViewport.left = (float) x; + } + + /** + * release the glowing effects + */ + private void releaseEdgeEffects() { + mEdgeEffectLeftActive + = mEdgeEffectRightActive + = false; + mEdgeEffectLeft.onRelease(); + mEdgeEffectRight.onRelease(); + } + + /** + * not used currently + * + * @param velocityX + * @param velocityY + */ + private void fling(int velocityX, int velocityY) { + velocityY = 0; + releaseEdgeEffects(); + // Flings use math in pixels (as opposed to math based on the viewport). + mScrollerStartViewport.set(mCurrentViewport); + int maxX = (int)((mCurrentViewport.width()/mCompleteRange.width())*(float)mGraphView.getGraphContentWidth()) - mGraphView.getGraphContentWidth(); + int maxY = (int)((mCurrentViewport.height()/mCompleteRange.height())*(float)mGraphView.getGraphContentHeight()) - mGraphView.getGraphContentHeight(); + int startX = (int)((mCurrentViewport.left - mCompleteRange.left)/mCompleteRange.width())*maxX; + int startY = (int)((mCurrentViewport.top - mCompleteRange.top)/mCompleteRange.height())*maxY; + mScroller.forceFinished(true); + mScroller.fling( + startX, + startY, + velocityX, + velocityY, + 0, maxX, + 0, maxY, + mGraphView.getGraphContentWidth() / 2, + mGraphView.getGraphContentHeight() / 2); + ViewCompat.postInvalidateOnAnimation(mGraphView); + } + + /** + * not used currently + */ + public void computeScroll() { + if (true) return; + + boolean needsInvalidate = false; + + if (mScroller.computeScrollOffset()) { + // The scroller isn't finished, meaning a fling or programmatic pan operation is + // currently active. + + int completeWidth = (int)((mCompleteRange.width()/mCurrentViewport.width()) * (float) mGraphView.getGraphContentWidth()); + int completeHeight = (int)((mCompleteRange.height()/mCurrentViewport.height()) * (float) mGraphView.getGraphContentHeight()); + + int currX = mScroller.getCurrX(); + int currY = mScroller.getCurrY(); + + boolean canScrollX = mCurrentViewport.left > mCompleteRange.left + || mCurrentViewport.right < mCompleteRange.right; + boolean canScrollY = mCurrentViewport.bottom > mCompleteRange.bottom + || mCurrentViewport.top < mCompleteRange.top; + + if (canScrollX + && currX < 0 + && mEdgeEffectLeft.isFinished() + && !mEdgeEffectLeftActive) { + mEdgeEffectLeft.onAbsorb((int) OverScrollerCompat.getCurrVelocity(mScroller)); + mEdgeEffectLeftActive = true; + needsInvalidate = true; + } else if (canScrollX + && currX > (completeWidth - mGraphView.getGraphContentWidth()) + && mEdgeEffectRight.isFinished() + && !mEdgeEffectRightActive) { + mEdgeEffectRight.onAbsorb((int) OverScrollerCompat.getCurrVelocity(mScroller)); + mEdgeEffectRightActive = true; + needsInvalidate = true; + } + + if (canScrollY + && currY < 0 + && mEdgeEffectTop.isFinished() + && !mEdgeEffectTopActive) { + mEdgeEffectTop.onAbsorb((int) OverScrollerCompat.getCurrVelocity(mScroller)); + mEdgeEffectTopActive = true; + needsInvalidate = true; + } else if (canScrollY + && currY > (completeHeight - mGraphView.getGraphContentHeight()) + && mEdgeEffectBottom.isFinished() + && !mEdgeEffectBottomActive) { + mEdgeEffectBottom.onAbsorb((int) OverScrollerCompat.getCurrVelocity(mScroller)); + mEdgeEffectBottomActive = true; + needsInvalidate = true; + } + + float currXRange = mCompleteRange.left + mCompleteRange.width() + * currX / completeWidth; + float currYRange = mCompleteRange.top - mCompleteRange.height() + * currY / completeHeight; + + float currWidth = mCurrentViewport.width(); + float currHeight = mCurrentViewport.height(); + mCurrentViewport.left = currXRange; + mCurrentViewport.right = currXRange + currWidth; + //mCurrentViewport.bottom = currYRange; + //mCurrentViewport.top = currYRange + currHeight; + } + + if (needsInvalidate) { + ViewCompat.postInvalidateOnAnimation(mGraphView); + } + } + + /** + * Draws the overscroll "glow" at the four edges of the chart region, if necessary. + * + * @see EdgeEffectCompat + */ + private void drawEdgeEffectsUnclipped(Canvas canvas) { + // The methods below rotate and translate the canvas as needed before drawing the glow, + // since EdgeEffectCompat always draws a top-glow at 0,0. + + boolean needsInvalidate = false; + + if (!mEdgeEffectTop.isFinished()) { + final int restoreCount = canvas.save(); + canvas.translate(mGraphView.getGraphContentLeft(), mGraphView.getGraphContentTop()); + mEdgeEffectTop.setSize(mGraphView.getGraphContentWidth(), mGraphView.getGraphContentHeight()); + if (mEdgeEffectTop.draw(canvas)) { + needsInvalidate = true; + } + canvas.restoreToCount(restoreCount); + } + + //if (!mEdgeEffectBottom.isFinished()) { + // final int restoreCount = canvas.save(); + // canvas.translate(2 * mContentRect.left - mContentRect.right, mContentRect.bottom); + // canvas.rotate(180, mContentRect.width(), 0); + // mEdgeEffectBottom.setSize(mContentRect.width(), mContentRect.height()); + // if (mEdgeEffectBottom.draw(canvas)) { + // needsInvalidate = true; + // } + // canvas.restoreToCount(restoreCount); + //} + + if (!mEdgeEffectLeft.isFinished()) { + final int restoreCount = canvas.save(); + canvas.translate(mGraphView.getGraphContentLeft(), mGraphView.getGraphContentTop()+ mGraphView.getGraphContentHeight()); + canvas.rotate(-90, 0, 0); + mEdgeEffectLeft.setSize(mGraphView.getGraphContentHeight(), mGraphView.getGraphContentWidth()); + if (mEdgeEffectLeft.draw(canvas)) { + needsInvalidate = true; + } + canvas.restoreToCount(restoreCount); + } + + if (!mEdgeEffectRight.isFinished()) { + final int restoreCount = canvas.save(); + canvas.translate(mGraphView.getGraphContentLeft()+ mGraphView.getGraphContentWidth(), mGraphView.getGraphContentTop()); + canvas.rotate(90, 0, 0); + mEdgeEffectRight.setSize(mGraphView.getGraphContentHeight(), mGraphView.getGraphContentWidth()); + if (mEdgeEffectRight.draw(canvas)) { + needsInvalidate = true; + } + canvas.restoreToCount(restoreCount); + } + + if (needsInvalidate) { + ViewCompat.postInvalidateOnAnimation(mGraphView); + } + } + + /** + * will be first called in order to draw + * the canvas + * Used to draw the background + * + * @param c canvas. + */ + public void drawFirst(Canvas c) { + // draw background + if (mBackgroundColor != Color.TRANSPARENT) { + mPaint.setColor(mBackgroundColor); + c.drawRect( + mGraphView.getGraphContentLeft(), + mGraphView.getGraphContentTop(), + mGraphView.getGraphContentLeft()+mGraphView.getGraphContentWidth(), + mGraphView.getGraphContentTop()+mGraphView.getGraphContentHeight(), + mPaint + ); + } + } + + /** + * draws the glowing edge effect + * + * @param c canvas + */ + public void draw(Canvas c) { + drawEdgeEffectsUnclipped(c); + } + + /** + * @return background of the viewport area + */ + public int getBackgroundColor() { + return mBackgroundColor; + } + + /** + * @param mBackgroundColor background of the viewport area + * use transparent to have no background + */ + public void setBackgroundColor(int mBackgroundColor) { + this.mBackgroundColor = mBackgroundColor; + } + + /** + * @return whether the viewport is scalable + */ + public boolean isScalable() { + return mIsScalable; + } + + /** + * active the scaling/zooming feature + * notice: sets the x axis bounds to manual + * + * @param mIsScalable whether the viewport is scalable + */ + public void setScalable(boolean mIsScalable) { + this.mIsScalable = mIsScalable; + if (mIsScalable) { + mIsScrollable = true; + + // set viewport to manual + setXAxisBoundsManual(true); + } + + } + + /** + * @return whether the x axis bounds are manual. + * @see #setMinX(double) + * @see #setMaxX(double) + */ + public boolean isXAxisBoundsManual() { + return mXAxisBoundsManual; + } + + /** + * @param mXAxisBoundsManual whether the x axis bounds are manual. + * @see #setMinX(double) + * @see #setMaxX(double) + */ + public void setXAxisBoundsManual(boolean mXAxisBoundsManual) { + this.mXAxisBoundsManual = mXAxisBoundsManual; + if (mXAxisBoundsManual) { + mXAxisBoundsStatus = AxisBoundsStatus.FIX; + } + } + + /** + * @return whether the y axis bound are manual + */ + public boolean isYAxisBoundsManual() { + return mYAxisBoundsManual; + } + + /** + * @param mYAxisBoundsManual whether the y axis bounds are manual + * @see #setMaxY(double) + * @see #setMinY(double) + */ + public void setYAxisBoundsManual(boolean mYAxisBoundsManual) { + this.mYAxisBoundsManual = mYAxisBoundsManual; + if (mYAxisBoundsManual) { + mYAxisBoundsStatus = AxisBoundsStatus.FIX; + } + } + + /** + * forces the viewport to scroll to the end + * of the range by keeping the current viewport size. + * + * Important: Only takes effect if x axis bounds are manual. + * + * @see #setXAxisBoundsManual(boolean) + */ + public void scrollToEnd() { + if (mXAxisBoundsManual) { + float size = mCurrentViewport.width(); + mCurrentViewport.right = mCompleteRange.right; + mCurrentViewport.left = mCompleteRange.right - size; + mScrollingReferenceX = Float.NaN; + mGraphView.onDataChanged(true, false); + } else { + Log.w("GraphView", "scrollToEnd works only with manual x axis bounds"); + } + } +} diff --git a/graphview/src/main/java/com/jjoe64/graphview/compat/OverScrollerCompat.java b/graphview/src/main/java/com/jjoe64/graphview/compat/OverScrollerCompat.java new file mode 100644 index 0000000000..20b497c91f --- /dev/null +++ b/graphview/src/main/java/com/jjoe64/graphview/compat/OverScrollerCompat.java @@ -0,0 +1,46 @@ +/** + * GraphView + * Copyright (C) 2014 Jonas Gehring + * + * 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 2 of the License, + * with the "Linking Exception", which can be found at the license.txt + * file in this program. + * + * 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 + * with the "Linking Exception" along with this program; if not, + * write to the author Jonas Gehring . + */ +package com.jjoe64.graphview.compat; + +import android.annotation.TargetApi; +import android.os.Build; +import android.widget.OverScroller; + +/** + * A utility class for using {@link android.widget.OverScroller} in a backward-compatible fashion. + */ +public class OverScrollerCompat { + /** + * Disallow instantiation. + */ + private OverScrollerCompat() { + } + /** + * @see android.view.ScaleGestureDetector#getCurrentSpanY() + */ + @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH) + public static float getCurrVelocity(OverScroller overScroller) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { + return overScroller.getCurrVelocity(); + } else { + return 0; + } + } +} \ No newline at end of file diff --git a/graphview/src/main/java/com/jjoe64/graphview/helper/DateAsXAxisLabelFormatter.java b/graphview/src/main/java/com/jjoe64/graphview/helper/DateAsXAxisLabelFormatter.java new file mode 100644 index 0000000000..1e9fa3cff4 --- /dev/null +++ b/graphview/src/main/java/com/jjoe64/graphview/helper/DateAsXAxisLabelFormatter.java @@ -0,0 +1,94 @@ +/** + * GraphView + * Copyright (C) 2014 Jonas Gehring + * + * 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 2 of the License, + * with the "Linking Exception", which can be found at the license.txt + * file in this program. + * + * 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 + * with the "Linking Exception" along with this program; if not, + * write to the author Jonas Gehring . + */ +package com.jjoe64.graphview.helper; + +import android.content.Context; + +import com.jjoe64.graphview.DefaultLabelFormatter; + +import java.text.DateFormat; +import java.util.Calendar; +import java.util.Date; + +/** + * Helper class to use date objects as x-values. + * This will use your own Date Format or by default + * the Android default date format to convert + * the x-values (that has to be millis from + * 01-01-1970) into a formatted date string. + * + * See the DateAsXAxis example in the GraphView-Demos project + * to see a working example. + * + * @author jjoe64 + */ +public class DateAsXAxisLabelFormatter extends DefaultLabelFormatter { + /** + * the date format that will convert + * the unix timestamp to string + */ + protected final DateFormat mDateFormat; + + /** + * calendar to avoid creating new date objects + */ + protected final Calendar mCalendar; + + /** + * create the formatter with the Android default date format to convert + * the x-values. + * + * @param context the application context + */ + public DateAsXAxisLabelFormatter(Context context) { + mDateFormat = android.text.format.DateFormat.getDateFormat(context); + mCalendar = Calendar.getInstance(); + } + + /** + * create the formatter with your own custom + * date format to convert the x-values. + * + * @param context the application context + * @param dateFormat custom date format + */ + public DateAsXAxisLabelFormatter(Context context, DateFormat dateFormat) { + mDateFormat = dateFormat; + mCalendar = Calendar.getInstance(); + } + + /** + * formats the x-values as date string. + * + * @param value raw value + * @param isValueX true if it's a x value, otherwise false + * @return value converted to string + */ + @Override + public String formatLabel(double value, boolean isValueX) { + if (isValueX) { + // format as date + mCalendar.setTimeInMillis((long) value); + return mDateFormat.format(mCalendar.getTimeInMillis()); + } else { + return super.formatLabel(value, isValueX); + } + } +} diff --git a/graphview/src/main/java/com/jjoe64/graphview/helper/GraphViewXML.java b/graphview/src/main/java/com/jjoe64/graphview/helper/GraphViewXML.java new file mode 100644 index 0000000000..d036805bc8 --- /dev/null +++ b/graphview/src/main/java/com/jjoe64/graphview/helper/GraphViewXML.java @@ -0,0 +1,137 @@ +/** + * GraphView + * Copyright (C) 2014 Jonas Gehring + * + * 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 2 of the License, + * with the "Linking Exception", which can be found at the license.txt + * file in this program. + * + * 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 + * with the "Linking Exception" along with this program; if not, + * write to the author Jonas Gehring . + */ +package com.jjoe64.graphview.helper; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.util.AttributeSet; +import android.util.Log; + +import com.jjoe64.graphview.GraphView; +import com.jjoe64.graphview.R; +import com.jjoe64.graphview.series.BarGraphSeries; +import com.jjoe64.graphview.series.BaseSeries; +import com.jjoe64.graphview.series.DataPoint; +import com.jjoe64.graphview.series.DataPointInterface; +import com.jjoe64.graphview.series.LineGraphSeries; +import com.jjoe64.graphview.series.PointsGraphSeries; +import com.jjoe64.graphview.series.Series; + +/** + * helper class to use GraphView directly + * in a XML layout file. + * + * You can set the data via attribute app:seriesData + * in the format: "X=Y;X=Y;..." e.g. "0=5.0;1=5;2=4;3=9" + * + * Other styling options: + *
  • app:seriesType="line|bar|points"
  • + *
  • app:seriesColor="#ff0000"
  • + *
  • app:seriesTitle="foobar" - if this is set, the legend will be drawn
  • + *
  • android:title="foobar"
  • + * + * Example: + *
    + * {@code
    + *  
    + * }
    + * 
    + * + * @author jjoe64 + */ +public class GraphViewXML extends GraphView { + /** + * creates the graphview object with data and + * other options from xml attributes. + * + * @param context + * @param attrs + */ + public GraphViewXML(Context context, AttributeSet attrs) { + super(context, attrs); + + // get attributes + TypedArray a=context.obtainStyledAttributes( + attrs, + R.styleable.GraphViewXML); + + String dataStr = a.getString(R.styleable.GraphViewXML_seriesData); + int color = a.getColor(R.styleable.GraphViewXML_seriesColor, Color.TRANSPARENT); + String type = a.getString(R.styleable.GraphViewXML_seriesType); + String seriesTitle = a.getString(R.styleable.GraphViewXML_seriesTitle); + String title = a.getString(R.styleable.GraphViewXML_android_title); + + a.recycle(); + + // decode data + DataPoint[] data; + if (dataStr == null || dataStr.isEmpty()) { + throw new IllegalArgumentException("Attribute seriesData is required in the format: 0=5.0;1=5;2=4;3=9"); + } else { + String[] d = dataStr.split(";"); + try { + data = new DataPoint[d.length]; + int i = 0; + for (String dd : d) { + String[] xy = dd.split("="); + data[i] = new DataPoint(Double.parseDouble(xy[0]), Double.parseDouble(xy[1])); + i++; + } + } catch (Exception e) { + Log.e("GraphViewXML", e.toString()); + throw new IllegalArgumentException("Attribute seriesData is broken. Use this format: 0=5.0;1=5;2=4;3=9"); + } + } + + // create series + BaseSeries series; + if (type == null || type.isEmpty()) { + type = "line"; + } + if (type.equals("line")) { + series = new LineGraphSeries(data); + } else if (type.equals("bar")) { + series = new BarGraphSeries(data); + } else if (type.equals("points")) { + series = new PointsGraphSeries(data); + } else { + throw new IllegalArgumentException("unknown graph type: "+type+". Possible is line|bar|points"); + } + if (color != Color.TRANSPARENT) { + series.setColor(color); + } + addSeries(series); + + if (seriesTitle != null && !seriesTitle.isEmpty()) { + series.setTitle(seriesTitle); + getLegendRenderer().setVisible(true); + } + + if (title != null && !title.isEmpty()) { + setTitle(title); + } + } +} diff --git a/graphview/src/main/java/com/jjoe64/graphview/helper/StaticLabelsFormatter.java b/graphview/src/main/java/com/jjoe64/graphview/helper/StaticLabelsFormatter.java new file mode 100644 index 0000000000..4b38dbf4c2 --- /dev/null +++ b/graphview/src/main/java/com/jjoe64/graphview/helper/StaticLabelsFormatter.java @@ -0,0 +1,209 @@ +package com.jjoe64.graphview.helper; + +import com.jjoe64.graphview.DefaultLabelFormatter; +import com.jjoe64.graphview.GraphView; +import com.jjoe64.graphview.LabelFormatter; +import com.jjoe64.graphview.Viewport; + +/** + * Use this label formatter to show static labels. + * Static labels are not bound to the data. It is typical used + * for show text like "low", "middle", "high". + * + * You can set the static labels for vertical or horizontal + * individually and you can define a label formatter that + * is to be used if you don't define static labels. + * + * For example if you only use static labels for horizontal labels, + * graphview will use the dynamicLabelFormatter for the vertical labels. + */ +public class StaticLabelsFormatter implements LabelFormatter { + /** + * reference to the viewport + */ + protected Viewport mViewport; + + /** + * the vertical labels, ordered from bottom to the top + * if it is null, the labels will be generated via the #dynamicLabelFormatter + */ + protected String[] mVerticalLabels; + + /** + * the horizontal labels, ordered form the left to the right + * if it is null, the labels will be generated via the #dynamicLabelFormatter + */ + protected String[] mHorizontalLabels; + + /** + * the label formatter that will format the labels + * for that there are no static labels defined. + */ + protected LabelFormatter mDynamicLabelFormatter; + + /** + * reference to the graphview + */ + protected final GraphView mGraphView; + + /** + * creates the formatter without any static labels + * define your static labels via {@link #setHorizontalLabels(String[])} and {@link #setVerticalLabels(String[])} + * + * @param graphView reference to the graphview + */ + public StaticLabelsFormatter(GraphView graphView) { + mGraphView = graphView; + init(null, null, null); + } + + /** + * creates the formatter without any static labels. + * define your static labels via {@link #setHorizontalLabels(String[])} and {@link #setVerticalLabels(String[])} + * + * @param graphView reference to the graphview + * @param dynamicLabelFormatter the label formatter that will format the labels + * for that there are no static labels defined. + */ + public StaticLabelsFormatter(GraphView graphView, LabelFormatter dynamicLabelFormatter) { + mGraphView = graphView; + init(null, null, dynamicLabelFormatter); + } + + /** + * creates the formatter with static labels defined. + * + * @param graphView reference to the graphview + * @param horizontalLabels the horizontal labels, ordered form the left to the right + * if it is null, the labels will be generated via the #dynamicLabelFormatter + * @param verticalLabels the vertical labels, ordered from bottom to the top + * if it is null, the labels will be generated via the #dynamicLabelFormatter + */ + public StaticLabelsFormatter(GraphView graphView, String[] horizontalLabels, String[] verticalLabels) { + mGraphView = graphView; + init(horizontalLabels, verticalLabels, null); + } + + /** + * creates the formatter with static labels defined. + * + * @param graphView reference to the graphview + * @param horizontalLabels the horizontal labels, ordered form the left to the right + * if it is null, the labels will be generated via the #dynamicLabelFormatter + * @param verticalLabels the vertical labels, ordered from bottom to the top + * if it is null, the labels will be generated via the #dynamicLabelFormatter + * @param dynamicLabelFormatter the label formatter that will format the labels + * for that there are no static labels defined. + */ + public StaticLabelsFormatter(GraphView graphView, String[] horizontalLabels, String[] verticalLabels, LabelFormatter dynamicLabelFormatter) { + mGraphView = graphView; + init(horizontalLabels, verticalLabels, dynamicLabelFormatter); + } + + /** + * @param horizontalLabels the horizontal labels, ordered form the left to the right + * if it is null, the labels will be generated via the #dynamicLabelFormatter + * @param verticalLabels the vertical labels, ordered from bottom to the top + * if it is null, the labels will be generated via the #dynamicLabelFormatter + * @param dynamicLabelFormatter the label formatter that will format the labels + * for that there are no static labels defined. + */ + protected void init(String[] horizontalLabels, String[] verticalLabels, LabelFormatter dynamicLabelFormatter) { + mDynamicLabelFormatter = dynamicLabelFormatter; + if (mDynamicLabelFormatter == null) { + mDynamicLabelFormatter = new DefaultLabelFormatter(); + } + + mHorizontalLabels = horizontalLabels; + mVerticalLabels = verticalLabels; + } + + /** + * Set a label formatter that will be used for the labels + * that don't have static labels. + * + * For example if you only use static labels for horizontal labels, + * graphview will use the dynamicLabelFormatter for the vertical labels. + * + * @param dynamicLabelFormatter the label formatter that will format the labels + * for that there are no static labels defined. + */ + public void setDynamicLabelFormatter(LabelFormatter dynamicLabelFormatter) { + this.mDynamicLabelFormatter = dynamicLabelFormatter; + adjust(); + } + + /** + * @param horizontalLabels the horizontal labels, ordered form the left to the right + * if it is null, the labels will be generated via the #dynamicLabelFormatter + */ + public void setHorizontalLabels(String[] horizontalLabels) { + this.mHorizontalLabels = horizontalLabels; + adjust(); + } + + /** + * @param verticalLabels the vertical labels, ordered from bottom to the top + * if it is null, the labels will be generated via the #dynamicLabelFormatter + */ + public void setVerticalLabels(String[] verticalLabels) { + this.mVerticalLabels = verticalLabels; + adjust(); + } + + /** + * + * @param value raw input number + * @param isValueX true if it is a value for the x axis + * false if it is a value for the y axis + * @return + */ + @Override + public String formatLabel(double value, boolean isValueX) { + if (isValueX && mHorizontalLabels != null) { + double minX = mViewport.getMinX(false); + double maxX = mViewport.getMaxX(false); + double range = maxX - minX; + value = value-minX; + int idx = (int)((value/range) * (mHorizontalLabels.length-1)); + return mHorizontalLabels[idx]; + } else if (!isValueX && mVerticalLabels != null) { + double minY = mViewport.getMinY(false); + double maxY = mViewport.getMaxY(false); + double range = maxY - minY; + value = value-minY; + int idx = (int)((value/range) * (mVerticalLabels.length-1)); + return mVerticalLabels[idx]; + } else { + return mDynamicLabelFormatter.formatLabel(value, isValueX); + } + } + + /** + * @param viewport the used viewport + */ + @Override + public void setViewport(Viewport viewport) { + mViewport = viewport; + adjust(); + } + + /** + * adjusts the number of vertical/horizontal labels + */ + protected void adjust() { + mDynamicLabelFormatter.setViewport(mViewport); + if (mVerticalLabels != null) { + if (mVerticalLabels.length < 2) { + throw new IllegalStateException("You need at least 2 vertical labels if you use static label formatter."); + } + mGraphView.getGridLabelRenderer().setNumVerticalLabels(mVerticalLabels.length); + } + if (mHorizontalLabels != null) { + if (mHorizontalLabels.length < 2) { + throw new IllegalStateException("You need at least 2 horizontal labels if you use static label formatter."); + } + mGraphView.getGridLabelRenderer().setNumHorizontalLabels(mHorizontalLabels.length); + } + } +} diff --git a/graphview/src/main/java/com/jjoe64/graphview/series/BarGraphSeries.java b/graphview/src/main/java/com/jjoe64/graphview/series/BarGraphSeries.java new file mode 100644 index 0000000000..8db7bac94b --- /dev/null +++ b/graphview/src/main/java/com/jjoe64/graphview/series/BarGraphSeries.java @@ -0,0 +1,379 @@ +/** + * GraphView + * Copyright (C) 2014 Jonas Gehring + * + * 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 2 of the License, + * with the "Linking Exception", which can be found at the license.txt + * file in this program. + * + * 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 + * with the "Linking Exception" along with this program; if not, + * write to the author Jonas Gehring . + */ +package com.jjoe64.graphview.series; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.RectF; +import android.util.Log; + +import com.jjoe64.graphview.GraphView; +import com.jjoe64.graphview.ValueDependentColor; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.SortedSet; +import java.util.TreeSet; + +/** + * Series with Bars to visualize the data. + * The Bars are always vertical. + * + * @author jjoe64 + */ +public class BarGraphSeries extends BaseSeries { + /** + * paint to do drawing on canvas + */ + private Paint mPaint; + + /** + * spacing between the bars in percentage. + * 0 => no spacing + * 100 => the space bewetten the bars is as big as the bars itself + */ + private int mSpacing; + + /** + * callback to generate value-dependent colors + * of the bars + */ + private ValueDependentColor mValueDependentColor; + + /** + * flag whether the values should drawn + * above the bars as text + */ + private boolean mDrawValuesOnTop; + + /** + * color of the text above the bars. + * + * @see #mDrawValuesOnTop + */ + private int mValuesOnTopColor; + + /** + * font size of the text above the bars. + * + * @see #mDrawValuesOnTop + */ + private float mValuesOnTopSize; + + /** + * stores the coordinates of the bars to + * trigger tap on series events. + */ + private Map mDataPoints = new HashMap(); + + /** + * creates bar series without any data + */ + public BarGraphSeries() { + mPaint = new Paint(); + } + + /** + * creates bar series with data + * + * @param data values + */ + public BarGraphSeries(E[] data) { + super(data); + mPaint = new Paint(); + } + + /** + * draws the bars on the canvas + * + * @param graphView corresponding graphview + * @param canvas canvas + * @param isSecondScale whether we are plotting the second scale or not + */ + @Override + public void draw(GraphView graphView, Canvas canvas, boolean isSecondScale) { + mPaint.setTextAlign(Paint.Align.CENTER); + if (mValuesOnTopSize == 0) { + mValuesOnTopSize = graphView.getGridLabelRenderer().getTextSize(); + } + mPaint.setTextSize(mValuesOnTopSize); + + // get data + double maxX = graphView.getViewport().getMaxX(false); + double minX = graphView.getViewport().getMinX(false); + + double maxY; + double minY; + if (isSecondScale) { + maxY = graphView.getSecondScale().getMaxY(); + minY = graphView.getSecondScale().getMinY(); + } else { + maxY = graphView.getViewport().getMaxY(false); + minY = graphView.getViewport().getMinY(false); + } + + // Iterate through all bar graph series + // so we know how wide to make our bar, + // and in what position to put it in + int numBarSeries = 0; + int currentSeriesOrder = 0; + int numValues = 0; + boolean isCurrentSeries; + SortedSet xVals = new TreeSet(); + for(Series inspectedSeries: graphView.getSeries()) { + if(inspectedSeries instanceof BarGraphSeries) { + isCurrentSeries = (inspectedSeries == this); + if(isCurrentSeries) { + currentSeriesOrder = numBarSeries; + } + numBarSeries++; + + // calculate the number of slots for bars based on the minimum distance between + // x coordinates in the series. This is divided into the range to find + // the placement and width of bar slots + // (sections of the x axis for each bar or set of bars) + // TODO: Move this somewhere more general and cache it, so we don't recalculate it for each series + Iterator curValues = inspectedSeries.getValues(minX, maxX); + if (curValues.hasNext()) { + xVals.add(curValues.next().getX()); + if(isCurrentSeries) { numValues++; } + while (curValues.hasNext()) { + xVals.add(curValues.next().getX()); + if(isCurrentSeries) { numValues++; } + } + } + } + } + if (numValues == 0) { + return; + } + + Double lastVal = null; + double minGap = 0; + for(Double curVal: xVals) { + if(lastVal != null) { + double curGap = Math.abs(curVal - lastVal); + if (minGap == 0 || (curGap > 0 && curGap < minGap)) { + minGap = curGap; + } + } + lastVal = curVal; + } + + int numBarSlots = (minGap == 0) ? 1 : (int)Math.round((maxX - minX)/minGap) + 1; + + Iterator values = getValues(minX, maxX); + + // Calculate the overall bar slot width - this includes all bars across + // all series, and any spacing between sets of bars + float barSlotWidth = numBarSlots == 1 + ? graphView.getGraphContentWidth() + : graphView.getGraphContentWidth() / (numBarSlots-1); + Log.d("BarGraphSeries", "numBars=" + numBarSlots); + + // Total spacing (both sides) between sets of bars + float spacing = Math.min((float) barSlotWidth*mSpacing/100, barSlotWidth*0.98f); + // Width of an individual bar + float barWidth = (barSlotWidth - spacing) / numBarSeries; + // Offset from the center of a given bar to start drawing + float offset = barSlotWidth/2; + + double diffY = maxY - minY; + double diffX = maxX - minX; + float contentHeight = graphView.getGraphContentHeight(); + float contentWidth = graphView.getGraphContentWidth(); + float contentLeft = graphView.getGraphContentLeft(); + float contentTop = graphView.getGraphContentTop(); + + // draw data + int i=0; + while (values.hasNext()) { + E value = values.next(); + + double valY = value.getY() - minY; + double ratY = valY / diffY; + double y = contentHeight * ratY; + + double valY0 = 0 - minY; + double ratY0 = valY0 / diffY; + double y0 = contentHeight * ratY0; + + double valX = value.getX() - minX; + double ratX = valX / diffX; + double x = contentWidth * ratX; + + // hook for value dependent color + if (getValueDependentColor() != null) { + mPaint.setColor(getValueDependentColor().get(value)); + } else { + mPaint.setColor(getColor()); + } + + float left = (float)x + contentLeft - offset + spacing/2 + currentSeriesOrder*barWidth; + float top = (contentTop - (float)y) + contentHeight; + float right = left + barWidth; + float bottom = (contentTop - (float)y0) + contentHeight - (graphView.getGridLabelRenderer().isHighlightZeroLines()?4:1); + + boolean reverse = top > bottom; + if (reverse) { + float tmp = top; + top = bottom + (graphView.getGridLabelRenderer().isHighlightZeroLines()?4:1); + bottom = tmp; + } + + // overdraw + left = Math.max(left, contentLeft); + right = Math.min(right, contentLeft+contentWidth); + bottom = Math.min(bottom, contentTop+contentHeight); + top = Math.max(top, contentTop); + + mDataPoints.put(new RectF(left, top, right, bottom), value); + + canvas.drawRect(left, top, right, bottom, mPaint); + + // set values on top of graph + if (mDrawValuesOnTop) { + if (reverse) { + top = bottom + mValuesOnTopSize + 4; + if (top > contentTop+contentHeight) top = contentTop + contentHeight; + } else { + top -= 4; + if (top<=contentTop) top+=contentTop+4; + } + + mPaint.setColor(mValuesOnTopColor); + canvas.drawText( + graphView.getGridLabelRenderer().getLabelFormatter().formatLabel(value.getY(), false) + , (left+right)/2, top, mPaint); + } + + i++; + } + } + + /** + * @return the hook to generate value-dependent color. default null + */ + public ValueDependentColor getValueDependentColor() { + return mValueDependentColor; + } + + /** + * set a hook to make the color of the bars depending + * on the actually value/data. + * + * @param mValueDependentColor hook + * null to disable + */ + public void setValueDependentColor(ValueDependentColor mValueDependentColor) { + this.mValueDependentColor = mValueDependentColor; + } + + /** + * @return the spacing between the bars in percentage + */ + public int getSpacing() { + return mSpacing; + } + + /** + * @param mSpacing spacing between the bars in percentage. + * 0 => no spacing + * 100 => the space between the bars is as big as the bars itself + */ + public void setSpacing(int mSpacing) { + this.mSpacing = mSpacing; + } + + /** + * @return whether the values should be drawn above the bars + */ + public boolean isDrawValuesOnTop() { + return mDrawValuesOnTop; + } + + /** + * @param mDrawValuesOnTop flag whether the values should drawn + * above the bars as text + */ + public void setDrawValuesOnTop(boolean mDrawValuesOnTop) { + this.mDrawValuesOnTop = mDrawValuesOnTop; + } + + /** + * @return font color of the values on top of the bars + * @see #setDrawValuesOnTop(boolean) + */ + public int getValuesOnTopColor() { + return mValuesOnTopColor; + } + + /** + * @param mValuesOnTopColor the font color of the values on top of the bars + * @see #setDrawValuesOnTop(boolean) + */ + public void setValuesOnTopColor(int mValuesOnTopColor) { + this.mValuesOnTopColor = mValuesOnTopColor; + } + + /** + * @return font size of the values above the bars + * @see #setDrawValuesOnTop(boolean) + */ + public float getValuesOnTopSize() { + return mValuesOnTopSize; + } + + /** + * @param mValuesOnTopSize font size of the values above the bars + * @see #setDrawValuesOnTop(boolean) + */ + public void setValuesOnTopSize(float mValuesOnTopSize) { + this.mValuesOnTopSize = mValuesOnTopSize; + } + + /** + * resets the cached coordinates of the bars + */ + @Override + protected void resetDataPoints() { + mDataPoints.clear(); + } + + /** + * find the corresponding data point by + * coordinates. + * + * @param x pixels + * @param y pixels + * @return datapoint or null + */ + @Override + protected E findDataPoint(float x, float y) { + for (Map.Entry entry : mDataPoints.entrySet()) { + if (x >= entry.getKey().left && x <= entry.getKey().right + && y >= entry.getKey().top && y <= entry.getKey().bottom) { + return entry.getValue(); + } + } + return null; + } +} diff --git a/graphview/src/main/java/com/jjoe64/graphview/series/BaseSeries.java b/graphview/src/main/java/com/jjoe64/graphview/series/BaseSeries.java new file mode 100644 index 0000000000..8ecdf23a73 --- /dev/null +++ b/graphview/src/main/java/com/jjoe64/graphview/series/BaseSeries.java @@ -0,0 +1,448 @@ +/** + * GraphView + * Copyright (C) 2014 Jonas Gehring + * + * 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 2 of the License, + * with the "Linking Exception", which can be found at the license.txt + * file in this program. + * + * 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 + * with the "Linking Exception" along with this program; if not, + * write to the author Jonas Gehring . + */ +package com.jjoe64.graphview.series; + +import android.graphics.PointF; +import android.util.Log; + +import com.jjoe64.graphview.GraphView; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Set; + +/** + * Basis implementation for series. + * Used for series that are plotted on + * a default x/y 2d viewport. + * + * Extend this class to implement your own custom + * graph type. + * + * This implementation uses a internal Array to store + * the data. If you want to implement a custom data provider + * you may want to implement {@link com.jjoe64.graphview.series.Series}. + * + * @author jjoe64 + */ +public abstract class BaseSeries implements Series { + /** + * holds the data + */ + final private List mData = new ArrayList(); + + /** + * stores the used coordinates to find the + * corresponding data point on a tap + * + * Key => x/y pixel + * Value => Plotted Datapoint + * + * will be filled while drawing via {@link #registerDataPoint(float, float, DataPointInterface)} + */ + private Map mDataPoints = new HashMap(); + + /** + * title for this series that can be displayed + * in the legend. + */ + private String mTitle; + + /** + * base color for this series. will be used also in + * the legend + */ + private int mColor = 0xff0077cc; + + /** + * listener to handle tap events on a data point + */ + protected OnDataPointTapListener mOnDataPointTapListener; + + /** + * stores the graphviews where this series is used. + * Can be more than one. + */ + private List mGraphViews; + + /** + * creates series without data + */ + public BaseSeries() { + mGraphViews = new ArrayList(); + } + + /** + * creates series with data + * + * @param data data points + * important: array has to be sorted from lowest x-value to the highest + */ + public BaseSeries(E[] data) { + mGraphViews = new ArrayList(); + for (E d : data) { + mData.add(d); + } + } + + /** + * @return the lowest x value, or 0 if there is no data + */ + public double getLowestValueX() { + if (mData.isEmpty()) return 0d; + return mData.get(0).getX(); + } + + /** + * @return the highest x value, or 0 if there is no data + */ + public double getHighestValueX() { + if (mData.isEmpty()) return 0d; + return mData.get(mData.size()-1).getX(); + } + + /** + * @return the lowest y value, or 0 if there is no data + */ + public double getLowestValueY() { + if (mData.isEmpty()) return 0d; + double l = mData.get(0).getY(); + for (int i = 1; i < mData.size(); i++) { + double c = mData.get(i).getY(); + if (l > c) { + l = c; + } + } + return l; + } + + /** + * @return the highest y value, or 0 if there is no data + */ + public double getHighestValueY() { + if (mData.isEmpty()) return 0d; + double h = mData.get(0).getY(); + for (int i = 1; i < mData.size(); i++) { + double c = mData.get(i).getY(); + if (h < c) { + h = c; + } + } + return h; + } + + /** + * get the values for a given x range. if from and until are bigger or equal than + * all the data, the original data is returned. + * If it is only a part of the data, the range is returned plus one datapoint + * before and after to get a nice scrolling. + * + * @param from minimal x-value + * @param until maximal x-value + * @return data for the range +/- 1 datapoint + */ + @Override + public Iterator getValues(final double from, final double until) { + if (from <= getLowestValueX() && until >= getHighestValueX()) { + return mData.iterator(); + } else { + return new Iterator() { + Iterator org = mData.iterator(); + E nextValue = null; + E nextNextValue = null; + boolean plusOne = true; + + { + // go to first + boolean found = false; + E prevValue = null; + if (org.hasNext()) { + prevValue = org.next(); + } + if (prevValue.getX() >= from) { + nextValue = prevValue; + found = true; + } else { + while (org.hasNext()) { + nextValue = org.next(); + if (nextValue.getX() >= from) { + found = true; + nextNextValue = nextValue; + nextValue = prevValue; + break; + } + prevValue = nextValue; + } + } + if (!found) { + nextValue = null; + } + } + + @Override + public void remove() { + throw new UnsupportedOperationException(); + } + + @Override + public E next() { + if (hasNext()) { + E r = nextValue; + if (r.getX() > until) { + plusOne = false; + } + if (nextNextValue != null) { + nextValue = nextNextValue; + nextNextValue = null; + } else if (org.hasNext()) nextValue = org.next(); + else nextValue = null; + return r; + } else { + throw new NoSuchElementException(); + } + } + + @Override + public boolean hasNext() { + return nextValue != null && (nextValue.getX() <= until || plusOne); + } + }; + } + } + + /** + * @return the title of the series + */ + public String getTitle() { + return mTitle; + } + + /** + * set the title of the series. This will be used in + * the legend. + * + * @param mTitle title of the series + */ + public void setTitle(String mTitle) { + this.mTitle = mTitle; + } + + /** + * @return color of the series + */ + public int getColor() { + return mColor; + } + + /** + * set the color of the series. This will be used in + * plotting (depends on the series implementation) and + * is used in the legend. + * + * @param mColor + */ + public void setColor(int mColor) { + this.mColor = mColor; + } + + /** + * set a listener for tap on a data point. + * + * @param l listener + */ + public void setOnDataPointTapListener(OnDataPointTapListener l) { + this.mOnDataPointTapListener = l; + } + + /** + * called by the tap detector in order to trigger + * the on tap on datapoint event. + * + * @param x pixel + * @param y pixel + */ + @Override + public void onTap(float x, float y) { + if (mOnDataPointTapListener != null) { + E p = findDataPoint(x, y); + if (p != null) { + mOnDataPointTapListener.onTap(this, p); + } + } + } + + /** + * find the data point which is next to the + * coordinates + * + * @param x pixel + * @param y pixel + * @return the data point or null if nothing was found + */ + protected E findDataPoint(float x, float y) { + float shortestDistance = Float.NaN; + E shortest = null; + for (Map.Entry entry : mDataPoints.entrySet()) { + float x1 = entry.getKey().x; + float y1 = entry.getKey().y; + float x2 = x; + float y2 = y; + + float distance = (float) Math.sqrt((x1-x2)*(x1-x2) + (y1-y2)*(y1-y2)); + if (shortest == null || distance < shortestDistance) { + shortestDistance = distance; + shortest = entry.getValue(); + } + } + if (shortest != null) { + if (shortestDistance < 120) { + return shortest; + } + } + return null; + } + + /** + * register the datapoint to find it at a tap + * + * @param x pixel + * @param y pixel + * @param dp the data point to save + */ + protected void registerDataPoint(float x, float y, E dp) { + mDataPoints.put(new PointF(x, y), dp); + } + + /** + * clears the cached data point coordinates + */ + protected void resetDataPoints() { + mDataPoints.clear(); + } + + /** + * clears the data of this series and sets new. + * will redraw the graph + * + * @param data the values must be in the correct order! + * x-value has to be ASC. First the lowest x value and at least the highest x value. + */ + public void resetData(E[] data) { + mData.clear(); + for (E d : data) { + mData.add(d); + } + checkValueOrder(null); + + // update graphview + for (GraphView gv : mGraphViews) { + gv.onDataChanged(true, false); + } + } + + /** + * called when the series was added to a graph + * + * @param graphView graphview + */ + @Override + public void onGraphViewAttached(GraphView graphView) { + mGraphViews.add(graphView); + } + + /** + * + * @param dataPoint values the values must be in the correct order! + * x-value has to be ASC. First the lowest x value and at least the highest x value. + * @param scrollToEnd true => graphview will scroll to the end (maxX) + * @param maxDataPoints if max data count is reached, the oldest data + * value will be lost to avoid memory leaks + */ + public void appendData(E dataPoint, boolean scrollToEnd, int maxDataPoints) { + checkValueOrder(dataPoint); + + if (!mData.isEmpty() && dataPoint.getX() < mData.get(mData.size()-1).getX()) { + throw new IllegalArgumentException("new x-value must be greater then the last value. x-values has to be ordered in ASC."); + } + synchronized (mData) { + int curDataCount = mData.size(); + if (curDataCount < maxDataPoints) { + // enough space + mData.add(dataPoint); + } else { + // we have to trim one data + mData.remove(0); + mData.add(dataPoint); + } + } + + // recalc the labels when it was the first data + boolean keepLabels = mData.size() != 1; + + // update linked graph views + // update graphview + for (GraphView gv : mGraphViews) { + gv.onDataChanged(keepLabels, scrollToEnd); + if (scrollToEnd) { + gv.getViewport().scrollToEnd(); + } + } + } + + /** + * @return whether there are data points + */ + @Override + public boolean isEmpty() { + return mData.isEmpty(); + } + + /** + * checks that the data is in the correct order + * + * @param onlyLast if not null, it will only check that this + * datapoint is after the last point. + */ + protected void checkValueOrder(DataPointInterface onlyLast) { + if (mData.size()>1) { + if (onlyLast != null) { + // only check last + if (onlyLast.getX() < mData.get(mData.size()-1).getX()) { + throw new IllegalArgumentException("new x-value must be greater then the last value. x-values has to be ordered in ASC."); + } + } else { + double lx = mData.get(0).getX(); + + for (int i = 1; i < mData.size(); i++) { + if (mData.get(i).getX() != Double.NaN) { + if (lx > mData.get(i).getX()) { + throw new IllegalArgumentException("The order of the values is not correct. X-Values have to be ordered ASC. First the lowest x value and at least the highest x value."); + } + lx = mData.get(i).getX(); + } + } + } + } + } +} diff --git a/graphview/src/main/java/com/jjoe64/graphview/series/DataPoint.java b/graphview/src/main/java/com/jjoe64/graphview/series/DataPoint.java new file mode 100644 index 0000000000..b5f5eb3ef3 --- /dev/null +++ b/graphview/src/main/java/com/jjoe64/graphview/series/DataPoint.java @@ -0,0 +1,63 @@ +/** + * GraphView + * Copyright (C) 2014 Jonas Gehring + * + * 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 2 of the License, + * with the "Linking Exception", which can be found at the license.txt + * file in this program. + * + * 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 + * with the "Linking Exception" along with this program; if not, + * write to the author Jonas Gehring . + */ +package com.jjoe64.graphview.series; + +import android.provider.ContactsContract; + +import java.io.Serializable; +import java.util.Date; + +/** + * default data point implementation. + * This stores the x and y values. + * + * @author jjoe64 + */ +public class DataPoint implements DataPointInterface, Serializable { + private static final long serialVersionUID=1428263322645L; + + private double x; + private double y; + + public DataPoint(double x, double y) { + this.x=x; + this.y=y; + } + + public DataPoint(Date x, double y) { + this.x = x.getTime(); + this.y = y; + } + + @Override + public double getX() { + return x; + } + + @Override + public double getY() { + return y; + } + + @Override + public String toString() { + return "["+x+"/"+y+"]"; + } +} diff --git a/graphview/src/main/java/com/jjoe64/graphview/series/DataPointInterface.java b/graphview/src/main/java/com/jjoe64/graphview/series/DataPointInterface.java new file mode 100644 index 0000000000..9be683bef8 --- /dev/null +++ b/graphview/src/main/java/com/jjoe64/graphview/series/DataPointInterface.java @@ -0,0 +1,41 @@ +/** + * GraphView + * Copyright (C) 2014 Jonas Gehring + * + * 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 2 of the License, + * with the "Linking Exception", which can be found at the license.txt + * file in this program. + * + * 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 + * with the "Linking Exception" along with this program; if not, + * write to the author Jonas Gehring . + */ +package com.jjoe64.graphview.series; + +/** + * interface of data points. Implement this in order + * to use your class in {@link com.jjoe64.graphview.series.Series}. + * + * You can also use the default implementation {@link com.jjoe64.graphview.series.DataPoint} so + * you do not have to implement it for yourself. + * + * @author jjoe64 + */ +public interface DataPointInterface { + /** + * @return the x value + */ + public double getX(); + + /** + * @return the y value + */ + public double getY(); +} diff --git a/graphview/src/main/java/com/jjoe64/graphview/series/LineGraphSeries.java b/graphview/src/main/java/com/jjoe64/graphview/series/LineGraphSeries.java new file mode 100644 index 0000000000..4d721c2e6c --- /dev/null +++ b/graphview/src/main/java/com/jjoe64/graphview/series/LineGraphSeries.java @@ -0,0 +1,409 @@ +/** + * GraphView + * Copyright (C) 2014 Jonas Gehring + * + * 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 2 of the License, + * with the "Linking Exception", which can be found at the license.txt + * file in this program. + * + * 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 + * with the "Linking Exception" along with this program; if not, + * write to the author Jonas Gehring . + */ +package com.jjoe64.graphview.series; + +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Path; + +import com.jjoe64.graphview.GraphView; + +import java.util.Iterator; + +/** + * Series to plot the data as line. + * The line can be styled with many options. + * + * @author jjoe64 + */ +public class LineGraphSeries extends BaseSeries { + /** + * wrapped styles regarding the line + */ + private final class Styles { + /** + * the thickness of the line. + * This option will be ignored if you are + * using a custom paint via {@link #setCustomPaint(android.graphics.Paint)} + */ + private int thickness = 5; + + /** + * flag whether the area under the line to the bottom + * of the viewport will be filled with a + * specific background color. + * + * @see #backgroundColor + */ + private boolean drawBackground = false; + + /** + * flag whether the data points are highlighted as + * a visible point. + * + * @see #dataPointsRadius + */ + private boolean drawDataPoints = false; + + /** + * the radius for the data points. + * + * @see #drawDataPoints + */ + private float dataPointsRadius = 10f; + + /** + * the background color for the filling under + * the line. + * + * @see #drawBackground + */ + private int backgroundColor = Color.argb(100, 172, 218, 255); + } + + /** + * wrapped styles + */ + private Styles mStyles; + + /** + * internal paint object + */ + private Paint mPaint; + + /** + * paint for the background + */ + private Paint mPaintBackground; + + /** + * path for the background filling + */ + private Path mPathBackground; + + /** + * path to the line + */ + private Path mPath; + + /** + * custom paint that can be used. + * this will ignore the thickness and color styles. + */ + private Paint mCustomPaint; + + /** + * creates a series without data + */ + public LineGraphSeries() { + init(); + } + + /** + * creates a series with data + * + * @param data data points + */ + public LineGraphSeries(E[] data) { + super(data); + init(); + } + + /** + * do the initialization + * creates internal objects + */ + protected void init() { + mStyles = new Styles(); + mPaint = new Paint(); + mPaint.setStrokeCap(Paint.Cap.ROUND); + mPaint.setStyle(Paint.Style.STROKE); + mPaintBackground = new Paint(); + + mPathBackground = new Path(); + mPath = new Path(); + } + + /** + * plots the series + * draws the line and the background + * + * @param graphView graphview + * @param canvas canvas + * @param isSecondScale flag if it is the second scale + */ + @Override + public void draw(GraphView graphView, Canvas canvas, boolean isSecondScale) { + resetDataPoints(); + + // get data + double maxX = graphView.getViewport().getMaxX(false); + double minX = graphView.getViewport().getMinX(false); + + double maxY; + double minY; + if (isSecondScale) { + maxY = graphView.getSecondScale().getMaxY(); + minY = graphView.getSecondScale().getMinY(); + } else { + maxY = graphView.getViewport().getMaxY(false); + minY = graphView.getViewport().getMinY(false); + } + + Iterator values = getValues(minX, maxX); + + // draw background + double lastEndY = 0; + double lastEndX = 0; + + // draw data + mPaint.setStrokeWidth(mStyles.thickness); + mPaint.setColor(getColor()); + mPaintBackground.setColor(mStyles.backgroundColor); + + Paint paint; + if (mCustomPaint != null) { + paint = mCustomPaint; + } else { + paint = mPaint; + } + + if (mStyles.drawBackground) { + mPathBackground.reset(); + } + + double diffY = maxY - minY; + double diffX = maxX - minX; + + float graphHeight = graphView.getGraphContentHeight(); + float graphWidth = graphView.getGraphContentWidth(); + float graphLeft = graphView.getGraphContentLeft(); + float graphTop = graphView.getGraphContentTop(); + + lastEndY = 0; + lastEndX = 0; + double lastUsedEndX = 0; + float firstX = 0; + int i=0; + while (values.hasNext()) { + E value = values.next(); + + double valY = value.getY() - minY; + double ratY = valY / diffY; + double y = graphHeight * ratY; + + double valX = value.getX() - minX; + double ratX = valX / diffX; + double x = graphWidth * ratX; + + double orgX = x; + double orgY = y; + + if (i > 0) { + // overdraw + if (x > graphWidth) { // end right + double b = ((graphWidth - lastEndX) * (y - lastEndY)/(x - lastEndX)); + y = lastEndY+b; + x = graphWidth; + } + if (y < 0) { // end bottom + double b = ((0 - lastEndY) * (x - lastEndX)/(y - lastEndY)); + x = lastEndX+b; + y = 0; + } + if (y > graphHeight) { // end top + double b = ((graphHeight - lastEndY) * (x - lastEndX)/(y - lastEndY)); + x = lastEndX+b; + y = graphHeight; + } + if (lastEndY < 0) { // start bottom + double b = ((0 - y) * (x - lastEndX)/(lastEndY - y)); + lastEndX = x-b; + lastEndY = 0; + } + if (lastEndX < 0) { // start left + double b = ((0 - x) * (y - lastEndY)/(lastEndX - x)); + lastEndY = y-b; + lastEndX = 0; + } + if (lastEndY > graphHeight) { // start top + double b = ((graphHeight - y) * (x - lastEndX)/(lastEndY - y)); + lastEndX = x-b; + lastEndY = graphHeight; + } + + float startX = (float) lastEndX + (graphLeft + 1); + float startY = (float) (graphTop - lastEndY) + graphHeight; + float endX = (float) x + (graphLeft + 1); + float endY = (float) (graphTop - y) + graphHeight; + + // draw data point + if (mStyles.drawDataPoints) { + //fix: last value was not drawn. Draw here now the end values + canvas.drawCircle(endX, endY, mStyles.dataPointsRadius, mPaint); + } + registerDataPoint(endX, endY, value); + + mPath.reset(); + mPath.moveTo(startX, startY); + mPath.lineTo(endX, endY); + canvas.drawPath(mPath, paint); + if (mStyles.drawBackground) { + if (i==1) { + firstX = startX; + mPathBackground.moveTo(startX, startY); + } + mPathBackground.lineTo(endX, endY); + } + lastUsedEndX = endX; + } else if (mStyles.drawDataPoints) { + //fix: last value not drawn as datapoint. Draw first point here, and then on every step the end values (above) + float first_X = (float) x + (graphLeft + 1); + float first_Y = (float) (graphTop - y) + graphHeight; + //TODO canvas.drawCircle(first_X, first_Y, dataPointsRadius, mPaint); + } + lastEndY = orgY; + lastEndX = orgX; + i++; + } + + if (mStyles.drawBackground) { + // end / close path + mPathBackground.lineTo((float) lastUsedEndX, graphHeight + graphTop); + mPathBackground.lineTo(firstX, graphHeight + graphTop); + mPathBackground.close(); + canvas.drawPath(mPathBackground, mPaintBackground); + } + + } + + /** + * the thickness of the line. + * This option will be ignored if you are + * using a custom paint via {@link #setCustomPaint(android.graphics.Paint)} + * + * @return the thickness of the line + */ + public int getThickness() { + return mStyles.thickness; + } + + /** + * the thickness of the line. + * This option will be ignored if you are + * using a custom paint via {@link #setCustomPaint(android.graphics.Paint)} + * + * @param thickness thickness of the line + */ + public void setThickness(int thickness) { + mStyles.thickness = thickness; + } + + /** + * flag whether the area under the line to the bottom + * of the viewport will be filled with a + * specific background color. + * + * @return whether the background will be drawn + * @see #getBackgroundColor() + */ + public boolean isDrawBackground() { + return mStyles.drawBackground; + } + + /** + * flag whether the area under the line to the bottom + * of the viewport will be filled with a + * specific background color. + * + * @param drawBackground whether the background will be drawn + * @see #setBackgroundColor(int) + */ + public void setDrawBackground(boolean drawBackground) { + mStyles.drawBackground = drawBackground; + } + + /** + * flag whether the data points are highlighted as + * a visible point. + * + * @return flag whether the data points are highlighted + * @see #setDataPointsRadius(float) + */ + public boolean isDrawDataPoints() { + return mStyles.drawDataPoints; + } + + /** + * flag whether the data points are highlighted as + * a visible point. + * + * @param drawDataPoints flag whether the data points are highlighted + * @see #setDataPointsRadius(float) + */ + public void setDrawDataPoints(boolean drawDataPoints) { + mStyles.drawDataPoints = drawDataPoints; + } + + /** + * @return the radius for the data points. + * @see #setDrawDataPoints(boolean) + */ + public float getDataPointsRadius() { + return mStyles.dataPointsRadius; + } + + /** + * @param dataPointsRadius the radius for the data points. + * @see #setDrawDataPoints(boolean) + */ + public void setDataPointsRadius(float dataPointsRadius) { + mStyles.dataPointsRadius = dataPointsRadius; + } + + /** + * @return the background color for the filling under + * the line. + * @see #setDrawBackground(boolean) + */ + public int getBackgroundColor() { + return mStyles.backgroundColor; + } + + /** + * @param backgroundColor the background color for the filling under + * the line. + * @see #setDrawBackground(boolean) + */ + public void setBackgroundColor(int backgroundColor) { + mStyles.backgroundColor = backgroundColor; + } + + /** + * custom paint that can be used. + * this will ignore the thickness and color styles. + * + * @param customPaint the custom paint to be used for rendering the line + */ + public void setCustomPaint(Paint customPaint) { + this.mCustomPaint = customPaint; + } +} diff --git a/graphview/src/main/java/com/jjoe64/graphview/series/OnDataPointTapListener.java b/graphview/src/main/java/com/jjoe64/graphview/series/OnDataPointTapListener.java new file mode 100644 index 0000000000..748a1122ee --- /dev/null +++ b/graphview/src/main/java/com/jjoe64/graphview/series/OnDataPointTapListener.java @@ -0,0 +1,38 @@ +/** + * GraphView + * Copyright (C) 2014 Jonas Gehring + * + * 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 2 of the License, + * with the "Linking Exception", which can be found at the license.txt + * file in this program. + * + * 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 + * with the "Linking Exception" along with this program; if not, + * write to the author Jonas Gehring . + */ +package com.jjoe64.graphview.series; + +/** + * Listener for the tap event which will be + * triggered when the user touches on a datapoint. + * + * Use this in {@link com.jjoe64.graphview.series.BaseSeries#setOnDataPointTapListener(OnDataPointTapListener)} + * + * @author jjoe64 + */ +public interface OnDataPointTapListener { + /** + * gets called when the user touches on a datapoint. + * + * @param series the corresponding series + * @param dataPoint the data point that was tapped on + */ + void onTap(Series series, DataPointInterface dataPoint); +} diff --git a/graphview/src/main/java/com/jjoe64/graphview/series/PointsGraphSeries.java b/graphview/src/main/java/com/jjoe64/graphview/series/PointsGraphSeries.java new file mode 100644 index 0000000000..c57d476d7b --- /dev/null +++ b/graphview/src/main/java/com/jjoe64/graphview/series/PointsGraphSeries.java @@ -0,0 +1,312 @@ +/** + * GraphView + * Copyright (C) 2014 Jonas Gehring + * + * 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 2 of the License, + * with the "Linking Exception", which can be found at the license.txt + * file in this program. + * + * 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 + * with the "Linking Exception" along with this program; if not, + * write to the author Jonas Gehring . + */ +package com.jjoe64.graphview.series; + +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.Point; + +import com.jjoe64.graphview.GraphView; + +import java.util.Iterator; + +/** + * Series that plots the data as points. + * The points can be different shapes or a + * complete custom drawing. + * + * @author jjoe64 + */ +public class PointsGraphSeries extends BaseSeries { + /** + * interface to implement a custom + * drawing for the data points. + */ + public static interface CustomShape { + /** + * called when drawing a single data point. + * use the x and y coordinates to render your + * drawing at this point. + * + * @param canvas canvas to draw on + * @param paint internal paint object. this has the correct color. + * But you can use your own paint. + * @param x x-coordinate the point has to be drawn to + * @param y y-coordinate the point has to be drawn to + * @param dataPoint the related data point + */ + void draw(Canvas canvas, Paint paint, float x, float y, DataPointInterface dataPoint); + } + + /** + * choose a predefined shape to render for + * each data point. + * You can also render a custom drawing via {@link com.jjoe64.graphview.series.PointsGraphSeries.CustomShape} + */ + public enum Shape { + /** + * draws a point / circle + */ + POINT, + + /** + * draws a triangle + */ + TRIANGLE, + + /** + * draws a rectangle + */ + RECTANGLE + } + + /** + * wrapped styles for this series + */ + private final class Styles { + /** + * this is used for the size of the shape that + * will be drawn. + * This is useless if you are using a custom shape. + */ + float size; + + /** + * the shape that will be drawn for each point. + */ + Shape shape; + } + + /** + * wrapped styles + */ + private Styles mStyles; + + /** + * internal paint object + */ + private Paint mPaint; + + /** + * handler to use a custom drawing + */ + private CustomShape mCustomShape; + + /** + * creates the series without data + */ + public PointsGraphSeries() { + init(); + } + + /** + * creates the series with data + * + * @param data datapoints + */ + public PointsGraphSeries(E[] data) { + super(data); + init(); + } + + /** + * inits the internal objects + * set the defaults + */ + protected void init() { + mStyles = new Styles(); + mStyles.size = 20f; + mPaint = new Paint(); + mPaint.setStrokeCap(Paint.Cap.ROUND); + setShape(Shape.POINT); + } + + /** + * plot the data to the viewport + * + * @param graphView graphview + * @param canvas canvas to draw on + * @param isSecondScale whether it is the second scale + */ + @Override + public void draw(GraphView graphView, Canvas canvas, boolean isSecondScale) { + resetDataPoints(); + + // get data + double maxX = graphView.getViewport().getMaxX(false); + double minX = graphView.getViewport().getMinX(false); + + double maxY; + double minY; + if (isSecondScale) { + maxY = graphView.getSecondScale().getMaxY(); + minY = graphView.getSecondScale().getMinY(); + } else { + maxY = graphView.getViewport().getMaxY(false); + minY = graphView.getViewport().getMinY(false); + } + + Iterator values = getValues(minX, maxX); + + // draw background + double lastEndY = 0; + double lastEndX = 0; + + // draw data + mPaint.setColor(getColor()); + + double diffY = maxY - minY; + double diffX = maxX - minX; + + float graphHeight = graphView.getGraphContentHeight(); + float graphWidth = graphView.getGraphContentWidth(); + float graphLeft = graphView.getGraphContentLeft(); + float graphTop = graphView.getGraphContentTop(); + + lastEndY = 0; + lastEndX = 0; + float firstX = 0; + int i=0; + while (values.hasNext()) { + E value = values.next(); + + double valY = value.getY() - minY; + double ratY = valY / diffY; + double y = graphHeight * ratY; + + double valX = value.getX() - minX; + double ratX = valX / diffX; + double x = graphWidth * ratX; + + double orgX = x; + double orgY = y; + + // overdraw + boolean overdraw = false; + if (x > graphWidth) { // end right + overdraw = true; + } + if (y < 0) { // end bottom + overdraw = true; + } + if (y > graphHeight) { // end top + overdraw = true; + } + + float endX = (float) x + (graphLeft + 1); + float endY = (float) (graphTop - y) + graphHeight; + registerDataPoint(endX, endY, value); + + // draw data point + if (!overdraw) { + if (mCustomShape != null) { + mCustomShape.draw(canvas, mPaint, endX, endY, value); + } else if (mStyles.shape == Shape.POINT) { + canvas.drawCircle(endX, endY, mStyles.size, mPaint); + } else if (mStyles.shape == Shape.RECTANGLE) { + canvas.drawRect(endX-mStyles.size, endY-mStyles.size, endX+mStyles.size, endY+mStyles.size, mPaint); + } else if (mStyles.shape == Shape.TRIANGLE) { + Point[] points = new Point[3]; + points[0] = new Point((int)endX, (int)(endY-getSize())); + points[1] = new Point((int)(endX+getSize()), (int)(endY+getSize()*0.67)); + points[2] = new Point((int)(endX-getSize()), (int)(endY+getSize()*0.67)); + drawArrows(points, canvas, mPaint); + } + } + + i++; + } + + } + + /** + * helper to render triangle + * + * @param point array with 3 coordinates + * @param canvas canvas to draw on + * @param paint paint object + */ + private void drawArrows(Point[] point, Canvas canvas, Paint paint) { + float [] points = new float[8]; + points[0] = point[0].x; + points[1] = point[0].y; + points[2] = point[1].x; + points[3] = point[1].y; + points[4] = point[2].x; + points[5] = point[2].y; + points[6] = point[0].x; + points[7] = point[0].y; + + canvas.drawVertices(Canvas.VertexMode.TRIANGLES, 8, points, 0, null, 0, null, 0, null, 0, 0, paint); + Path path = new Path(); + path.moveTo(point[0].x , point[0].y); + path.lineTo(point[1].x,point[1].y); + path.lineTo(point[2].x,point[2].y); + canvas.drawPath(path,paint); + } + + /** + * This is used for the size of the shape that + * will be drawn. + * This is useless if you are using a custom shape. + * + * @return the size of the shape + */ + public float getSize() { + return mStyles.size; + } + + /** + * This is used for the size of the shape that + * will be drawn. + * This is useless if you are using a custom shape. + * + * @param radius the size of the shape + */ + public void setSize(float radius) { + mStyles.size = radius; + } + + /** + * @return the shape that will be drawn for each point + */ + public Shape getShape() { + return mStyles.shape; + } + + /** + * @param s the shape that will be drawn for each point + */ + public void setShape(Shape s) { + mStyles.shape = s; + } + + /** + * Use a custom handler to render your own + * drawing for each data point. + * + * @param shape handler to use a custom drawing + */ + public void setCustomShape(CustomShape shape) { + mCustomShape = shape; + } +} diff --git a/graphview/src/main/java/com/jjoe64/graphview/series/Series.java b/graphview/src/main/java/com/jjoe64/graphview/series/Series.java new file mode 100644 index 0000000000..dce32eb78e --- /dev/null +++ b/graphview/src/main/java/com/jjoe64/graphview/series/Series.java @@ -0,0 +1,125 @@ +/** + * GraphView + * Copyright (C) 2014 Jonas Gehring + * + * 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 2 of the License, + * with the "Linking Exception", which can be found at the license.txt + * file in this program. + * + * 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 + * with the "Linking Exception" along with this program; if not, + * write to the author Jonas Gehring . + */ +package com.jjoe64.graphview.series; + +import android.graphics.Canvas; + +import com.jjoe64.graphview.GraphView; + +import java.util.Iterator; + +/** + * Basis interface for series that can be plotted + * on the graph. + * You can implement this in order to create a completely + * custom series type. + * But it is recommended to extend {@link com.jjoe64.graphview.series.BaseSeries} or another + * implemented Series class to save time. + * Anyway this interface can make sense if you want to implement + * a custom data provider, because BaseSeries uses a internal Array to store + * the data. + * + * @author jjoe64 + */ +public interface Series { + /** + * @return the lowest x-value of the data + */ + public double getLowestValueX(); + + /** + * @return the highest x-value of the data + */ + public double getHighestValueX(); + + /** + * @return the lowest y-value of the data + */ + public double getLowestValueY(); + + /** + * @return the highest y-value of the data + */ + public double getHighestValueY(); + + /** + * get the values for a specific range. It is + * important that the data comes in the sorted order + * (from lowest to highest x-value). + * + * @param from the minimal x-value + * @param until the maximal x-value + * @return all datapoints between the from and until x-value + * including the from and until data points. + */ + public Iterator getValues(double from, double until); + + /** + * Plots the series to the viewport. + * You have to care about overdrawing. + * This method may be called 2 times: one for + * the default scale and one time for the + * second scale. + * + * @param graphView corresponding graphview + * @param canvas canvas to draw on + * @param isSecondScale true if the drawing is for the second scale + */ + public void draw(GraphView graphView, Canvas canvas, boolean isSecondScale); + + /** + * @return the title of the series. Used in the legend + */ + public String getTitle(); + + /** + * @return the color of the series. Used in the legend and should + * be used for the plotted points or lines. + */ + public int getColor(); + + /** + * set a listener for tap on a data point. + * + * @param l listener + */ + public void setOnDataPointTapListener(OnDataPointTapListener l); + + /** + * called by the tap detector in order to trigger + * the on tap on datapoint event. + * + * @param x pixel + * @param y pixel + */ + void onTap(float x, float y); + + /** + * called when the series was added to a graph + * + * @param graphView graphview + */ + void onGraphViewAttached(GraphView graphView); + + /** + * @return whether there are data points + */ + boolean isEmpty(); +} diff --git a/graphview/src/main/res/values/attr.xml b/graphview/src/main/res/values/attr.xml new file mode 100644 index 0000000000..8b73838605 --- /dev/null +++ b/graphview/src/main/res/values/attr.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/settings.gradle b/settings.gradle index 20cc1cc2b6..b11f11adfc 100644 --- a/settings.gradle +++ b/settings.gradle @@ -19,3 +19,4 @@ include ':diaconn' include ':openhumans' include ':shared' include ':iconify' +include ':graphview'