Show timeline chart in dashboard

Hi folks,

This winter I want to continue again playing with video surveillance in Node-RED, so I'm doing already now and then a bit of prospection...

One of the things I need to have is a visual overview of all my camera recordings (on disc).

The only thing I found for Node-RED is this timeline flow. Think it is in the correct direction, but would like to have a UI node that is easy to setup and customize.

Google tells me that I need to look for Timeline charts, or perhaps Gantt charts (although I don't need arrows from one bar to another)?

Last hour I have quickly setup an experimental node, based on this library:


When a bar is clicked, I want to send an output message and - in a next node wired to the output - e.g. play that (stored) camera footage in a popup window (or whatever ...).

Don't know at all whether I'm searching in the correct direction:

  • Does one of the current chart nodes perhaps already supports this kind of stuff?
  • If not, is my library ok or does anybody know a better (still maintained) library? Disadvantages of my library is that the tooltip cannot show custom text, and I always need to supply all the data (i.e. need to update it entirely).
  • Other tips?
  • Feature requests perhaps?

Thanks !!

Would be nice to get some ideas...
Just to make sure I'm not going to reinvent hot water: is this already possible in the dashboard? And if not, is my library proposal a decent choice or are there much better libraries available?

the node-red-contrib-ui-vega node is capable of drawing many types of data - including gantt charts
eg - see
But whether that is easy for the average user is another question... though the picture you show could perhaps be somewhat complex as well.

Thanks!! Will give it a try...

Yes I agree, but it was a simple experiment I quickly created to have at least a screenshot to discuss...

Perhaps it is better if I use a screenshot from another application. For example ISpy offers a Timeline screen, where you can see all audio and video recordings:


I would like to have something similar in my Node-RED dashboard:

  1. I could load automatically information about all the recordings from disc.
  2. Based on that information, a similar Timeline chart could be created automatically.
  3. When the user clicks a rectangle/recording in the chart, an output message is send (containing the information about that recording).
  4. Then next node in the flow would be triggered by the message, and could e.g. start playing the recorded video in the dashboard .

Yes, this would be a great widget to have available. I managed to get at least 2 of them working years ago (one with Google charts, and the other with ChartJS, I think). Here is what a simple timeline could look like for monitoring lights in different rooms:

I found some old example code, but have not tried it since 0.20.x -- so buyer beware!

[{"id":"8da01ad2.268c08","type":"ui_template","z":"5f33da3f.ce2ef4","group":"3b6fc506.b120ca","name":"chartjs timeline","order":0,"width":"16","height":"9","format":"<script>\nvar max_duration = .01; // number of hours of history to show\n\nvar labels = [];\nvar datasets = [];\nvar colorArray = ['#FF6633','#4D4D4D','#5DA5DA','#FAA43A','#60BD68','#F17CB0','#B2912F','#B276B2','#DECF3F','#F15854'];\n\n\nvar ctx = document.getElementById(\"canvas\").getContext(\"2d\");\n\n// window.timeline = new Chart(ctx, {\nvar chart = new Chart(ctx, {\n    type: 'timeLine',\n    options: {\n        responsive: true,\n        colorFunction: function(data){\n            // data is the dataset event point.\n            // The first and second entries are the start/stop date\n            //The third entry specifies the color to use\n            return (typeof data[2] === 'undefined' ? 'black' : data[2]);\n        }\n    },\n    data: {\n        labels: labels,\n        datasets: datasets\n    }\n    \n    //   data: {\n    //     labels: [\"Joe\", \"Bob\", \"Jim\", \"Alice\"],\n    //     datasets: [{\n    //         data: [\n    //             [new Date('2018-04-11'), new Date('2018-04-12')],\n    //             [new Date('2018-04-14'), new Date('2018-04-18')],\n    //             [new Date('2018-04-25'), new Date('2018-04-27')]\n    //         ]\n    //     }, {\n    //         data: [\n    //             [getDate(1), getDate(3)],\n    //             [getDate(4), getDate(6)],\n    //             [getDate(7), getDate(9)]\n    //         ]\n    //     }, {\n    //         data: [\n    //             [getDate(3), getDate(5)],\n    //             [getDate(6), getDate(9)]\n    //         ]\n    //     }, {\n    //         data: [\n    //             [getDate(3), getDate(5)],\n    //             [getDate(6), getDate(20)]\n    //         ]\n    //     }]\n    // }\n});\n    \n(function(scope){\n    scope.$watch('msg', function(msg) {\n        if(typeof(msg) !== \"object\") return;\n        \n        if(typeof(msg.cache) === \"object\"){\n            console.log(\"Read cache\");\n            \n            // It blows up here!\n            labels = [];\n            datasets = [];\n            \n            // for(var i in msg.cache.labels){\n                // scope.labels[i] = msg.cache.labels[i];\n            // }\n            // scope.labels = msg.cache.labels;\n            \n            // console.log(scope.labels);\n            // console.log(msg.cache.datasets);\n            \n            \n            // scope.labels = [\"a\"];\n            // scope.datasets = [{data: [\n            //         [new Date('2018-04-11'), new Date('2018-04-12')],\n            //         [new Date('2018-04-14'), new Date('2018-04-18')],\n            //         [new Date('2018-04-25'), new Date('2018-04-27')]\n            //     ]}];\n            // scope.datasets = msg.cache.datasets;\n            // return null;\n        }\n\n        if(typeof(msg.topic) === \"string\" && typeof(msg.payload) === \"boolean\"){\n            /* Begin history tracking */\n            // Doesn't exist so init the object\n            if(labels.indexOf(msg.topic) == -1){\n                labels.push(msg.topic);\n                \n                datasets.push({\n                    label: msg.topic,\n                    current: false,\n                    timestamp: new Date(),\n                    data: []\n                });\n            }\n            \n            var thisIndex = labels.indexOf(msg.topic);\n            \n            // The state has changed\n            if(msg.payload != datasets[thisIndex].current){\n                datasets[thisIndex].current = msg.payload;\n                datasets[thisIndex].timestamp = new Date();\n                \n                // Only add a new event if the state switched from false to true\n                if(msg.payload === true){\n                    // add a new start/stop event to the topics history\n                    var event = [new Date(), new Date(), colorArray[thisIndex]];\n                    datasets[thisIndex].data.push(event);\n                }\n            }\n        }\n        \n        \n        angular.forEach(datasets, function(topic){\n            if(topic.current === true &&{\n                // Update the event stop time to the current time\n      [ - 1][1] = new Date();\n            }\n            \n\n            angular.forEach(, function(event, index, object){\n                // cleanup old history (anything with an ending time more than max_duration hours old)\n                if(new Date() - event[1] > max_duration * 3600000){\n                    // Delete the event\n                    object.splice(index,1);\n                }\n            });\n        });\n        \n        /* END History tracking */\n        \n        // Force the timeline to redraw by adding an event to the end of the first bar\n        // if(scope.datasets.length){\n        //     scope.datasets[0].data[0].push([new Date(), new Date()]);\n        // }\n\nconsole.log(;\n\n        //redraw\n        chart.update();\n        \n        // if(scope.datasets.length){\n        //     scope.datasets[0].data[0].pop();\n        // }\n        \n        // This triggers a never ending loop for some reason\n        var metas = [];\n        for(var i in datasets){\n            metas[i] = datasets[i]._meta;\n            delete datasets[i]._meta;\n        }\n        \n        // Attempting to make chart persistence between deploys\n        scope.send({cache:});\n        // scope.send({payload: [\"one\"]});\n        \n        // put the meta property back in\n        for(var i in datasets){\n            datasets[i]._meta = metas[i];\n        }\n\n\n    });\n})(scope);\n\n\n</script>\n\n<div style=\"width: 100%;\">\n    <canvas id=\"canvas\"></canvas>\n</div>","storeOutMessages":false,"fwdInMessages":false,"templateScope":"local","x":520,"y":120,"wires":[["190d9e6d.358372"]]},{"id":"9d4bd2dd.2039","type":"inject","z":"5f33da3f.ce2ef4","name":"Update Chart","topic":"","payload":"","payloadType":"date","repeat":"10","crontab":"","once":false,"x":180,"y":80,"wires":[["8da01ad2.268c08"]]},{"id":"7a58d2cb.24da1c","type":"inject","z":"5f33da3f.ce2ef4","name":"Kitchen On","topic":"Kitchen","payload":"true","payloadType":"json","repeat":"","crontab":"","once":false,"x":160,"y":240,"wires":[["8da01ad2.268c08"]]},{"id":"6bb6f899.9fa418","type":"inject","z":"5f33da3f.ce2ef4","name":"Bathroom On","topic":"Bathroom","payload":"true","payloadType":"json","repeat":"","crontab":"","once":false,"x":170,"y":280,"wires":[["8da01ad2.268c08"]]},{"id":"ee8743c2.c6c33","type":"inject","z":"5f33da3f.ce2ef4","name":"Living On","topic":"Living","payload":"true","payloadType":"json","repeat":"","crontab":"","once":false,"x":160,"y":320,"wires":[["8da01ad2.268c08"]]},{"id":"a587d9bb.d0f428","type":"inject","z":"5f33da3f.ce2ef4","name":"Basement On","topic":"Basement","payload":"true","payloadType":"json","repeat":"","crontab":"","once":false,"x":170,"y":360,"wires":[["8da01ad2.268c08"]]},{"id":"8fb689c5.e27a08","type":"inject","z":"5f33da3f.ce2ef4","name":"Kitchen Off","topic":"Kitchen","payload":"false","payloadType":"json","repeat":"","crontab":"","once":false,"x":160,"y":480,"wires":[["8da01ad2.268c08"]]},{"id":"4494cb0e.a4b6e4","type":"inject","z":"5f33da3f.ce2ef4","name":"Bathroom Off","topic":"Bathroom","payload":"false","payloadType":"json","repeat":"","crontab":"","once":false,"x":170,"y":520,"wires":[["8da01ad2.268c08"]]},{"id":"9c059944.1524d8","type":"inject","z":"5f33da3f.ce2ef4","name":"Living Off","topic":"Living","payload":"false","payloadType":"json","repeat":"","crontab":"","once":false,"x":160,"y":560,"wires":[["8da01ad2.268c08"]]},{"id":"3aa733d5.c27a7c","type":"inject","z":"5f33da3f.ce2ef4","name":"Basement Off","topic":"Basement","payload":"false","payloadType":"json","repeat":"","crontab":"","once":false,"x":170,"y":600,"wires":[["8da01ad2.268c08"]]},{"id":"2f33255.08169da","type":"inject","z":"5f33da3f.ce2ef4","name":"Garage On","topic":"Garage","payload":"true","payloadType":"json","repeat":"","crontab":"","once":false,"x":160,"y":200,"wires":[["8da01ad2.268c08"]]},{"id":"41f2bb30.3fb914","type":"inject","z":"5f33da3f.ce2ef4","name":"Garage Off","topic":"Garage","payload":"false","payloadType":"json","repeat":"","crontab":"","once":false,"x":160,"y":440,"wires":[["8da01ad2.268c08"]]},{"id":"190d9e6d.358372","type":"debug","z":"5f33da3f.ce2ef4","name":"debug msg object","active":false,"console":"false","complete":"true","x":770,"y":80,"wires":[]},{"id":"3118460e.fa9faa","type":"ui_template","z":"5f33da3f.ce2ef4","group":"3b6fc506.b120ca","name":"ChartJS Timeline Library","order":0,"width":0,"height":0,"format":"<script>\nconst helpers = Chart.helpers;\nconst isArray = helpers.isArray;\n\nvar time = {\n\t\tunits: [{\n\t\t\tname: 'millisecond',\n\t\t\tsteps: [1, 2, 5, 10, 20, 50, 100, 250, 500]\n\t\t}, {\n\t\t\tname: 'second',\n\t\t\tsteps: [1, 2, 5, 10, 30]\n\t\t}, {\n\t\t\tname: 'minute',\n\t\t\tsteps: [1, 2, 5, 10, 30]\n\t\t}, {\n\t\t\tname: 'hour',\n\t\t\tsteps: [1, 2, 3, 6, 12]\n\t\t}, {\n\t\t\tname: 'day',\n\t\t\tsteps: [1, 2, 3, 5]\n\t\t}, {\n\t\t\tname: 'week',\n\t\t\tmaxStep: 4\n\t\t}, {\n\t\t\tname: 'month',\n\t\t\tmaxStep: 3\n\t\t}, {\n\t\t\tname: 'quarter',\n\t\t\tmaxStep: 4\n\t\t}, {\n\t\t\tname: 'year',\n\t\t\tmaxStep: false\n\t\t}]\n};\n\nvar myConfig = {\n    myTime : {\n        redoLabels: false\n    },\n    position: 'bottom',\n\n    time: {\n        parser: false, // false == a pattern string from or a custom callback that converts its argument to a moment\n        format: false, // DEPRECATED false == date objects, moment object, callback or a pattern string from\n        unit: false, // false == automatic or override with week, month, year, etc.\n        round: false, // none, or override with week, month, year, etc.\n        displayFormat: false, // DEPRECATED\n        isoWeekday: false, // override week start day - see\n        minUnit: 'millisecond',\n\n        // defaults to unit's corresponding unitFormat below or override using pattern string from\n        displayFormats: {\n            millisecond: 'h:mm:ss.SSS a', // 11:20:01.123 AM,\n            second: 'h:mm:ss a', // 11:20:01 AM\n            minute: 'h:mm:ss a', // 11:20:01 AM\n            quarter: '[Q]Q - YYYY', // Q3\n            year: 'YYYY', // 2015        \n            hour: 'MMM D, hA', // Sept 4, 5PM\n            day: 'll', // Sep 4 2015\n            week: 'll', // Week 46, or maybe \"[W]WW - YYYY\" ?\n            month: 'MMM YYYY', // Sept 2015\n            }\n    },\n    ticks: {\n        autoSkip: false\n    }\n};\n\n\nvar myTimeScale = Chart.scaleService.getScaleConstructor('time').extend({\n\n    determineDataLimits: function() {\n        var me = this;\n        me.labelMoments = [];\n\n        // We parse all date labels here, for each entry we parse its initial and end date\n        var scaleLabelMoments = [];\n        if ( && > 0) {\n            helpers.each(, function(datasets) {\n                var data =;\n                var length = data.length;\n                for (var i = 0; i < length; i++) {\n                    // We consider 0 to have initial date\n                    var initialLabelMoment = me.parseTime(data[i][0]);\n                    // we consider 1 to have end date\n                    // TODO maybe add a check to see which one is bigger, but right now i don't know the\n                    // TODO implications off that check\n                    var finalLabelMoment = me.parseTime(data[i][1]);\n                    if (initialLabelMoment.isValid()) {\n                        if (me.options.time.round) {\n                            initialLabelMoment.startOf(me.options.time.round);\n                        }\n                        scaleLabelMoments.push(initialLabelMoment);\n                    }\n                    if (finalLabelMoment.isValid()) {\n                        if (me.options.time.round) {\n                            finalLabelMoment.startOf(me.options.time.round);\n                        }\n                        scaleLabelMoments.push(finalLabelMoment);\n                    }\n                }\n            }, me);\n\n            me.firstTick =, scaleLabelMoments);\n            me.lastTick =, scaleLabelMoments);\n        } else {\n            me.firstTick = null;\n            me.lastTick = null;\n        }\n\n        // In this case label moments are the same as scale moments because this chart only supports\n        // dates as data and not labels like normal time scale. We are doing this to keep\n        // coordination between parent(TimeScale) calls\n        me.labelMoments.push(scaleLabelMoments);\n\n        // Set these after we've done all the data\n        if (me.options.time.min) {\n            me.firstTick = me.parseTime(me.options.time.min);\n        }\n\n        if (me.options.time.max) {\n            me.lastTick = me.parseTime(me.options.time.max);\n        }\n\n        // We will modify these, so clone for later\n        me.firstTick = (me.firstTick || moment()).clone();\n        me.lastTick = (me.lastTick || moment()).clone();\n    },\n    buildLabelDiffs: function() {\n        var me = this;\n        me.labelDiffs = [];\n        var scaleLabelDiffs = [];\n        // Parse common labels once\n        if ( && > 0) {\n            helpers.each(, function(datasets, datasetIndex) {\n                var data =;\n                var length = data.length;\n                for (var i = 0; i < length; i++) {\n                    // We consider 0 to have initial date\n                    var initialLabelMoment = me.parseTime(data[i][0]);\n                    // we consider 1 to have end date\n                    // TODO maybe add a check to see which one is bigger, but right now i don't know the\n                    // TODO implications off that check\n                    var finalLabelMoment = me.parseTime(data[i][1]);\n                    var diff;\n                    if (initialLabelMoment.isValid()) {\n                        if (me.options.time.round) {\n                            diff = initialLabelMoment.diff(me.firstTick, me.tickUnit, false);\n                        }\n                        else {\n                            if (me.isInTicks(initialLabelMoment, me.tickUnit))\n                            // No floor needed since we are one of the ticks\n                                diff = initialLabelMoment.diff(me.firstTick, me.tickUnit, false);\n                            else\n                                diff = initialLabelMoment.diff(me.firstTick, me.tickUnit, true);\n                        }\n                        scaleLabelDiffs.push(diff);\n                    }\n                    if (finalLabelMoment.isValid()) {\n                        if (me.options.time.round) {\n                            // Moment doesn't round on diff anymore\n                            diff = finalLabelMoment.diff(me.firstTick, me.tickUnit, false);\n                        }\n                        else\n                        {\n                            if (me.isInTicks(finalLabelMoment, me.tickUnit))\n                            // No floor needed since we are one of the ticks\n                                diff = finalLabelMoment.diff(me.firstTick, me.tickUnit, false);\n                            else\n                                diff = finalLabelMoment.diff(me.firstTick, me.tickUnit, true);\n                        }\n                        scaleLabelDiffs.push(diff);\n                    }\n                }\n                me.labelDiffs[datasetIndex] = scaleLabelDiffs;\n                scaleLabelDiffs = [];\n            }, me);\n        }\n\n\n    },\n\n    // This function is different from parent because the second argument of the index inside the array of dates\n    // e.g [initialDate, endDate]. Since we built the diffs in date order, which means that every 2 entries in\n    // me.labelDiffs represent one set of date with initial and end dates by order.\n    getLabelDiff: function (datasetIndex, dateIndex) {\n        var me = this;\n        if (datasetIndex === null || dateIndex === null)\n            return null;\n\n        if (me.labelDiffs === undefined)\n            me.buildLabelDiffs();\n\n        if (me.labelDiffs[datasetIndex] != undefined)\n            return me.labelDiffs[datasetIndex][dateIndex];\n\n        return null;\n    },\n\n    getPixelForValue: function(value, index, datasetIndex) {\n        var me = this;\n        var offset = null;\n        if (index !== undefined && datasetIndex !== undefined) {\n            offset = me.getLabelDiff(datasetIndex, index);\n        }\n\n        if (offset === null) {\n            if (!value || !value.isValid) {\n                // not already a moment object\n                value = me.parseTime(me.getRightValue(value));\n            }\n            if (value && value.isValid && value.isValid()) {\n                offset = value.diff(me.firstTick, me.tickUnit, false);\n            }\n        }\n\n        if (offset !== null) {\n            var decimal = offset !== 0 ? offset / me.scaleSizeInUnits : offset;\n\n            if (me.isHorizontal()) {\n                var valueOffset = (me.width * decimal);\n                return me.left + Math.round(valueOffset);\n            }\n\n            var heightOffset = (me.height * decimal);\n            return + Math.round(heightOffset);\n        }\n    },\n\n    // Checks if some date object is a tickMoment\n    isInTicks: function (date, unit) {\n        var result = false;\n        var length = this.tickMoments.length;\n        var ticks = this.tickMoments;\n        for(var i = 0; i < length; i++)\n        {\n            var tick = ticks[i];\n            if (date.isSame(tick, unit))\n            {\n                result = true;\n                break;\n            }\n        }\n        return result;\n    }\n});\n\n\nChart.scaleService.registerScaleType('myTime', myTimeScale, myConfig);\n\n\n\nChart.controllers.timeLine ={\n\n    getBarBounds : function (bar) {\n        var vm =   bar._view;\n        var x1, x2, y1, y2;\n\n        x1 = vm.x;\n        x2 = vm.x + vm.width;\n        y1 = vm.y;\n        y2 = vm.y + vm.height;\n\n        return {\n            left : x1,\n            top: y1,\n            right: x2,\n            bottom: y2\n        };\n\n    },\n\n    update: function(reset) {\n        var me = this;\n        var meta = me.getMeta();\n        helpers.each(, function(rectangle, index) {\n            me.updateElement(rectangle, index, reset);\n        }, me);\n    },\n\n    updateElement: function(rectangle, index, reset) {\n        var me = this;\n        var meta = me.getMeta();\n        var xScale = me.getScaleForId(meta.xAxisID);\n        var yScale = me.getScaleForId(meta.yAxisID);\n        var dataset = me.getDataset();\n        var data =[index];\n        var custom = rectangle.custom || {};\n        var datasetIndex = me.index;\n        var rectangleElementOptions = me.chart.options.elements.rectangle;\n\n        rectangle._xScale = xScale;\n        rectangle._yScale = yScale;\n        rectangle._datasetIndex = me.index;\n        rectangle._index = index;\n\n        var ruler = me.getRuler(index);\n\n        if (index !== 0)\n            index = index * 2;\n\n        var x = xScale.getPixelForValue(data, index , datasetIndex);\n        index++;\n        var end = xScale.getPixelForValue(data, index, datasetIndex);\n\n        var y = yScale.getPixelForValue(data, datasetIndex, datasetIndex);\n        var width = end - x;\n        var height = me.calculateBarHeight(ruler);\n        var color = me.chart.options.colorFunction(data);\n\n        // This one has in account the size of the tick and the height of the bar, so we just\n        // divide both of them by two and subtract the height part and add the tick part\n        // to the real position of the element y. The purpose here is to place the bar\n        // in the middle of the tick.\n        var boxY = y + (ruler.tickHeight / 2) - (height / 2);\n\n        // console.log([index] + ' box x ' + index + ' : ' + x);\n        // console.log([index] + ' box y ' + index + ' : ' + boxY);\n        rectangle._model = {\n            x: reset ?  x - width : x,   // Top left of rectangle\n            y: boxY , // Top left of rectangle\n            width: width,\n            height: height,\n            base: x + width,\n            backgroundColor: color,\n            borderSkipped: custom.borderSkipped ? custom.borderSkipped : rectangleElementOptions.borderSkipped,\n            borderColor: custom.borderColor ? custom.borderColor : helpers.getValueAtIndexOrDefault(dataset.borderColor, index, rectangleElementOptions.borderColor),\n            borderWidth: custom.borderWidth ? custom.borderWidth : helpers.getValueAtIndexOrDefault(dataset.borderWidth, index, rectangleElementOptions.borderWidth),\n            // Tooltip\n            label:[index],\n            datasetLabel: dataset.label\n        };\n\n\n\n        rectangle.draw = function() {\n            var ctx = this._chart.ctx;\n            var vm = this._view;\n            ctx.fillStyle = vm.backgroundColor;\n            ctx.lineWidth = vm.borderWidth;\n            helpers.drawRoundedRectangle(ctx, vm.x, vm.y, vm.width, vm.height, 1);\n            ctx.fill();\n        };\n\n        rectangle.inXRange = function (mouseX) {\n            var bounds = me.getBarBounds(this);\n            return mouseX >= bounds.left && mouseX <= bounds.right;\n        };\n        rectangle.tooltipPosition = function () {\n            var vm = this.getCenterPoint();\n            return {\n                x: vm.x ,\n                y: vm.y\n            };\n        };\n\n        rectangle.getCenterPoint = function () {\n            var vm = this._view;\n            var x, y;\n            x = vm.x + (vm.width / 2);\n            y = vm.y + (vm.height / 2);\n\n            return {\n                x : x,\n                y : y\n            };\n        };\n\n        rectangle.inRange = function (mouseX, mouseY) {\n            var inRange = false;\n\n            if(this._view)\n            {\n                var bounds = me.getBarBounds(this);\n                inRange = mouseX >= bounds.left && mouseX <= bounds.right &&\n                    mouseY >= && mouseY <= bounds.bottom;\n            }\n            return inRange;\n        };\n\n        rectangle.pivot();\n    },\n\n    // From\n    getRuler: function(index) {\n        var me = this;\n        var meta = me.getMeta();\n        var yScale = me.getScaleForId(meta.yAxisID);\n        var datasetCount = me.getBarCount();\n\n        var tickHeight;\n        if (yScale.options.type === 'category') {\n            tickHeight = yScale.getPixelForTick(index + 1) - yScale.getPixelForTick(index);\n        } else {\n            // Average width\n            tickHeight = yScale.width / yScale.ticks.length;\n        }\n        var categoryHeight = tickHeight * yScale.options.categoryPercentage;\n        var categorySpacing = (tickHeight - (tickHeight * yScale.options.categoryPercentage)) / 2;\n        var fullBarHeight = categoryHeight / datasetCount;\n\n        if (yScale.ticks.length !== {\n            var perc = yScale.ticks.length /;\n            fullBarHeight = fullBarHeight * perc;\n        }\n\n        var barHeight = fullBarHeight * yScale.options.barPercentage;\n        var barSpacing = fullBarHeight - (fullBarHeight * yScale.options.barPercentage);\n\n        return {\n            datasetCount: datasetCount,\n            tickHeight: tickHeight,\n            categoryHeight: categoryHeight,\n            categorySpacing: categorySpacing,\n            fullBarHeight: fullBarHeight,\n            barHeight: barHeight,\n            barSpacing: barSpacing\n        };\n    },\n\n    // From\n    getBarCount: function() {\n        var me = this;\n        var barCount = 0;\n        helpers.each(, function(dataset, datasetIndex) {\n            var meta = me.chart.getDatasetMeta(datasetIndex);\n            if ( && me.chart.isDatasetVisible(datasetIndex)) {\n                ++barCount;\n            }\n        }, me);\n        return barCount;\n    },\n\n\n    // draw\n    draw: function (ease) {\n        var easingDecimal = ease || 1;\n        var i, len;\n        var metaData = this.getMeta().data;\n        for (i = 0, len = metaData.length; i < len; i++)\n        {\n            metaData[i].transition(easingDecimal).draw();\n        }\n    },\n\n    // From\n    calculateBarHeight: function(ruler) {\n        var me = this;\n        var yScale = me.getScaleForId(me.getMeta().yAxisID);\n        if (yScale.options.barThickness) {\n            return yScale.options.barThickness;\n        }\n        return yScale.options.stacked ? ruler.categoryHeight : ruler.barHeight;\n    },\n\n    removeHoverStyle: function(e) {\n        // TODO\n    },\n\n    setHoverStyle: function(e) {\n        // TODO: Implement this\n    }\n\n});\n\n\nChart.defaults.timeLine = {\n\n    colorFunction: function() {\n        return 'black';\n    },\n\n    layout: {\n        padding: {\n            left: 5,\n            right: 5,\n            top: 0\n        }\n    },\n\n    legend: {\n        display: false\n    },\n\n    scales: {\n        xAxes: [{\n            type: 'myTime',\n            position: 'bottom',\n            gridLines: {\n                display: true,\n                offsetGridLines: true,\n                drawBorder: true,\n                drawTicks: true\n            },\n            ticks: {\n                maxRotation: 0\n            },\n            unit: 'day'\n        }],\n        yAxes: [{\n            type: 'category',\n            position: 'left',\n            barThickness : 20,\n            gridLines: {\n                display: true,\n                offsetGridLines: true,\n                drawBorder: true,\n                drawTicks: true\n            }\n        }]\n    },\n    tooltips: {\n        mode: 'single',\n        callbacks: {\n            title: function(tooltipItems, data) {\n                return data.labels[tooltipItems[0].datasetIndex];\n            },\n            label: function(tooltipItem, data) {\n                \n                return [\"Started: \" + moment(data.datasets[tooltipItem.datasetIndex].data[tooltipItem.index][0]).fromNow(),\n                    \"Duration: \" + moment(data.datasets[tooltipItem.datasetIndex].data[tooltipItem.index][1]).from(data.datasets[tooltipItem.datasetIndex].data[tooltipItem.index][0], true)\n                    ]\n            }\n        }\n    }\n};\n\n</script>","storeOutMessages":true,"fwdInMessages":true,"templateScope":"global","x":470,"y":60,"wires":[[]]},{"id":"3b6fc506.b120ca","type":"ui_group","z":"","name":"Default Group","tab":"39af89f4.368cc6","disp":true,"width":"16"},{"id":"39af89f4.368cc6","type":"ui_tab","z":"","name":"Home Tab","icon":"dashboard","order":18,"disabled":false,"hidden":false}]

As Dave mentioned, the Vega nodes are designed to do things like this, and more -- but it's a steep learning curve!

1 Like

Hey Steve,
That is indeed another interesting use case!

I had seen here already that the home assistant developers have been playing with google charts. But I thought I had read somewhere that Google chart isn't offline available...

Seems that Openhab also has a timeline widget, with lots of configurable settings. That might als be a good start point for my new UI node...

Thanks for sharing your flow!!!