vasturiano / globe.gl

UI component for Globe Data Visualization using ThreeJS/WebGL
https://vasturiano.github.io/globe.gl/example/world-population/
MIT License
1.99k stars 298 forks source link

Position DOM element on top of point #91

Open mbrodt opened 2 years ago

mbrodt commented 2 years ago

Hey - thanks again for the awesome library!

I was wondering if it's possible to position arbitrary DOM elements on top of the points, at specific long / lattitudes?

The usecase is to have complete flexibility in the points design, to create completely custom points / labels without being inside the ThreeJS world.

So far I've tried listening to camera changes, and then updating the position of my DOM nodes to match the viewport coords (using getScreenCoords()), but the problem is that the points then (obviously) also appear on the other side of the globe (which I don't want).

Example below where the blue dots are my DOM elements positioned ontop of regular customLayer points.

https://user-images.githubusercontent.com/21239560/155993579-b45fb770-30b8-424c-99e9-860f54d47ffc.mov

So I was curious if there's a way to do this where the elements would be "behind" the globe in some way, or if I could somehow know when they are behind the globe, and then use that information to reduce their opacity myself?

Thanks again!

vasturiano commented 2 years ago

@mbrodt thanks for the interesting question. And great work on getting this setup for DOM labels, it looks awesome already!

What you're asking is quite reasonable, but a bit on the tricky side because of the two step conversion in getScreenCoords. First globe coordinates get converted to the 3D domain (x,y,z), and then that gets projected onto the screen for 2D canvas coordinates. It's difficult to find out whether the 3D spatial coordinates are in front or behind the globe because the camera position changes all the time.

Though I can think of a possible way to determine this, after receiving 2D coords from getScreenCoords, you can pass them back into toGlobeCoords(x, y), this will give you the first intersection point with the globe surface, via a raycast originated from that screen position. If the globe surface coordinates you receive are dramatically different from the ones you started with, your point is basically on the other side of the globe.

Let me know if this approach works out for your case. πŸ‘

mbrodt commented 2 years ago

Ahh, interesting idea - I'll give that a shot for sure, thanks!

