Chart.js Tutorial #3 Custom Node-Tree with Circle Spacing

in utopian-io •  7 years ago  (edited)

c3.png

Preface

In this tutorial, I will combine the basics from the previous two tutorials to create an interactive node-tree type chart. My prior tutorials on Chart.js can be found here:

What Will You Learn?

  • How to create evenly spaced interactive node-tree type charts based on circle math.
  • Basic animation controls and how to keep animations during major data changes.
  • One way to create dynamic line curves with canvas functions within our custom draw.

Requirements

Since this tutorial will be jumping from the previous two I have made, I recommend looking them over and acquiring a non-minified version of the Chart.js library. Nonetheless, since I will reiterating the most relevant parts here is what you need:

  • The Chart.js library
  • A folder or directory with index.html and main.js files.
  • A browser, this tutorial tested on Firefox, Edge, Android Firefox, and Chrome.
  • A text editor of your choice (I use Brackets).

Difficulty

  • Basic/Intermediate

Tutorial Contents

The first thing I am going to do is load the Chart.js library into my html page and establish a canvas element to draw my chart on top of. I will also set up my main.js file with the custom draw function from the last tutorial which is called immediately. Here is the initial html and javascript files:

index.html

<!DOCTYPE html>
<html>
    
    <head>
        
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width,initial-scale=1">
        <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.2/Chart.js"></script>
        <script src="main.js"></script>
        
    </head>
    
    <body>
        
        <div style="width:75%; height:auto; margin:auto; border-style: solid;">
            <canvas id="chart" width="9" height="5"></canvas>
        </div>
        
    </body>
    
</html>

main.js

function regCustomChart() {
    
    Chart.defaults.nodeTree = Chart.defaults.line;
    
    var ndGraphtype = Chart.controllers.line.extend({
        draw: function(ease) {
            var chart = this.chart,
                meta = this.getMeta(),
                purePoints = this._data,
                metaPoints = meta.data || [];
            
            if (meta.dataset._view) {
                var area = chart.chartArea,
                    datasetOps = meta.dataset._view,
                    index = 0,
                    suppIndex = 0,
                    subIndex = 0,
                    pointsLength = purePoints.length,
                    globalOptionLineElements = Chart.defaults.global.elements.line,
                    ctx = this.chart.ctx;
                
                Chart.helpers.canvas.clipArea(ctx, area);
                
                if (meta.dataset._loop && metaPoints.length) {
                    metaPoints.push(metaPoints[0]);
                }
                
                ctx.save();
                
                // Stroke Line Options
                ctx.lineCap = datasetOps.borderCapStyle || globalOptionLineElements.borderCapStyle;
                if (ctx.setLineDash) { // IE 9 and 10 do not support line dash
                    ctx.setLineDash(datasetOps.borderDash || globalOptionLineElements.borderDash);
                }
                ctx.lineDashOffset = datasetOps.borderDashOffset || globalOptionLineElements.borderDashOffset;
                ctx.lineJoin = datasetOps.borderJoinStyle || globalOptionLineElements.borderJoinStyle;
                ctx.lineWidth = datasetOps.borderWidth || globalOptionLineElements.borderWidth;
                ctx.strokeStyle = datasetOps.borderColor || Chart.defaults.global.defaultColor;
                
                // Draw lines connecting points
                for (index = 0; index < pointsLength; ++index) {
                    if (purePoints[index] && Array.isArray(purePoints[index].children)) {
                        for (suppIndex = 0; suppIndex < purePoints[index].children.length; ++suppIndex) {
                            ctx.beginPath();
                            ctx.moveTo(metaPoints[index]._view.x, metaPoints[index]._view.y);
                            for (subIndex = 0; subIndex < pointsLength; ++subIndex) {
                                if (purePoints[subIndex] && purePoints[subIndex].id == purePoints[index].children[suppIndex]) {
                                    if (meta.dataset._model.tension == 0) {
                                        ctx.lineTo(metaPoints[subIndex]._view.x, metaPoints[subIndex]._view.y);
                                    } else {
                                        ctx.bezierCurveTo(metaPoints[index]._view.x - 0, metaPoints[index]._view.y + 0, metaPoints[subIndex]._view.x + 0, metaPoints[subIndex]._view.y - 0, metaPoints[subIndex]._view.x, metaPoints[subIndex]._view.y);
                                    }
                                    ctx.stroke();
                                }
                            }
                        }
                    }
                }
                ctx.restore();
                Chart.helpers.canvas.unclipArea(ctx);
            }
            // Draw the points
            for (var i = 0; i < metaPoints.length; ++i) {
                metaPoints[i].draw(area);
            }
        }
    });
    Chart.controllers.nodeTree = ndGraphtype;
}
regCustomChart();

This is the same function from the previous tutorial, the only changes I have made to the custom draw function are tiny micro-optimizations which I am not sure are actually helping such as caching point data length at the top and prefixing the ++ on iterating variables. I have also added ctx.bezierCurveTo(metaPoints[index]._view.x - 0, metaPoints[index]._view.y + 0, metaPoints[subIndex]._view.x + 0, metaPoints[subIndex]._view.y - 0, metaPoints[subIndex]._view.x, metaPoints[subIndex]._view.y); which is an html5 canvas function that creates control points for a curved line between two points. As you may see, I have a set-up of (x - 0, y + 0), (x + 0, y - 0) for the control points which are the first 4 arguments, this does nothing now but we will replace those 0 values later on when we can control them.

With that out of the way, we can actually draw a chart now and start testing ways to make a node-tree look presentable. Since animations are a large part of that, we will be trying to retain them as well. Here is the next step where I again mostly reuse the chart instance from the last tutorial with some slight changes:

main.js

let globalNodeChtRef,
    idCount = 1;

window.addEventListener('DOMContentLoaded', function () {
    
    drawTester();

    document.getElementById('chart').onmousedown = function (result) {
        var metaFromPt = globalNodeChtRef.getElementAtEvent(result),
            purePoint = metaFromPt.length > 0 ? metaFromPt[0]._chart.config.data.datasets[metaFromPt[0]._datasetIndex].data[metaFromPt[0]._index] : [];
        
        console.log(metaFromPt, purePoint);
        clickHandler(result, metaFromPt, purePoint);
    };
    
}, false);

function randomColor(alpha) {
    return String('rgba(' + Math.round(Math.random() * 255) + ',' + Math.round(Math.random() * 255) + ',' + Math.round(Math.random() * 255) + ',' + alpha + ')');
}

function drawTester() {
    var ctx = document.getElementById('chart').getContext('2d'),
        lineColor = randomColor(0.5);

    globalNodeChtRef = new Chart(ctx, {
        type: 'nodeTree',
        data: {
            datasets: [
                {
                    label: 'Tree Dataset',
                    borderColor: lineColor,
                    backgroundColor: randomColor(1),
                    pointBorderColor: lineColor,
                    pointHoverRadius: 6,
                    borderWidth: 3, // line and legend-box border width
                    pointRadius: 5, // all points' size
                    //pointRadius: [10, 2, 3, 5, 6, 9], // if array - individual points' radius based on their index
                    data: [],
                    showLine: true,
                    fill: false,
                    lineTension: .3,
                    tensionDirection: 0
                }
            ]
        },
        options: {
            title: {
                display: true,
                text: "Chart.js Node Tree",
                lineHeight: 1
            },
            scales: {
                xAxes: [{
                    type: "linear",
                    display: true,
                    scaleLabel: {
                        display: true,
                        labelString: 'X-Axis'
                    },
                    ticks: {
                        min: -11,
                        max: 11
                    }
                }, ],
                yAxes: [{
                    display: true,
                    scaleLabel: {
                        display: true,
                        labelString: 'Y-Axis'
                    },
                    ticks: {
                        min: -5,
                        max: 5
                    }
                }]
            },
            animation: {
                duration: 700, // in milliseconds
                easing: 'easeOutQuart',
                onProgress: null,
                onComplete: null
            },
            responsive: true,
            maintainAspectRatio: true
        }
    });
}

