Skip to main content
Yianni_Ververis
Employee
Employee

Creating 3D bars on a map using Nebula.js, Mapbox GL and Three.js.

Couple of months ago I blogged about Mapbox GL and Nebula.js https://community.qlik.com/t5/Qlik-Design-Blog/Using-Mapbox-GL-with-Nebula-js/ba-p/1817621.

Today, I will take that example and add some 3D Bars with Three.js.

I will be using the observable notation but you can substitute "require" with "import" on your React/Angular apps

First, fork or follow the setup as described in my previous blog.  Then, we have to add the installation and importing of Three and GSAP for the animation.

 

 

// Observable
GSAP = require('gsap');
TweenMax = GSAP.TweenMax;

// React / Angular
import { TweenMax } from 'gsap';
import * as THREE from 'three/build/three';

 

 

Lets define the constants

 

  let maxBarΝumberFromData = 0;
  let maxNumberOfBars = 0;
  let map;
  let camera;
  let scene;
  let renderer;
  const barWidth = 100;
  const barOpacity = 1;
  
    
  // parameters to ensure the model is georeferenced correctly on the map
  const modelOrigin = [-30, 55];
  const modelAltitude = 0;
  const modelRotate = [Math.PI / 2, 0, 0];
  const modelAsMercatorCoordinate = mapboxgl.MercatorCoordinate.fromLngLat(
    modelOrigin,
    modelAltitude,
  );

  // transformation parameters to position, rotate and scale the 3D model onto the map
  const modelTransform = {
    translateX: modelAsMercatorCoordinate.x,
    translateY: modelAsMercatorCoordinate.y,
    translateZ: modelAsMercatorCoordinate.z,
    rotateX: modelRotate[0],
    rotateY: modelRotate[1],
    rotateZ: modelRotate[2],
    /* Since our 3D model is in real world meters, a scale transform needs to be
  * applied since the CustomLayerInterface expects units in MercatorCoordinates.
  */
    scale: modelAsMercatorCoordinate.meterInMercatorCoordinateUnits(),
  };

 

 

 

Now we can add the function that creates the bars on the map and animates the height

 

 

      const createBar = (posx, posz, posy, order) => {
        const max = 3000;
        const ratio = Number(posy) / Number(maxBarΝumberFromData);
        const y = max * ratio;
        const _posy = 1;
        const geometry = new THREE.BoxGeometry(barWidth, 1, barWidth, 1, 1, 1);
        const material = new THREE.MeshLambertMaterial({ color: 0xfffff, transparent: true });
        const bar = new THREE.Mesh(geometry, material);
        bar.position.set(posx, _posy, posz);
        bar.name = `bar-${order}`;
        bar.userData.y = y;
        bar.material.opacity = barOpacity;
        scene.add(bar);
        // Animate
        TweenMax.to(bar.scale, 1, { y, delay: order * 0.01 });
        TweenMax.to(bar.position, 1, { y: y / 2, delay: order * 0.01 });
        maxNumberOfBars = order;
      };

 

 

 

Now, lets switch the "buildLayer" function with this one so we can create a custom 3d layer using three.js

 

 

      // Create the layer that will hold the bars
      const buildLayer = () => {
        const layer = {
          id: '3d-model',
          type: 'custom',
          renderingMode: '3d',
          onAdd(_map, gl) {
            camera = new THREE.Camera();
            scene = new THREE.Scene();

            // create two three.js lights to illuminate the model
            const directionalLight = new THREE.DirectionalLight(0xffffff);
            directionalLight.position.set(-90, 200, 130).normalize();
            scene.add(directionalLight);
            // sky color ground color intensity
            const directionalLight2 = new THREE.DirectionalLight(0xffffff, 0.3);
            directionalLight2.position.set(90, 20, -100).normalize();
            scene.add(directionalLight2);
            
            qMatrix.forEach((row, index) => {
              maxBarΝumberFromData = (maxBarΝumberFromData < row[1].qNum) ? row[1].qNum : maxBarΝumberFromData;
            })
            
            qMatrix.forEach((row, index) => {
              createBar(row[2].qNum * 150, row[1].qNum * 150, row[5].qNum, index);
            })
            
            // scale up geometry
            scene.scale.set(300, 300, 300);

            // use the Mapbox GL JS map canvas for three.js
            renderer = new THREE.WebGLRenderer({
              canvas: _map.getCanvas(),
              context: gl,
              antialias: true,
            });

            renderer.autoClear = false;
          },
          render(gl, matrix) {
            const rotationX = new THREE.Matrix4().makeRotationAxis(
              new THREE.Vector3(1, 0, 0),
              modelTransform.rotateX,
            );
            const rotationY = new THREE.Matrix4().makeRotationAxis(
              new THREE.Vector3(0, 1, 0),
              modelTransform.rotateY,
            );
            const rotationZ = new THREE.Matrix4().makeRotationAxis(
              new THREE.Vector3(0, 0, 1),
              modelTransform.rotateZ,
            );

            const m = new THREE.Matrix4().fromArray(matrix);
            const l = new THREE.Matrix4()
              .makeTranslation(
                modelTransform.translateX,
                modelTransform.translateY,
                modelTransform.translateZ,
              )
              .scale(
                new THREE.Vector3(
                  modelTransform.scale,
                  -modelTransform.scale,
                  modelTransform.scale,
                ),
              )
              .multiply(rotationX)
              .multiply(rotationY)
              .multiply(rotationZ);

            camera.projectionMatrix = m.multiply(l);
            
            renderer.state.reset();
            renderer.render(scene, camera);
            map.triggerRepaint();
          },
        };
        return layer;
      }

 

 

 

This is it! The final result should be similar to this:

map3.jpg

 

You can view, fork and play with the above demo at
https://observablehq.com/@yianni-ververis/nebula-js-mapbox-with-three-js?collection=@yianni-ververis...

/Yianni