Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Custom dynamic graph in Android

[UPDATE] To conclude this question, I implemented my graph using the following two methods (see below). drawCurve() receives a Canvas and an array of float. The array is properly filled (timestamps are assumed by the value index in the array) and varies from 0.0 to 1.0. The array is sent to prepareWindowArray() that takes a chunk of the array from position windowStart for windowSize-values, in a circular manner.

The array used by the GraphView and by the data provider (a Bluetooth device) is the same. A Class in the middle ensures that GraphView is not reading data that are being written by the Bluetooth device. Since the GraphView always loop thru the array and redraw it at every iteration, it will update according to the data written by the Bluetooth device, and by forcing the write frequency of the Bluetooth device to the refresh frequency of the Graph, I obtain a smooth animation of my signal.

The GraphView's invalidate() method is called by the Activity, which run a Timer to refresh the graph at every x milliseconds. The frequency at which the graph is refreshed is dynamically set, so that it adapt to the flow of data from the Bluetooth device (which specify the frequency of its signal in the header of its packet).

Find the complete code of my GraphView in the answer I wrote below (in the answer section). If you guys find errors or way to optimize it, please let me know; it would be greatly appreciated!

/**
 * Read a buffer array of size greater than "windowSize" and create a window array out of it.
 * A curve is then drawn from this array using "windowSize" points, from left
 * to right.
 * @param canvas is a Canvas object on which the curve will be drawn.  Ensure the canvas is the
 * later drawn object at its position or you will not see your curve.
 * @param data is a float array of length > windowSize.  The floats must range between 0.0 and 1.0.
 * A value of 0.0 will be drawn at the bottom of the graph, while a value of 1.0 will be drawn at 
 * the top of the graph.  The range is not tested, so you must ensure to pass proper values, or your
 * graph will look terrible. 
 *      0.0  : draw at the bottom of the graph
 *      0.5  : draw in the middle of the graph
 *      1.0  : draw at the top of the graph
 */
private void drawCurve(Canvas canvas, float[] data){

    // Create a reference value to determine the stepping between each points to be drawn
    float incrementX = (mRightSide-mLeftSide)/(float) windowSize;

    float incrementY = (mBottomSide - mTopSide);

    // Prepare the array for the graph
    float[] source = prepareWindowArray(data);

    // Prepare the curve Path
    curve = new Path();
    // Move at the first point.
    curve.moveTo(mLeftSide, source[0]*incrementY);
    // Draw the remaining points of the curve
    for(int i = 1; i < windowSize; i++){
        curve.lineTo(mLeftSide + (i*incrementX), source[i] * incrementY);
    }

    canvas.drawPath(curve, curvePaint);

}

The prepareWindowArray() method that implement the circular behavior of the array:

/**
 * Extract a window array from the data array, and reposition the windowStart 
 * index for next iteration
 * @param data the array of data from which we get the window
 * @return an array of float that represent the window
 */
private float[] prepareWindowArray(float[] data){
    // Prepare the source array for the graph.
    float[] source = new float[windowSize];

    // Copy the window from the data array into the source array
    for(int i = 0; i < windowSize; i++){
        if(windowStart+i < data.length)                         // If the windows holds within the data array
            source[i] = data[windowStart + i];                  // Simply copy the value in the source array
        else{                                                   // If the window goes beyond the data array
            source[i] = data[(windowStart + 1)%data.length];    // Loop at the beginning of the data array and copy from there
        }
    }
    // Reposition the buffer index
    windowStart = windowStart + windowSize;
    // If the index is beyond the end of the array
    if(windowStart >= data.length){
        windowStart = windowStart % data.length;
    }

    return source;
}

[/UPDATE]

I'm making an app that read data from a Bluetooth device at a fixed rate. Everytime that I have new data, I want them to be plotted on the graph to the right, and to translate the remainder of the graph to the left in realtime. Basically, like an oscilloscope would do.