Added things to note in this chart instance is the tensionDirection key in the dataset which we will use to determine how our lines will curve. It is not a part of the native functionality of chart.js. Otherwise the min and max values of the x and y scales are intentionally set to be similar to the chart canvas's ratio of 9:5 so that circles do not look like ellipses. Then there is the native animation options in the options object. Only the duration is new, the other values are the defaults, options for which can be found here

Now we can add the clickHandler function referenced by the onmousedown event of the chart canvas. Also, since globalNodeChtRef.getElementAtEvent(result) returns at meta-fied version of the clicked point's data, we use the information there(mostly array indexes) to find the pure info we input in the chart initialization object which contains our children array and tensionDirection. Also, since we will need to begin referencing html elements to improve visuals and interaction, we will add inputs for point id's and control sliders together with the clickHandler:

index.html

<body>
        
        <div style="float: left;">
            
            <label>Selected Point ID:</label><br>
            <input id="CurrentID" value="id of selected point"></input>
            <hr>
            
            <label>Amount of Points:</label><br>
            <input id="PAmount" class="Sliders" type="range" min="1" max="100" value="7" placeholder="0"></input>
            <p>Points: <span class="SDisplay"></span></p>
            <p style="font-size: 10pt;">First Point Asymmetry: <input id="FPoint" type="checkbox"/></p>
            <hr>

            <label>Wrapping:</label><br>
            <input id="PCircle" class="Sliders" type="range" min="1" max="100" value="25" placeholder="1"></input>
            <p>Wrap Percent: <span class="SDisplay"></span>%</p>
            <hr>

            <label>Segment Slider:</label><br>
            <input id="SegSlide" class="Sliders" type="range" min="0" max="100" step="0.5" value="87.5" placeholder="2"></input>
            <p>Shift Percent: <span class="SDisplay"></span>%</p>
            <hr>

            <label>Circle Radius:</label><br>
            <input id="Radius" class="Sliders" type="range" min="0" max="10" step="0.1" value="5" placeholder="3"></input>
            <p>Radius: <span class="SDisplay"></span></p>
            <hr>

        </div>

        <div style="float: right;">
            
            <label>Curve Tension:</label><br>
            <input id="Tension" class="Sliders" type="range" min="0" max="2" step="0.01" value="0" placeholder="4"></input>
            <p>Tension: <span class="SDisplay"></span></p>
            <hr>

            <label>Tension Direction:</label><br>
            <input id="TenDir" class="Sliders" type="range" min="0" max="100" step="0.5" value="87.5" placeholder="5"></input>
            <p>Dir. Shift Percent: <span class="SDisplay"></span>%</p>
            <hr>

        </div>
        
        <div style="width:75%; height:auto; margin:auto; border-style: solid;">
            <canvas id="chart" width="9" height="5"></canvas>
        </div>
        
    </body>

Mostly just set-up elements meant for controlling specific parts of the chart. The only one that might not be as intuitive is the "Wrapping" slider which controls the percentage of circle that our points will take up. By floating the container divs, on a standard resolution screen here is what your chart should look like now:

e1.jpg

main.js

window.addEventListener('DOMContentLoaded', function () {
    
    drawTester();
    
    var clRef = document.getElementsByClassName('Sliders'),
        spanRef = document.getElementsByClassName('SDisplay');
    for (var i = 0; i < clRef.length; i++) {
        clRef[i].oninput = function(result) {
            newSlideHandler(result);
        }
        spanRef[i].innerHTML = clRef[i].value;
    };
    
    document.getElementById('chart').onmousedown = function (result) {
        var metaFromPt = globalNodeChtRef.getElementAtEvent(result),
            purePoint = metaFromPt.length > 0 ? metaFromPt[0]._chart.config.data.datasets[metaFromPt[0]._datasetIndex].data[metaFromPt[0]._index] : [];
        
        console.log(metaFromPt, purePoint);
        clickHandler(result, metaFromPt, purePoint);
    };
    
    document.getElementById('FPoint').onclick = function(result) {newSlideHandler(result);};
    
}, false);

