leeoniya / uPlot

📈 A small, fast chart for time series, lines, areas, ohlc & bars
MIT License
8.48k stars 370 forks source link

Interactive canvas layer #922

Open Minardil opened 3 months ago

Minardil commented 3 months ago

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

leeoniya commented 3 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.

Minardil commented 3 months ago

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})

leeoniya commented 3 months ago

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:

https://stackoverflow.com/questions/41830529/optimizing-native-hit-testing-of-dom-elements-chrome/51534641#51534641

(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>
Minardil commented 3 months ago

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

leeoniya commented 3 months ago

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

leeoniya commented 3 months ago

when i profile your code, most of the cost is in re-rendering the arcs (points) on canvas:

image

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>
Minardil commented 3 months ago

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

Minardil commented 3 months ago

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

leeoniya commented 3 months ago

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.

Minardil commented 3 months ago

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!

Minardil commented 3 months ago
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);
                }
            }
        }
    };
}
Minardil commented 3 months ago

Also, as I already said, chart with a lot of series hangs on my computer even without "fill"

Minardil commented 3 months ago

Maybe It's worth it to add an example of such plugin to your demos?

leeoniya commented 3 months ago

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

Minardil commented 3 months ago
image
Minardil commented 3 months ago

I know from my colleges that uplot works perfectly on m2 without any performance hacks

Minardil commented 3 months ago

I have checked safari with your code. It works better but not well enough

Minardil commented 3 months ago

https://github.com/leeoniya/uPlot/assets/5220791/0b19ae74-5972-4d1a-84e2-f127c2c17a27

leeoniya commented 3 months ago

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.