So I made a custom View, with xy axis, a title and units. To do this, I simply draw those things on the View canvas. Now I want to draw the curve. I manage to draw a static curve from an already filled array using this method:

public void drawCurve(Canvas canvas){

    int left = getPaddingLeft();
    int bottom = getHeight()-getPaddingTop();
    int middle = (bottom-10)/2 - 10;

    curvePaint = new Paint();
    curvePaint.setColor(Color.GREEN);
    curvePaint.setStrokeWidth(1f);
    curvePaint.setDither(true);
    curvePaint.setStyle(Paint.Style.STROKE);
    curvePaint.setStrokeJoin(Paint.Join.ROUND);
    curvePaint.setStrokeCap(Paint.Cap.ROUND);
    curvePaint.setPathEffect(new CornerPathEffect(10) );
    curvePaint.setAntiAlias(true);

    mCurve = new Path();
    mCurve.moveTo(left, middle);
    for(int i = 0; i < mData[0].length; i++)
        mCurve.lineTo(left + ((float)mData[0][i] * 5), middle-((float)mData[1][i] * 20));


    canvas.drawPath(mCurve, curvePaint);
}

It gives me something like this.

My custom GraphView

There are still things to fix on my graph (the sub-axis are not properly scaling), but these are details I can fix later.

Now I want to change this static graph (that receives a non-dynamic matrice of values) with something dynamic that would redraw the curve every 40ms, pushing the old data to the left and plotting the new data to the right, so I could visualise in real time the information provided by the Bluetooth device.

I know there are some graphing package that exists already, but I'm kinda noob with these things and I'd like to pratice by implementing this graph myself. Also, most of my GraphView class is done, except for the curve part.

Second question, I'm wondering how I should send the new values to the graph. Should I use something like a FIFO stack, or can I achieve what I want with a simple matrice of doubles?

On a side note, the 4 fields at the bottom are already dynamically updated. Well, they are kind of faking the "dynamic", they loop thru the same double matrice again and again, they don't actually take fresh values.

Thanks for your time! If something's unclear about my question, let me know and I'll update it with more details.

like image 729
AntoineG Avatar asked Oct 09 '11 03:10

AntoineG


2 Answers

As mentioned in my question, here's the class that I designed to solve my problems.

/**
 * A View implementation that displays a scatter graph with 
 * automatic unit scaling.
 * 
 * Call the <i>setupGraph()</i> method to modify the graph's
 * properties.
 * @author Antoine Grondin
 *
 */

public class GraphView extends View {

    //////////////////////////////////////////////////////////////////
    // Configuration
    //////////////////////////////////////////////////////////////////

    // Set to true to impose the graph properties
    private static final boolean TEST = false;  

    // Scale configuration
    private float minX = 0;         // When TEST is true, these values are used to
    private float maxX = 50;        // Draw the graph
    private float minY = 0;
    private float maxY = 100;

    private String titleText = "A Graph...";
    private String xUnitText = "s";
    private String yUnitText = "Volts";

    // Debugging variables
    private boolean D = true;
    private String TAG = "GraphView";

    //////////////////////////////////////////////////////////////////
    // Member fields
    //////////////////////////////////////////////////////////////////

    // Represent the borders of the View
    private int mTopSide = 0;
    private int mLeftSide = 0;
    private int mRightSide = 0;
    private int mBottomSide = 0;
    private int mMiddleX = 0;
    // Size of a DensityIndependentPixel
    private float mDips = 0;

    // Hold the position of the axis in regard to the range of values
    private int positionOfX = 0;
    private int positionOfY = 0;

    // Index for the graph array window, and size of the window
    private int windowStart = 0;
    private int windowSize = 128;
    private float[] dataSource;

    // Painting tools
    private Paint xAxisPaint;
    private Paint yAxisPaint;
    private Paint tickPaint;
    private Paint curvePaint;
    private Paint backgroundPaint;

    private TextPaint unitTextPaint;
    private TextPaint titleTextPaint;