function clickHandler(element, metaFromPt, purePoint) {
    if (purePoint.length == 0) {
        let scaleRef,
            valueX,
            valueY,
            xsRef,
            ysRef;

        for (var scaleKey in globalNodeChtRef.scales) {
            scaleRef = globalNodeChtRef.scales[scaleKey];
            if (scaleRef.isHorizontal()) {
                valueX = scaleRef.getValueForPixel(element.offsetX);
                xsRef = scaleRef;
            } else {
                valueY = scaleRef.getValueForPixel(element.offsetY);
                ysRef = scaleRef;
            }
        }
        
        if (valueX > xsRef.min && valueX < xsRef.max && valueY > ysRef.min && valueY < ysRef.max) {
            globalNodeChtRef.data.datasets.forEach((dataset) => {
                dataset.data.splice(0, 1, {
                    x: valueX,
                    y: valueY,
                    id: 0,
                    children: []
                });
            });
            globalNodeChtRef.update();
            document.getElementById('CurrentID').value = 0;
            if (globalNodeChtRef.data.datasets[0].data.length <= 1) {
                newSlideHandler(element);
            }
        }
    } else {
        document.getElementById('CurrentID').value = purePoint.id;
        newSlideHandler(element);
    }
}

Things to note above, the added stuff in the onload event:

    var clRef = document.getElementsByClassName('Sliders'),
        spanRef = document.getElementsByClassName('SDisplay');
    for (var i = 0; i < clRef.length; i++) {
        clRef[i].oninput = function(result) {
            newSlideHandler(result);
        }
        spanRef[i].innerHTML = clRef[i].value;
    };

This is just a simple way to assign the same function to multiple sliders so they all update our chart as well as displaying their values on the corresponding span element right after the page loads.

Otherwise in the clickHandler function, the biggest thing is the use of .splice() to replace the first point in the chart's point array to one with the coordinates of the click. The meat of the click handler is mostly the same as in the first tutorial but more clean and concise. I use the isHorizontal() function only now, to determine graph boundaries and set references during the initial check to avoid having to use specific axis names. The rest is designed to work with the fabled newSlideHandler function now referenced everywhere. Wow, everything so far was just set-up and reiteration of previous functionality. We can finally get to the actual node-tree creation.

Node-tree Circle Math

Lets just get right into the function because this tutorial has gone on quite a bit without showing the meat of the functionality:

main.js

function newSlideHandler(element) {
    if (element.originalTarget.className == 'Sliders') {
        document.getElementsByClassName('SDisplay')[Number(element.originalTarget.placeholder)].innerHTML = element.originalTarget.value;
    }
    
    var currentId = Number(document.getElementById('CurrentID').value),
        gk = globalNodeChtRef.data.datasets[0].data.slice(),
        idIndex = 0,
        arrLength = document.getElementById('PAmount').value,
        percentOfCircle = 100 / document.getElementById('PCircle').value,
        radianPiece = Number(Math.PI * 2 / arrLength / percentOfCircle),
        segmentSlide = Number((Math.PI * 2 / radianPiece) * (document.getElementById('SegSlide').value / 100)),
        cRadius = document.getElementById('Radius').value,
        lineRota = document.getElementById('TenDir').value * 0.01 * (Math.PI * 2);
    globalNodeChtRef.data.datasets[0].lineTension = document.getElementById('Tension').value;
    globalNodeChtRef.data.datasets[0].tensionDirection = lineRota;
    
    var ii = globalNodeChtRef.data.datasets[0].data.length;
    while (--ii) {
        if (globalNodeChtRef.data.datasets[0].data[ii].children.indexOf(currentId) != -1) {
        //if (globalNodeChtRef.data.datasets[0].data[ii].children[currentId] != undefined) { // faster but only works with numbered id's
            //globalNodeChtRef.data.datasets[0].data.splice(ii, 1); // does not retain animations
            gk.splice(ii, 1);
            --idCount;
        }
    };
    
    globalNodeChtRef.data.datasets[0].data = gk;
    
    for (var i = document.getElementById('FPoint').checked ? 0 : 1; i < arrLength; ++i) {
        for (var j = 0; j < globalNodeChtRef.data.datasets[0].data.length; ++j) {
            if (globalNodeChtRef.data.datasets[0].data[j].id == currentId) {
                idIndex = j;
                break;
            }
        }
        globalNodeChtRef.data.datasets[0].data.push({
            x: globalNodeChtRef.data.datasets[0].data[idIndex].x + cRadius * Math.cos((i + segmentSlide) * radianPiece),
            y: globalNodeChtRef.data.datasets[0].data[idIndex].y + cRadius * Math.sin((i + segmentSlide) * radianPiece),
            id: idCount,
            children: [currentId]
        });
        ++idCount;
    }
    globalNodeChtRef.update();
}

Now the task of breaking this function down into pieces so it is easily digestible and modifiable. The very first if statement is a little trick that has to do with the placeholder values on our sliders. By aligning our placeholder values with the index at which they were loaded, we can correspond the span displays with the specific slider we are currently using. It can be done multiple ways but I thought that was neat.

The next part where we define a bunch of variables can be broken up into two important pieces:

var currentId = Number(document.getElementById('CurrentID').value),
    gk = globalNodeChtRef.data.datasets[0].data.slice(),
    idIndex = 0,

The first 3 variables create references to the currently clicked point, a shallow copy of our points with .slice(), and a setup index respectively. Since splicing points in and out ruins animations for some getter & setter reason probably, we need to copy the points array and do our splicing there to retain animations. The variables defined after those three are where the math for the circle spacing is:

        arrLength = document.getElementById('PAmount').value,
        percentOfCircle = 100 / document.getElementById('PCircle').value,
        radianPiece = Number(Math.PI * 2 / arrLength / percentOfCircle),
        segmentSlide = Number((Math.PI * 2 / radianPiece) * (document.getElementById('SegSlide').value / 100)),
        cRadius = document.getElementById('Radius').value,
        lineRota = document.getElementById('TenDir').value * 0.01 * (Math.PI * 2);
    globalNodeChtRef.data.datasets[0].lineTension = document.getElementById('Tension').value;
    globalNodeChtRef.data.datasets[0].tensionDirection = lineRota;

arrLength is simply an arbitrary amount of points for this demonstration. The radianPiece variable determines how much space in radians is between the points depending on the amount of points and percentage of the circle to use assuming the entire circle is 2 * PI. segmentSlide determines where our segment begins if we are using less than the entire circle, and while it could be done simpler, this math is still worth showing. Think of it as rotation but not really, and I'll show why in the next tutorial. The cRadius is just the radius of the circle, and the lineRota is our radian based controller for the direction of the curve. The next two lines simply set the actual values in our chart's tension and tensionDirection to our slider values directly. Now would be a good time to include the direction math in our custom draw function:

main.js custom draw

var area = chart.chartArea,
    datasetOps = meta.dataset._view,
    index = 0,
    suppIndex = 0,
    subIndex = 0,
    pointsLength = purePoints.length,
    constCos = Math.cos(chart.config.data.datasets[meta.dataset._datasetIndex].tensionDirection) * 100 * meta.dataset._model.tension,
    constSin = Math.sin(chart.config.data.datasets[meta.dataset._datasetIndex].tensionDirection) * 100 * meta.dataset._model.tension,
    globalOptionLineElements = Chart.defaults.global.elements.line,
    ctx = this.chart.ctx;

and

ctx.bezierCurveTo(metaPoints[index]._view.x - constCos, metaPoints[index]._view.y + constSin, metaPoints[subIndex]._view.x + constCos, metaPoints[subIndex]._view.y - constSin, metaPoints[subIndex]._view.x, metaPoints[subIndex]._view.y);

The curve function just has the format (x - constCos, y + constSin), (x + constCos, y - constSin) for the control points now. This will make it so the control points circle the original point in opposite directions so the lines face each other's direction like how most node tree curves are.

Back to the slide handler function, the next part, the while loop simply iterates down an array starting from the last entry. It is supposed to be slightly faster than a negative for loop, but the iteration does need to begin at the final index because otherwise the splice would mess with the length and thus the loop. Minus iterating while loops have their own ability to stop when the ii iteration variable reaches zero, and the entire loop is meant to delete points associated with the currently selected point when updating the points array with the sliders. I am splicing out points on the copied array, then after this loop, I set the actual array in our chart instance to the finalized copy.

After that is finished, I run another loop which checks initially if you want the first point. This is because the first point makes the circle asymmetric. Then I run a check to find the index of the currently selected id and break out of that sub-loop when I find it. Finally, I push the new points in and use the circle math variables above to determine their x and y locations, making sure each new point has the original clicked as its child instead of each one being a child of the clicked. That is a simple design choice based on what your data will be. As for the math,

x: globalNodeChtRef.data.datasets[0].data[idIndex].x + cRadius * Math.cos((i + segmentSlide) * radianPiece),
y: globalNodeChtRef.data.datasets[0].data[idIndex].y + cRadius * Math.sin((i + segmentSlide) * radianPiece),

is essentially equal to:
(centerX + Radius * Cos(angle), centerY + Radius * Cos(angle)) which is the standard function for finding points on the circumference of a circle. The (i + segmentSlide) * radianPiece determines what the angle is based on how many points we have already plotted combined with the shift/slide amount, then multiplying the radian segment that would be between the points.

Lastly the idCount ++ and -- is for demonstration purposes since id's could be names or anything. The -- in the while loop is only to keep the number from getting massive but it could break some connections if you backtrack. Finally, I call the Chart.js native update function and draw the new points and lines hopefully retaining animations. That is everything.

By playing around with all the sliders, here is an example of results you can achieve:

e2.jpg

How it will behave

Pls note that because the id's are number based and I minus iterate the idCount to keep the numbers from getting massive, this only works in one direction, if you reselect a point whose points have their own connections and use the sliders, the system will break. The breaking can be fixed by simply not minus iterating the idCount in the while loop but the connections would still be broken. Nonetheless, this is designed to be a demonstration as node-trees are specifically for non-numerical id's which, with a little modification, should work easily.

As for this demo in particular, clicking anywhere either creates or moves the origin point. After that, you can use the controls to modify the child points. Upon clicking on any other point, the same instance of points will be added with the newly clicked point as the origin, and the controls will only modify the new children. The way I have it set up, it only works if you continue to click and modify from any point whose children do not have their own children, and weird behavior might arise if re-clicking on a previously used point. It is essentially one-way, so clicked points cannot be revisited. Same goes for clicking anywhere on the graph after modifying a child of the origin. These problems could be easily remedied, but because a legitimate string-named-nodes node tree chart would likely not use numbered id's, I will leave that part for later.

Postface

There it is, slightly more robust, animated, and organized node-trees. I hope you liked the include-everything format and learned something here, and I apologize for how long it is especially with all the back tracking and setup in the beginning. This was done because I wanted everything pertaining to this tutorial to be finalized and in one place, but also because I plan to jump directly from this tutorial in my next one since all the set-up will be the same. That way, this one will fully prepare for the next. Anyways, as always, hope you enjoyed and have a good one.



Posted on Utopian.io - Rewarding Open Source Contributors

Authors get paid when people like you upvote their post.
If you enjoyed what you read here, create your account today and start earning FREE STEEM!
Sort Order:  

Thanks for the contribution.


Need help? Write a ticket on https://support.utopian.io.
Chat with us on Discord.

[utopian-moderator]

Hey @deathwing, I just gave you a tip for your hard work on moderation. Upvote this comment to support the utopian moderators and increase your future rewards!