A bit worried about performance if running it on every camera movement (right now I'm listening for the "change" event on controls, which gets fired very often), but might be a way to work around that.

And just for reference, I'm trying to do something similar to what Spotify are doing with their location pins in the example here: https://listeningtogether.atspotify.com/

I'll let you know how it works out, thanks again πŸ™Œ

mbrodt commented 2 years ago

Hey @vasturiano, here's an update:

Your approach works well to determine if a point is "behind" the globe, so that's great - though it does seem to take a lot of performance, and actually makes the whole experience lag if I run the check too often. I also have a slight issue with positioning the DOM nodes themselves. Right now I'm using a Gsap ticker to update their x and y position on every frame, and then once per second I check if they're behind the globe and reduce their opacity/scale to hide them.

In this video you can see that the pins are staying in the right place when auto rotating, but they have a weird "jump" when dragging the globe manually:

https://www.loom.com/share/4b8c89e2717745e28723ee2b35815217

This is my code (run every frame using a ticker) to update the pin position:

const updatedWithViewportCoords = this.points.map((point) => {
  const viewportCoords = this.world.getScreenCoords(point.lat, point.lng)

  const x = viewportCoords.x - 60
  const y = viewportCoords.y - 60 - window.innerHeight * 0.5

  return {
    ...point,
    x,
    y,
  }
})

And this is my code (run once per second) to detect if a pin is visible:

const updatedWithViewportDetection = this.points.map((point) => {
  const viewportCoords = this.world.getScreenCoords(point.lat, point.lng)
  const globeCoords = this.world.toGlobeCoords(viewportCoords.x, viewportCoords.y)

  const absLng = Math.abs(point.lng)
  const absLat = Math.abs(point.lat)
  const globeAbsLng = Math.abs(globeCoords?.lng)
  const globeAbsLat = Math.abs(globeCoords?.lat)

  // This roughly estimates if the point is infront or behind the globe (not perfect)
  const isInViewport = globeAbsLng > absLng - 3 && globeAbsLng < absLng + 3 && globeAbsLat > absLat - 3 && globeAbsLat < absLat + 3

  return {
    ...point,
    opacity: isInViewport ? 1 : 0,
    scale: isInViewport ? 0.8 : 0.3,
  }
})

Unsure if there's a better way to handle it - this works, but it doesnt feel quite smooth enough with the performance implications and the delayed check.

Thanks for all your help!

vasturiano commented 2 years ago

@mbrodt it's great to see that it works, thanks for sharing!

The performance hit in the toGlobeCoords function is most likely due to all the raycasts that need to be generated for this verification of the intersection point with the globe. And it's one per item per frame, so it can easily add up.

Also, I'm guessing the lag while rotating the globe has to do with the delay between detecting a camera motion and moving the DOM element. At the very least it will happen one frame later so that can cause the DOM elements to fall behind on the position.

I'm starting to wonder if using a ThreeJS CSS2DObject for these markers (via the Globe Objects Layer) wouldn't be a more performing and straight-forward way to do this. It would require the addition of a CSS2DRenderer to the internal scene, but we could potentially provide the support for it in the lib, if that's a viable option.

mbrodt commented 2 years ago

Yeah I think you're right on with the performance issues - and probably using a CSS2DRenderer (or CSS3DRenderer I guess should work as well?) is the way to go.

How would I best integrate that with the library? Would I create a new renderer and add that to the scene somehow?

Appreciate the help, I'm still kinda new to ThreeJS :D

justinfagnani commented 2 years ago

FYI, <model-viewer> has a feature that lets you automatically associate DOM with hotspots defined in the scene file, and it'll position the elements correctly through rotations and obscure them when they go behind the model. It might be a good way to expose a similar feature to users here: https://modelviewer.dev/examples/annotations/index.html

vasturiano commented 2 years ago

@mbrodt I've just added a new layer to the globe that I believe will fit your use case very well. It's the ability to add HTML element items, using the new HTML elements layer.

It also has built-in functionality to automatically hide items located behind the globe. This uses an additional ThreeJS renderer (CSS2DRenderer) which is overlayed on top of the existing WebGL renderer, thus you cannot rely on the regular object occlusion logic to work, as you discovered earlier in this issue. πŸ˜„ This is the harder challenge of this approach but was solved with some spheric trigonometric calculations, triggered when the camera moves.

There's a demo example here: https://globe.gl/example/html-markers/

The required code is minimal, as can be seen here: https://github.com/vasturiano/globe.gl/blob/5c471aea8e1272180ce426af46cc9cb576b4d799/example/html-markers/index.html#L28-L39

One thing to remark is that the overlayed CSS renderer canvas has to disable pointer-events, otherwise all the controlling functionality of the regular renderer no longer works (globe rotation/zoom, item hovering, etc). So, if in your HTML element you need to intercept some of these pointer events you should add the css attribute pointer-events: auto, to re-enable it just for the element without affecting the whole canvas.

Let me know if it works out for you.

mbrodt commented 2 years ago

Hey @vasturiano - this is amazing! Great work, the new layer works perfectly πŸ‘

I was experimenting with a similar approach myself, but it's great to have it as an official part of the library.

One thing I thought was pretty cool was to have the pins scale/fade based on how close they are to the edge, something like this:

https://user-images.githubusercontent.com/21239560/157001661-247a7eea-67ef-4004-a964-b0273b258fe6.mov

And this is the (very rough) code running in the render loop (using three-globe, couldn't find a way to get the globeRadius in globe.gl):

      const globeDist = camera.position.distanceTo(Globe.position);
      const globeRadius = Globe.getGlobeRadius();
      const min = globeDist - globeRadius;
      const max = globeDist;
      labels.forEach((label) => {
        const { x, y, z } = camera.position;
        const dist = camera.position.distanceTo(label.position);
        const scale = 1 - mapConstrain(dist, min, max, 0, 1);
        label.__child__.style.transform = `scale(${scale}, ${scale})`;
        label.lookAt(x, y, z);
      });

Thank you so much for your awesome work and help with this πŸ™Œ

vasturiano commented 2 years ago

@mbrodt that demo looks fantastic. I also like how the items decrease in size as the move away from the center of the globe. Great work!

The getGlobeRadius() method is available in globe.gl since version 2.25.0. Are you using a version at least as recent as that?

mbrodt commented 2 years ago

Ah yes, I see it now - and turns out the radius is always 100, so that makes it easier anyways. And now I can get the scaling effect in a few lines of code πŸ”₯

For anyone curious:

this.$gsap.ticker.add(() => {
        if (this.showLocationPins) {
          const globeDist = this.world.camera().position.distanceTo({
            x: 0,
            y: 0,
            z: 0,
          })

          const min = globeDist - 100
          const max = globeDist
          this.labels.forEach((label) => {
            const dist = this.world.camera().position.distanceTo(label.position)
            const scale = 1 - mapConstrain(dist, min, max, 0, 1)
            label.node.__child__.style.transform = `scale(${scale}, ${scale})`
          })
        }
      })

Thanks again for the awesome library and quick response time - feel free to close the issue πŸ‘