    // Object to be drawn

    private Path curve;
    private Bitmap background;

    ///////////////////////////////////////////////////////////////////////////////
    // Constructors
    ///////////////////////////////////////////////////////////////////////////////

    public GraphView(Context context) {
        super(context);
        init();
    }

    public GraphView(Context context, AttributeSet attrs){
        super(context, attrs);
        init();
    }

    public GraphView(Context context, AttributeSet attrs, int defStyle){
        super(context, attrs, defStyle);
        init();
    }

    ///////////////////////////////////////////////////////////////////////////////
    // Configuration methods
    /////////////////////////////////////////////////////////////////////////////// 

    public void setupGraph(String title, String nameOfX, float min_X, float max_X, String nameOfY, float min_Y, float max_Y){
        if(!TEST){
            titleText = title;
            xUnitText = nameOfX;
            yUnitText = nameOfY;
            minX = min_X;
            maxX = max_X;
            minY = min_Y;
            maxY = max_Y;
        }
    }

    /**
     * Set the array this GraphView is to work with.
     * @param data is a float array of length > windowSize.  The floats must range between 0.0 and 1.0.
     * A value of 0.0 will be drawn at the bottom of the graph, while a value of 1.0 will be drawn at 
     * the top of the graph.  The range is not tested, so you must ensure to pass proper values, or your
     * graph will look terrible.
     *      0.0  : draw at the bottom of the graph
     *      0.5  : draw in the middle of the graph
     *      1.0  : draw at the top of the graph
     */
    public void setDataSource(float[] data){
        this.dataSource = data;
    }

    ///////////////////////////////////////////////////////////////////////////////
    // Initialization methods
    /////////////////////////////////////////////////////////////////////////////// 

    private void init(){
        initDrawingTools();
    }

    private void initConstants(){
        mDips = getResources().getDisplayMetrics().density;
        mTopSide = (int) (getTop() + 10*mDips);
        mLeftSide = (int) (getLeft() + 10*mDips);
        mRightSide = (int) (getMeasuredWidth() - 10*mDips);
        mBottomSide = (int) (getMeasuredHeight() - 10*mDips);
        mMiddleX = (mRightSide - mLeftSide)/2 + mLeftSide;
    }

    private void initWindowSetting() throws IllegalArgumentException {

        // Don't do anything if the given values make no sense
        if(maxX < minX || maxY < minY ||
                maxX == minX || maxY == minY){
            throw new IllegalArgumentException("Max and min values make no sense");
        }
        // Transform the values in scanable items
        float[][] maxAndMin = new float[][]{
                {minX, maxX},
                {minY, maxY}};
        int[] positions = new int[]{positionOfY, positionOfX};

        // Place the X and Y axis in regard to the given max and min
        for(int i = 0; i<2; i++){
            if(maxAndMin[i][0] < 0f){
                if(maxAndMin[i][1] < 0f){
                    positions[i] = (int) maxAndMin[i][0];
                } else{
                    positions[i] = 0;
                }
            } else if (maxAndMin[i][0] > 0f){
                positions[i] = (int) maxAndMin[i][0];
            } else {
                positions[i] = 0;
            }
        }

        // Put the values back in their right place
        minX = maxAndMin[0][0];
        maxX = maxAndMin[0][1];
        minY = maxAndMin[1][0];
        maxY = maxAndMin[1][1];

        positionOfY = mLeftSide +  (int) (((positions[0] - minX)/(maxX-minX))*(mRightSide - mLeftSide));    
        positionOfX = mBottomSide - (int) (((positions[1] - minY)/(maxY-minY))*(mBottomSide - mTopSide));
    }

