LineChart

Creating QML Controls From Scratch: LineChart

By Chris Cortopassi

LineChart

Continuing our QML Controls from Scratch series, this time we will implement a LineChart. LineChart is similar to BarChart but with two exceptions: (1) it requires x axis tick marks and (2) it uses Canvas to draw the line curve. This is our first control to use Canvas, which is a rectangular area on which to draw with a Context2D. The public interface consists of a title, yLabel, xLabel, a list of points, and the color of the line.


Note: If using Qt 4 and/or QtQuick 1, replace Canvas either by a custom QDeclarativeItem or an Image fed by a QDeclarativeImageProvider.

LineChart.qml

import QtQuick 2.0

Item {
    id: root

 // public
    property string title:  'title'
    property string yLabel: 'yLabel'
    property string xLabel: 'xLabel'

    property variant points: []//{x: 0, y: 0}, {x: 1, y: 2}]
    property string  color: 'red'

 // private
    property double factor: Math.min(width, height)

    property double yInterval:  1 // set by onPointsChanged
    property double yMaximum:  10
    property double yMinimum:   0
    function toYPixels(y){return -plot.height / (yMaximum - yMinimum) * (y - yMinimum) + plot.height}

    property double xInterval:  1 // set by onPointsChanged
    property double xMaximum:  10
    property double xMinimum:   0
    function toXPixels(x){return plot.width  / (xMaximum - xMinimum) * (x - xMinimum)}

    onPointsChanged: { // auto scale
        var xMinimum = 0, xMaximum = 0, yMinimum = 0, yMaximum = 0
        for(var i = 0; i < points.length; i++) {
            if(points[i].y > yMaximum)  yMaximum = points[i].y
            if(points[i].y < yMinimum)  yMinimum = points[i].y
            if(points[i].x > xMaximum)  xMaximum = points[i].x
            if(points[i].x < xMinimum)  xMinimum = points[i].x
        }

        var yLog10     = Math.log(yMaximum - yMinimum) / Math.LN10 //  take log, convert to integer, and then raise 10 to this power
        root.yInterval = Math.pow(10, Math.floor(yLog10)) / 2 // distance between ticks
        root.yMaximum  = Math.ceil( yMaximum / yInterval) * yInterval
        root.yMinimum  = Math.floor(yMinimum / yInterval) * yInterval

        var xLog10     = Math.log(xMaximum - xMinimum) / Math.LN10 //  take log, convert to integer, and then raise 10 to this power
        root.xInterval = Math.pow(10, Math.floor(xLog10)) // distance between ticks
        root.xMaximum  = Math.ceil( xMaximum / xInterval) * xInterval
        root.xMinimum  = Math.floor(xMinimum / xInterval) * xInterval

        canvas.requestPaint()
    }

    width: 500;  height: 500 // default size
    
    Text { // title
        text: title
        anchors.horizontalCenter: parent.horizontalCenter
        font.pixelSize: 0.03 * factor
    }
    
    Text { // y label
        text: yLabel
        font.pixelSize: 0.03 * factor
        y: 0.5 * (2 * plot.y + plot.height + width)
        rotation: -90
        transformOrigin: Item.TopLeft
    }    

    Text { // x label
        text: xLabel
        font.pixelSize: 0.03 * factor
        anchors{bottom: parent.bottom;  horizontalCenter: plot.horizontalCenter}
    }
    
    Item { // plot
        id: plot

        anchors{fill: parent;  topMargin: 0.05 * factor;  bottomMargin: 0.1 * factor;  leftMargin: 0.15 * factor;  rightMargin: 0.05 * factor}
        
        Repeater { // y axis tick marks and labels
            model: Math.floor((yMaximum - yMinimum) / yInterval) + 1 // number of tick marks

            delegate: Rectangle {
                property double value: index * yInterval + yMinimum
                y: toYPixels(value)
                width: plot.width;  height: value? 1: 3
                color: 'black'

                Text {
                    text: parseFloat(parent.value.toPrecision(9)).toString()
                    anchors{right: parent.left;  verticalCenter: parent.verticalCenter;  margins: 0.01 * factor}
                    font.pixelSize: 0.03 * factor
                }
            }
        }

        Repeater { // x axis tick marks and labels
            model: Math.floor((xMaximum - xMinimum) / xInterval) + 1 // number of tick marks

            delegate: Rectangle {
                property double value: index * xInterval + xMinimum
                x: toXPixels(value)
                width: value? 1: 3;  height: plot.height;
                color: 'black'

                Text {
                    text: parseFloat(parent.value.toPrecision(9)).toString()
                    anchors{top: parent.bottom;  horizontalCenter: parent.horizontalCenter;  margins: 0.01 * factor}
                    font.pixelSize: 0.03 * factor
                }
            }
        }
        
        Canvas { // points
            id: canvas

            anchors.fill: parent

            onPaint: {
                var context = getContext("2d")
                context.clearRect(0, 0, width, height) // new points data (animation)
                context.strokeStyle = color
                context.lineWidth   = 0.005 * factor
                context.beginPath()
                for(var i = 0; i < points.length; i++)
                    context.lineTo(toXPixels(points[i].x), toYPixels(points[i].y))
                context.stroke()
            }
        }
    }

//    focus: true
//    Keys.onPressed: { // increase values with 0-9 and decrease with Alt+0-9
//        if(!isNaN(parseInt(event.text))  &&  parseInt(event.text) < root.points.length) { // 0-9 keys
//            var points = root.points
//            points[event.text].y = points[event.text].y + (event.modifiers? -0.1: 0.1) * (yMaximum - yMinimum)
//            root.points = points
//        }
//    }

//    Component.onCompleted: { // sine wave
//        var points = [], N = 100, T = 1
//        for(var i = 0; i <= N; i++)
//            points.push({x: T / N * i , y: Math.sin(2 * Math.PI * i / N)})
//        root.points = points
//    }
}

Test.qml

import QtQuick 2.0

LineChart {
    title:  'Pendulum Position versus Time'
    yLabel: 'position (degrees)'
    xLabel: 'time (s)'
    color:  'red'

    Component.onCompleted: { // sine wave
        var positions = [], N = 100, T = 1
        for(var i = 0; i <= N; i++)
            positions.push({x: T / N * i , y: Math.sin(2 * Math.PI * i / N)})
        points = positions
    }
}

Summary

In this post, we created a LineChart. Next time we'll create a PieChart. The source code can be downloaded here. Click here to access the whole series.