Open Minardil opened 9 months ago
i'm open to exploring this. i am sure updating one canvas element will be cheaper than updating 200 dom elements. i'd like to see some tests of how it impacts mem as well, not just cpu, in cases of 100 charts with 5 series each. or 200 sparklines with one series each
besides .u-over, there is also .u-under, as well as plans to add a clipped and unclipped region in https://github.com/leeoniya/uPlot/issues/876.
taking all this into consideration makes this not completely trivial. long term, i would prefer not to have two different strategies either.
Here is small demo. It takes time to render because there are a lot of series on each chart. https://minardil.github.io/uPlot/demos/lot-of-series.html. Changes in uplot are now dirty but I think it could be done as a plugin if there is an exposed method like u.draw(idx, opts: {canvasCtx, stroke, fill, width, etc})
thanks, i'll take a look soon.
i did a quick test of the impact of creating multiple canvases and putting bunch of hover point dom elements inside.
performance profile of moving the mouse (without moving the points) showed a lot of cost going to browser hit-testing, even though there are no hover css styles and no listeners attached to the points. i found a neat trick to reduce this by a lot:
(the main perf cost is not this, tho. the main cost is the actual .style.transform
mutation of each hover point)
<!doctype html>
<html>
<head>
<title>many-can</title>
<style>
body {
margin: 0;
}
canvas {
background: pink;
}
.wrap {
position: relative;
display: inline-block;
margin: 8px;
}
.overlay {
width: 100%;
height: 100%;
position: absolute;
opacity: 0;
z-index: 100;
left: 0;
top: 0;
}
.pt {
top: 0;
left: 0;
position: absolute;
will-change: transform;
pointer-events: none;
}
</style>
</head>
<body>
<script>
let count = 30;
let series = 200;
let width = 800;
let height = 600;
let pxRatio = devicePixelRatio;
let ctxs = [];
for (let i = 0; i < count; i++) {
let can = document.createElement('canvas');
can.width = Math.ceil(width * pxRatio);
can.height = Math.ceil(height * pxRatio);
can.style.width = `${width}px`;
can.style.height = `${height}px`;
let ctx = can.getContext('2d');
ctxs.push(ctx);
let wrap = document.createElement('div');
wrap.className = 'wrap';
wrap.style.width = `${width}px`;
wrap.style.height = `${height}px`;
wrap.appendChild(can);
for (let i = 0; i < series; i++) {
let pt = document.createElement('div');
pt.className = 'pt';
pt.style.width = '5px';
pt.style.height = '5px';
pt.style.backgroundColor = '#'+(Math.random() * 0xFFFFFF << 0).toString(16).padStart(6, '0');
pt.style.transform = `translate(${Math.round(Math.random() * width)}px, ${Math.round(Math.random() * height)}px)`;
wrap.appendChild(pt);
}
// dramatically reduces cost of browser hit-testing
// https://stackoverflow.com/questions/41830529/optimizing-native-hit-testing-of-dom-elements-chrome/51534641#51534641
let ovr = document.createElement('div');
ovr.className = 'overlay';
wrap.appendChild(ovr);
document.body.appendChild(wrap);
}
</script>
</body>
</html>
Maybe you misunderstood me. The problem is not in hovering points but in hovering whole line or it's area. It's is shown in my example. Of course it is better to draw all hovering items on second canvas but bottleneck is line but not points
oh, yeah i misunderstood :)
point hover is also a huge cost when you have tons of datapoints (not just tons of series), and i guess def related to moving things off to an separate interaction canvas.
due to the way that bands are constructed, and opacity/alpha can interact between series, im not sure that moving the focus rendering to another canvas can be done properly in the general case.
i think what you're trying to accomplish can be done with existing hooks, and simply grabbing the cached Path2D objects from the needed series._paths
and drawing them to your own injected canvas.
maybe once hover point rendering is moved to its own canvas, you can draw the focused/cached path to that canvas in a setSeries hook. it might be necessary to noop the focus redraw in uPlot, though it may already be done when focus.alpha: 1
when i profile your code, most of the cost is in re-rendering the arcs (points) on canvas:
i see a very significant improvement from disabling point rendering and skipping the fill color:
series.push({
stroke: color,
fill: null,
points: {
show: false,
}
});
full code:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>A lot of lines hover</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="../dist/uPlot.min.css">
</head>
<body>
<script src="../dist/uPlot.iife.js"></script>
<script>
const numCharts = 12;
const numSeries = 150;
let xs = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30];
for (let i = 0; i < numCharts; i++) {
const series = [{}];
const data = [
xs,
];
for (let i = 0; i < numSeries; i++) {
const ys = xs.map((t, j) => (180 / numSeries * i) - Math.sin(j) * 120);
data.push(ys);
const color = '#' + (Math.random() * 0xFFFFFF << 0).toString(16).padStart(6, '0');
series.push({
stroke: color,
fill: null,
points: {
show: false,
}
});
}
const opts = {
legend: {
show: false
},
width: 1920,
height: 800,
focus: {
alpha: 0.5
},
cursor: {
focus: {
prox: 30
}
},
scales: {
x: {
time: false,
},
},
series
};
let u = new uPlot(opts, data, document.body);
}
</script>
</body>
</html>
Your optimization does not work at all on my computer: https://github.com/leeoniya/uPlot/assets/5220791/61347978-b99c-45bc-bdf5-0a360ca6f980
Here is my approach with second canvas: https://github.com/leeoniya/uPlot/assets/5220791/72f7b7d3-05af-4493-996c-5917a00d6cc4
Your optimization does not work at all on my computer: https://github.com/leeoniya/uPlot/assets/5220791/61347978-b99c-45bc-bdf5-0a360ca6f980
Here is my approach with second canvas: https://github.com/leeoniya/uPlot/assets/5220791/72f7b7d3-05af-4493-996c-5917a00d6cc4
What is more, I can't disable fill because it is a requirement to have it in real production
What is more, I can't disable fill because it is a requirement to have it in real production
with this many series it's a weird requirement, i gotta say. usually area charts are basically usesless beyond a few filled series, except maybe in stacked areas, but that sucks in a whole lot of other ways.
can you try enabling out-of-process canvas rasterization (and other gpu tweaks) and see if it makes a difference? (yes, i know you can't make all users do this :)
https://github.com/leeoniya/uPlot?tab=readme-ov-file#unclog-your-rendering-pipeline
also, when i try https://minardil.github.io/uPlot/demos/focus-cursor.html it creates but does not use the extra canvas. for sure i dont want a bespoke API for this specific case -- the standard focus route would have to use this, and i think that might be a big lift / increase in complexity for little benefit.
again, i think it's perfectly feasible to use uPlot's existing API to do what you're doing in your fork. i would suggest trying get that working as i describe above so we can see if there are some minor changes that might be needed in uPlot to get that strategy to work cleanly.
It hangs even with fewer number of lines. I just put so many in order to make it easily visible for any kind of computer. Anyway, your suggest of using setSeries helped. Thanks a lot!
function focusPlugin() {
let canvas, ctx;
let _closestDataIndex = -1;
return {
hooks: {
setSeries: (u, seriesIdx, opts) => {
canvas.width = canvas.width;
for (let i = 1; i < u.series.length; i++) {
let series = u.series[i];
if (series.hovered) {
if (series._fill) {
ctx.fillStyle = series._fill;
ctx.fill(series._paths.fill);
}
if (series._stroke) {
ctx.strokeStyle = series._stroke;
ctx.stroke(series._paths.stroke);
}
}
}
},
init: (u) => {
const over = u.over;
canvas = document.createElement("canvas");
canvas.style.position = "absolute";
canvas.style.top = "0";
over.parentNode.insertBefore(canvas, over);
ctx = canvas.getContext("2d");
over.addEventListener('mouseleave', () => {
if (_closestDataIndex !== -1) {
if (_closestDataIndex !== -1) {
u.series[_closestDataIndex].hovered = false;
u.setSeries(_closestDataIndex, {}, true);
}
// u.setHoverWidth(_closestDataIndex, 1);
}
});
},
setSize: (u) => {
console.log(u)
canvas.width = u.width * devicePixelRatio;
canvas.height = u.height * devicePixelRatio;
},
setCursor: (u) => {
const yVal = u.posToVal(u.cursor.top, 'y');
if (!u.cursor.event || !u.cursor.event.clientX) {
return;
}
const idx = u.cursor.idx;
let closestDataIndex = 1;
for (let i = 1; i < u.data.length; i++) {
if (yVal || yVal === 0) {
if (Math.abs(u.data[i][idx] - yVal) < Math.abs(u.data[closestDataIndex][idx] - yVal)) {
closestDataIndex = i;
}
}
}
if (closestDataIndex !== _closestDataIndex) {
if (_closestDataIndex !== -1) {
u.series[_closestDataIndex].hovered = false;
u.setSeries(_closestDataIndex, {}, true);
}
_closestDataIndex = closestDataIndex;
u.series[closestDataIndex].hovered = true;
u.setSeries(closestDataIndex, {}, true);
}
}
}
};
}
Also, as I already said, chart with a lot of series hangs on my computer even without "fill"
Maybe It's worth it to add an example of such plugin to your demos?
Also, as I already said, chart with a lot of series hangs on my computer even without "fill"
maybe you have a really really weak GPU, or your OS/browser/drivers have some issues. i'm trying this on a Ryzen 7 integrated laptop GPU in Linux on a 4k display in Chrome and in Firefox with zero lag. can you try my code above with no points and no fill on another machine / os / browser?
https://github.com/leeoniya/uPlot/assets/43234/f6d41ce9-4cc3-4f28-a90d-db84ef66e530
I know from my colleges that uplot works perfectly on m2 without any performance hacks
I have checked safari with your code. It works better but not well enough
your info isnt very detailed. you're showing a screenshot of your system specs which has two very different GPUs. i have no idea which one it is using. M2/apple silicon are almost supercomputers, and nothing is slow on them, so i can't get anything from that datapoint.
i tried the no-points, no-fill variant in latest Chrome on my 2016-2017 desktop build and got 0 lag.
Intel HD630 is also ~2016 era, but weaker and integrated. still something seems wonky to be this slow. idk, maybe drivers issue. to understand what is going on, you really need to dig in and investigate.
Hello. I am planning to fork uplot to make it possible to move hovering to second canvas in order to improve performance when there are a lot of (200 and more series. Maybe we could discuss it and support this functionality in the library? I am sure that it will improve the performance because we do it in our custom library