    private void initDrawingTools(){

        xAxisPaint = new Paint();
        xAxisPaint.setColor(0xff888888);
        xAxisPaint.setStrokeWidth(1f*mDips);
        xAxisPaint.setAlpha(0xff);
        xAxisPaint.setAntiAlias(true);

        yAxisPaint = xAxisPaint;

        tickPaint = xAxisPaint;
        tickPaint.setColor(0xffaaaaaa);

        curvePaint = new Paint();
        curvePaint.setColor(0xff00ff00);
        curvePaint.setStrokeWidth(1f*mDips);
        curvePaint.setDither(true);
        curvePaint.setStyle(Paint.Style.STROKE);
        curvePaint.setStrokeJoin(Paint.Join.ROUND);
        curvePaint.setStrokeCap(Paint.Cap.ROUND);
        curvePaint.setPathEffect(new CornerPathEffect(10));
        curvePaint.setAntiAlias(true);

        backgroundPaint = new Paint();
        backgroundPaint.setFilterBitmap(true);

        titleTextPaint = new TextPaint();
        titleTextPaint.setAntiAlias(true);
        titleTextPaint.setColor(0xffffffff);
        titleTextPaint.setTextAlign(Align.CENTER);
        titleTextPaint.setTextSize(20f*mDips);
        titleTextPaint.setTypeface(Typeface.MONOSPACE);

        unitTextPaint = new TextPaint();
        unitTextPaint.setAntiAlias(true);
        unitTextPaint.setColor(0xff888888);
        unitTextPaint.setTextAlign(Align.CENTER);
        unitTextPaint.setTextSize(20f*mDips);
        unitTextPaint.setTypeface(Typeface.MONOSPACE);

    }

