Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Interactive canvas layer #922

Open
Minardil opened this issue Feb 5, 2024 · 19 comments
Open

Interactive canvas layer #922

Minardil opened this issue Feb 5, 2024 · 19 comments

Comments

@Minardil
Copy link

Minardil commented Feb 5, 2024

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
Copy link
Owner

leeoniya commented Feb 5, 2024

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 #876.

taking all this into consideration makes this not completely trivial. long term, i would prefer not to have two different strategies either.

@Minardil
Copy link
Author

Minardil commented Feb 6, 2024

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
Copy link
Owner

leeoniya commented Feb 6, 2024

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
Copy link
Author

Minardil commented Feb 6, 2024

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
Copy link
Owner

leeoniya commented Feb 6, 2024

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
Copy link
Owner

leeoniya commented Feb 6, 2024

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
Copy link
Author

Minardil commented Feb 6, 2024

@Minardil
Copy link
Author

Minardil commented Feb 6, 2024

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
Copy link
Owner

leeoniya commented Feb 6, 2024

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
Copy link
Author

Minardil commented Feb 6, 2024

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
Copy link
Author

Minardil commented Feb 6, 2024

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
Copy link
Author

Minardil commented Feb 6, 2024

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

@Minardil
Copy link
Author

Minardil commented Feb 6, 2024

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

@leeoniya
Copy link
Owner

leeoniya commented Feb 6, 2024

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?

Peek.2024-02-06.16-27.mp4

@Minardil
Copy link
Author

Minardil commented Feb 6, 2024

image

@Minardil
Copy link
Author

Minardil commented Feb 6, 2024

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

@Minardil
Copy link
Author

Minardil commented Feb 6, 2024

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

@Minardil
Copy link
Author

Minardil commented Feb 6, 2024

Screen.Recording.2024-02-07.at.00.36.42.mov

@leeoniya
Copy link
Owner

leeoniya commented Feb 6, 2024

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants