I know it's not the same as a live-chart, but here's an example of an infinite scrolling chart with automatic data density upgrades I've been working on:
view.json
{
"custom": {},
"params": {},
"props": {},
"root": {
"children": [
{
"meta": {
"name": "Chartjs"
},
"position": {
"height": 285,
"width": "100%"
},
"props": {
"data": {
"datasets": [
{
"label": "Dataset 1"
}
]
},
"events": {
"chart": {
"lifecycle": {
"onMount": "(chart) \u003d\u003e chart.$infiniteScroll.requestHistory()"
}
}
},
"options": {
"animation": false,
"elements": {
"point": {
"radius": 0
}
},
"plugins": {
"zoom": {
"limits": {
"x": {
"minRange": 3600000
},
"y": {
"max": "original",
"min": "original",
"minRange": null
}
},
"pan": {
"modifierKey": null,
"onPanComplete": "(context) \u003d\u003e context.chart.$infiniteScroll.onScaleChange()"
},
"zoom": {
"drag": {
"enabled": false
},
"onZoomComplete": "(context) \u003d\u003e context.chart.$infiniteScroll.onScaleChange()",
"wheel": {
"modifierKey": null
}
}
}
},
"scales": {
"x": {
"type": "timestack"
},
"y": {
"type": "linear"
}
}
},
"plugins": [
{
"beforeInit": "(chart, args, options) \u003d\u003e {\n if (!options) return;\n if (chart.$infiniteScroll) return;\n\n const state \u003d chart.$infiniteScroll \u003d {\n destroyed: false,\n cache: null,\n inflight: new Set(),\n };\n\n const config \u003d {\n datasets: [],\n margin: 3600*1000,\n dataSource: {\n history: (context) \u003d\u003e perspective.sendMessage(\u0027data-request\u0027, {\n type: \u0027history\u0027,\n start: context.start,\n end: context.end,\n density: context.density,\n })\n },\n ...options,\n };\n\n if (!config.dataSource?.history) {\n console.error(\"infinite scroll plugin requires dataSource.history\");\n return;\n }\n\n /* ---------------------------------\n * Timeseries Cache with density upgrade\n * --------------------------------- */\n function createTimeseriesCache() {\n let chunks \u003d [];\n\n function insertChunk(newChunk) {\n if (!Array.isArray(newChunk.data) || !Array.isArray(newChunk.data[0])) {\n throw new Error(\"Chunk.data must be array of arrays, one per dataset\");\n }\n\n const updatedChunks \u003d [];\n\n for (const chunk of chunks) {\n // Non-overlapping: keep\n if (chunk.end \u003c\u003d newChunk.start || chunk.start \u003e\u003d newChunk.end) {\n updatedChunks.push(chunk);\n continue;\n }\n\n // Overlap: split lower-density chunks\n if (chunk.density \u003c newChunk.density) {\n if (chunk.start \u003c newChunk.start) {\n updatedChunks.push({\n ...chunk,\n start: chunk.start,\n end: newChunk.start,\n });\n }\n if (chunk.end \u003e newChunk.end) {\n updatedChunks.push({\n ...chunk,\n start: newChunk.end,\n end: chunk.end,\n });\n }\n // Overlapped portion is removed\n } else {\n updatedChunks.push(chunk); // Keep equal/higher-density\n }\n }\n\n updatedChunks.push(newChunk);\n chunks \u003d updatedChunks.sort((a, b) \u003d\u003e a.start - b.start);\n }\n\n function collect(start, end, requestedDensity, datasetCount) {\n const result \u003d Array.from({ length: datasetCount }, () \u003d\u003e []);\n const relevantChunks \u003d chunks.filter(c \u003d\u003e c.end \u003e start \u0026\u0026 c.start \u003c end);\n\n for (let i \u003d 0; i \u003c datasetCount; i++) {\n const pointsByX \u003d new Map();\n\n for (const c of relevantChunks) {\n const data \u003d c.data[i];\n if (!data) continue;\n\n for (const pt of data) {\n if (pt.x \u003e\u003d start \u0026\u0026 pt.x \u003c\u003d end) {\n const existing \u003d pointsByX.get(pt.x);\n if (!existing || existing.density \u003c c.density) {\n pointsByX.set(pt.x, { ...pt, density: c.density });\n }\n }\n }\n }\n\n result[i] \u003d Array.from(pointsByX.values())\n .sort((a, b) \u003d\u003e a.x - b.x)\n .map(pt \u003d\u003e ({ x: pt.x, y: pt.y }));\n }\n\n return result;\n }\n\n function computeGaps(start, end, requestedDensity) {\n const gaps \u003d [];\n let cursor \u003d start;\n\n // Sort chunks by start\n const relevantChunks \u003d chunks\n .filter(c \u003d\u003e c.end \u003e start \u0026\u0026 c.start \u003c end)\n .sort((a, b) \u003d\u003e a.start - b.start);\n\n for (const chunk of relevantChunks) {\n // Skip chunks fully below cursor\n if (chunk.end \u003c\u003d cursor) continue;\n\n // Gap before this chunk\n if (chunk.start \u003e cursor) {\n gaps.push({ start: cursor, end: chunk.start });\n }\n\n // If chunk density \u003c requested, need to request this region\n if (chunk.density \u003c requestedDensity) {\n const gapStart \u003d Math.max(cursor, chunk.start);\n const gapEnd \u003d Math.min(chunk.end, end);\n gaps.push({ start: gapStart, end: gapEnd });\n }\n\n cursor \u003d Math.max(cursor, chunk.end);\n }\n\n // Gap after last chunk\n if (cursor \u003c end) gaps.push({ start: cursor, end });\n\n // Merge adjacent gaps\n const merged \u003d [];\n for (const g of gaps) {\n if (!merged.length || g.start \u003e merged[merged.length - 1].end) {\n merged.push({ ...g });\n } else {\n merged[merged.length - 1].end \u003d Math.max(merged[merged.length - 1].end, g.end);\n }\n }\n\n return merged;\n }\n\n function pruneOutside(min, max) {\n chunks \u003d chunks.filter(c \u003d\u003e c.end \u003e min \u0026\u0026 c.start \u003c max);\n }\n\n return { insertChunk, collect, computeGaps, pruneOutside };\n }\n\n /* ---------------------------------\n * Chart Setup\n * --------------------------------- */\n chart.data.datasets \u003d config.datasets;\n chart.options.scales ??\u003d {};\n chart.options.scales.x ??\u003d { type: \"timestack\" };\n state.cache \u003d createTimeseriesCache();\n\n /* ---------------------------------\n * applyData (async callback)\n * --------------------------------- */\n chart.$infiniteScroll.applyData \u003d (rows, density, range) \u003d\u003e {\n if (!rows?.length) return;\n const datasetCount \u003d rows[0].series.length;\n const perDataset \u003d Array.from({ length: datasetCount }, () \u003d\u003e []);\n\n for (const row of rows) {\n row.series.forEach((pt, i) \u003d\u003e perDataset[i].push({ x: pt.x, y: pt.y }));\n }\n\n state.cache.insertChunk({\n start: range.min,\n end: range.max,\n density,\n data: perDataset,\n });\n\n state.inflight.delete(`${range.min}:${range.max}:${density}`);\n renderFromCache();\n };\n\n /* ---------------------------------\n * Render from cache\n * --------------------------------- */\n function renderFromCache() {\n const xScale \u003d chart.scales?.x;\n if (!xScale) return;\n\n const min \u003d xScale.min;\n const max \u003d xScale.max;\n const density \u003d chart.width / (max - min);\n const datasetCount \u003d chart.data.datasets.length;\n\n const collected \u003d state.cache.collect(min, max, density, datasetCount);\n for (let i \u003d 0; i \u003c datasetCount; i++) {\n chart.data.datasets[i].data \u003d collected[i] || [];\n }\n\n const margin \u003d (max - min) * 2;\n state.cache.pruneOutside(min - margin, max + margin);\n\n chart.update(\"none\");\n }\n\n /* ---------------------------------\n * Request history (density upgrade aware)\n * --------------------------------- */\n function requestHistory() {\n const xScale \u003d chart.scales?.x;\n if (!xScale) return;\n\n const min \u003d xScale.min;\n const max \u003d xScale.max;\n const density \u003d chart.width / (max - min);\n\n const gaps \u003d state.cache.computeGaps(min, max, density);\n for (const gap of gaps) {\n const key \u003d `${gap.start}:${gap.end}:${density}`;\n if (state.inflight.has(key)) continue;\n\n state.inflight.add(key);\n config.dataSource.history({\n chart,\n config,\n density,\n start: gap.start,\n end: gap.end,\n });\n }\n }\n chart.$infiniteScroll.requestHistory \u003d requestHistory;\n\n /* ---------------------------------\n * Scale change\n * --------------------------------- */\n chart.$infiniteScroll.onScaleChange \u003d () \u003d\u003e {\n if (!state.destroyed) {\n requestHistory();\n renderFromCache();\n }\n };\n\n /* ---------------------------------\n * Destroy\n * --------------------------------- */\n const originalDestroy \u003d chart.destroy;\n chart.destroy \u003d function () {\n state.destroyed \u003d true;\n config.dataSource.destroy?.(chart);\n originalDestroy.apply(this, arguments);\n };\n};",
"events": [
"mousemove",
"mouseout",
"click",
"touchstart",
"touchmove"
],
"id": "infiniteScroll"
}
]
},
"scripts": {
"customMethods": [
{
"name": "get_history_data",
"params": [
"start",
"end",
"density"
],
"script": " \"\"\"\n Generate synthetic test points between start and end,\n using the requested density (points per millisecond).\n Returns a list of dicts: { \u0027series\u0027: [ {x, y}, ... ] }\n \"\"\"\n as_date \u003d lambda x: system.date.fromMillis(int(x))\n \n now \u003d system.date.now()\n start_ms \u003d system.date.toMillis(self.min_date(as_date(start), now))\n end_ms \u003d system.date.toMillis(self.min_date(as_date(end), now))\n\n # Compute number of points from density\n points \u003d max(2, int((end_ms - start_ms) * density))\n dt \u003d (end_ms - start_ms) / float(points - 1)\n\n results \u003d []\n\n for i in range(points):\n x \u003d start_ms + i * dt\n data_point \u003d self.generate_data_point(x)\n results.append({\u0027series\u0027: [data_point]})\n\n return results"
},
{
"name": "generate_data_point",
"params": [
"x"
],
"script": "\timport math\n\t\n\tamplitude \u003d 1\n\tfrequency \u003d 1\n\t\n\tt \u003d x / 10000000.0\n\ty \u003d amplitude * math.sin(2 * math.pi * frequency * t)\n\t\n\treturn {\n\t\t\u0027x\u0027: x,\n\t\t\u0027y\u0027: y\n\t}"
},
{
"name": "min_date",
"params": [
"a",
"b"
],
"script": "\tif (system.date.isBefore(a, b)):\n\t\treturn a\n\telse:\n\t\treturn b"
}
],
"extensionFunctions": null,
"messageHandlers": [
{
"messageType": "data-request",
"pageScope": true,
"script": "\tdata \u003d []\n\trequest_type \u003d payload.get(\u0027type\u0027)\n\t\t\n\tif (request_type \u003d\u003d \u0027history\u0027):\t\n\t\tstart \u003d payload.get(\u0027start\u0027)\n\t\tend \u003d payload.get(\u0027end\u0027)\n\t\tdensity \u003d payload.get(\u0027density\u0027)\n\t\trows \u003d self.get_history_data(start, end, density)\n\t\t\t\t\n\t\tif len(rows) \u003d\u003d 0:\n\t\t\treturn\n\t\t\t\n\t\tproxy \u003d self.getJavaScriptProxy()\n\t\tproxy.runAsync(\u0027\u0027\u0027(rows, density, range) \u003d\u003e {\n\t\t\tconsole.log(\u0027history returned\u0027, rows)\n\t\t\tthis.$infiniteScroll?.applyData(rows, density, range)\n\t\t}\u0027\u0027\u0027, {\n\t\t\t\u0027rows\u0027: rows,\n\t\t\t\u0027density\u0027: density,\n\t\t\t\u0027range\u0027: {\n\t\t\t\t\u0027min\u0027: start,\n\t\t\t\t\u0027max\u0027: end\n\t\t\t}\n\t\t})",
"sessionScope": false,
"viewScope": false
}
]
},
"type": "embr.chart.chart-js"
}
],
"meta": {
"name": "root"
},
"type": "ia.container.coord"
}
}
I know, it's a lot a manual set up. My only concern so far has been making these things possible.
The future development effort is going into making it easier to write and reuse this JavaScript code, meaning you (or community members) can write this functionality once then apply it across multiple charts.