    ///////////////////////////////////////////////////////////////////////////////
    // Overridden methods
    /////////////////////////////////////////////////////////////////////////////// 

    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    }

    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        regenerateBackground();
    }

    public void onDraw(Canvas canvas){
        drawBackground(canvas);
        if(dataSource != null)
            drawCurve(canvas, dataSource);
    }

    ///////////////////////////////////////////////////////////////////////////////
    // Drawing methods
    /////////////////////////////////////////////////////////////////////////////// 

    private void drawX(Canvas canvas){
        canvas.drawLine(mLeftSide, positionOfX, mRightSide, positionOfX, xAxisPaint);
        canvas.drawText(xUnitText, mRightSide -  unitTextPaint.measureText(xUnitText)/2, positionOfX - unitTextPaint.getTextSize()/2, unitTextPaint);
    }

    private void drawY(Canvas canvas){
        canvas.drawLine(positionOfY, mTopSide, positionOfY, mBottomSide, yAxisPaint);
        canvas.drawText(yUnitText, positionOfY + unitTextPaint.measureText(yUnitText)/2 + 4*mDips, mTopSide + (int) (unitTextPaint.getTextSize()/2), unitTextPaint);
    }

    private void drawTick(Canvas canvas){
        // No tick at this time
        // TODO decide how I want to put those ticks, if I want them
    }

    private void drawTitle(Canvas canvas){
        canvas.drawText(titleText, mMiddleX, mTopSide + (int) (titleTextPaint.getTextSize()/2), titleTextPaint);
    }

    /**
     * Read a buffer array of size greater than "windowSize" and create a window array out of it.
     * A curve is then drawn from this array using "windowSize" points, from left
     * to right.
     * @param canvas is a Canvas object on which the curve will be drawn.  Ensure the canvas is the
     * later drawn object at its position or you will not see your curve.
     * @param data is a float array of length > windowSize.  The floats must range between 0.0 and 1.0.
     * A value of 0.0 will be drawn at the bottom of the graph, while a value of 1.0 will be drawn at 
     * the top of the graph.  The range is not tested, so you must ensure to pass proper values, or your
     * graph will look terrible. 
     *      0.0  : draw at the bottom of the graph
     *      0.5  : draw in the middle of the graph
     *      1.0  : draw at the top of the graph
     */
    private void drawCurve(Canvas canvas, float[] data){

        // Create a reference value to determine the stepping between each points to be drawn
        float incrementX = (mRightSide-mLeftSide)/(float) windowSize;

        float incrementY = mBottomSide - mTopSide;

        // Prepare the array for the graph
        float[] source = prepareWindowArray(data);

        // Prepare the curve Path
        curve = new Path();
        // Move at the first point.
        curve.moveTo(mLeftSide, source[0]*incrementY);
        // Draw the remaining points of the curve
        for(int i = 1; i < windowSize; i++){
            curve.lineTo(mLeftSide + (i*incrementX), source[i] * incrementY);
        }

        canvas.drawPath(curve, curvePaint);
    }

    ///////////////////////////////////////////////////////////////////////////////
    // Intimate methods
    /////////////////////////////////////////////////////////////////////////////// 

    /**
     * When asked to draw the background, this method will verify if a bitmap of the
     * background is available.  If not, it will regenerate one.  Then, it will draw
     * the background using this bitmap.  The use of a bitmap to draw the background
     * is to avoid unnecessary processing for static parts of the view.
     */
    private void drawBackground(Canvas canvas){
        if(background == null){
            regenerateBackground();
        } 
        canvas.drawBitmap(background, 0, 0, backgroundPaint);
    }

    /**
     * Call this method to force the <i>GraphView</i> to redraw the cache of it's background,
     * using new properties if you changed them with <i>setupGraph()</i>.
     */
    public void regenerateBackground(){
        initConstants();
        try{
            initWindowSetting();
        } catch (IllegalArgumentException e){
            Log.e(TAG, "Could not initalize windows.", e);
            return;
        }
        if(background != null){
            background.recycle();
        }
        background = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
        Canvas backgroundCanvas = new Canvas(background);

        drawX(backgroundCanvas);
        drawY(backgroundCanvas);
        drawTick(backgroundCanvas);
        drawTitle(backgroundCanvas);

    }

    /**
     * Extract a window array from the data array, and reposition the windowStart 
     * index for next iteration
     * @param data the array of data from which we get the window
     * @return an array of float that represent the window
     */
    private float[] prepareWindowArray(float[] data){
        // Prepare the source array for the graph.
        float[] source = new float[windowSize];

        // Copy the window from the data array into the source array
        for(int i = 0; i < windowSize; i++){
            if(windowStart+i < data.length)                         // If the windows holds within the data array
                source[i] = data[windowStart + i];                  // Simply copy the value in the source array
            else{                                                   // If the window goes beyond the data array
                source[i] = data[(windowStart + 1)%data.length];    // Loop at the beginning of the data array and copy from there
            }
        }
        // Reposition the buffer index
        windowStart = windowStart + windowSize;
        // If the index is beyond the end of the array
        if(windowStart >= data.length){
            windowStart = windowStart % data.length;
        }

        return source;
    }
}
like image 182
AntoineG Avatar answered Nov 17 '22 06:11

AntoineG


Well I would start by just trying to redraw it all with the code you have and real dynalic data. Only if that is not quick enough do you need to try anything fancy like scrolling...

If you need fancy I would try somthing like this.

I would draw the dynamic part of the graph into a secondary Bitmap that you keep between frames rather than directly to the canves. I would have the background none dynamic part of the graph in another bitmap that only gets drawen on rescale etc.

In this secondary dynamic bitmap when ploting new data you first need to clear the old data you are replacing you do this by drawing the apropriate slice of the static background bitmap over the top of the stale data, thus clearing it and geting the background nice and fresh again. You then just need to draw your new bit of dynamic data. The trick is that You draw into this second bitmap left to right then just wrap back to the left at the end and start over.

To get from the soncodary bitmap to your cancas draw the bitmap to the canvas in two parts. The older data to the right of what you just added needs to be drawn onto the left part of your final canvas and the new data needs to be drawn imediatly to the right of it.

For sending the data a circular buffer would be the normal thing for this sort of data where once it's off the graph you don't care about it.

like image 24
Ifor Avatar answered Nov 17 '22 07:11

Ifor