- under the hood

ThreeJS

We hebben onlangs de mogelijkheid toegevoegd om 3D Livechart-templates te maken. Hiervoor hebben we gebruik gemaakt van de ThreeJS-library. Kort gezegd is ThreeJS een tool die het makkelijk(er) maakt om 3D-scenes in te laden, te manipuleren en te renderen met WebGL.

Het gaat ongeveer als volgt, je creert een scene, je laadt je 3D model in, je definieert een aantal lichten in je scene, je plaatst een camera en je rendert (tekent) het resultaat naar het scherm. Dan heb je alleen nog maar een statisch plaatje, dus vervolgens moet er nog iets gebeuren om het interessant te maken.

In ons geval hebben we ervoor gekozen om te beginnen met een kaart van Nederland, opgedeeld in de 12 provincies, en hier een Livechart-template van te maken. Hieronder een video van de output van onze demo-template:

In bovenstaande template animeren we de hoogte van de 12 provincies en de positie van de camera met ThreeJS in de template zelf. Net als voor alle andere animaties in Livecharts gebruiken we hiervoor Greensock. Met deze library kan je nagenoeg alles op een webpagina animeren, dus ook 3D content.

De code voor het animeren van de provincies ziet er als volgt uit:

// Loop through all provinces
    this.nlModel.children.map(province => {
      // Get the value for that province from the data
      let value = this.valueMap[this.provinceMap[province['name']]];
      // Figure out the height that province should have
      let heightValue = heightMin + ((value - valueMin) * (heightMax - heightMin)) / valueDiff;
      // If the height is pretty much zero, give it a tiny height so as to render properly
      if (heightValue < 0.01 && heightValue > -0.01) {
        heightValue = 0.01;
      }
      // Determine the amount to scale the default height of the province with
      // to get the desired height
      let scaleValue = heightValue / heightDefault;
      // Create the animation
      timeline.to(province.scale, { duration, y: scaleValue }, label ?? 'animateProvincesUp');
    });
    // If we have values that lie below zero, animate the zero plane from 0 opacity to
    // half opaque to delineate values above and below zero (like a 2D x-axis)
    if (valueMin < 0) {
      timeline.to(this.zeroPlane.material, { opacity: 0.5, duration }, label ?? 'animateProvincesUp');
    }

Misschien is het je opgevallen dat in bovenstaande code de provincies in de y-richting worden geschaald. De richting maakt niet uit maar dat we schalen wel want als de oorsprong van het object (in dit geval een provincie) niet op de onderkant van de provincie ligt dan schalen we zowel naar boven als naar beneden, relatief ten opzichte van de oorsprong. En dat is niet wat we willen. In onderstaand diagram heb ik geprobeerd dit te illustreren (sorry voor m’n slechte tekenskills). Het gewenste effect is dat van het rechter-plaatje maar dat krijgen we alleen als de oorsprong op y=0 ligt.

We hebben twee opties:

  1. we kunnen ieder model dat we inlezen in 3D software zodanig manipuleren dat de oorsprong ligt waar we willen
  2. we kunnen een stukje code schrijven om ons model naar onze wensen aan te passen in Livecharts, dit heeft als bijkomend voordeel dat altijd in de software nog zouden kunnen beslissen om een andere kant op te schalen, dus dat hebben we gedaan
public originToBottom(geometry, axis: 'x' | 'y' | 'z', align: 'bottom' | 'top') {
/* This method shifts all vertices such that either the top or
    the bottom of the geometry is at 0 of the axis that we selected.
    This means that we can scale up or down along that axis and
    keep the bottom in place. */

    // The amount we'll need to move stuff around
    let shift: number;

    // If we want to align the bottom we need to know the min value
    // otherwise the max value...
    let minMax = align === 'bottom' ? 'min' : 'max';

    // Loop through all elements of the object
    // in case it is not a plain geometry
    geometry.traverse(child => {
      if (child instanceof THREE.Mesh) {
        // Figure out the boundingBox
        child.geometry.computeBoundingBox();
        let box = child.geometry.boundingBox;
        let distance = box[minMax][axis];
        // We need the smallest distance to the axis
        shift = shift <= distance ? shift : distance;
      }
    });

    geometry.traverse(child => {
      if (child instanceof THREE.Mesh) {
        // Now move all vertices by shift
        // The following array: child.geometry.attributes.position.array
        // holds all x,y,z values for every vertex, depending on which direction
        // we're shifting our object we need every 0th / 1st / 2nd element
        // and shift that...
        let arrayLength = child.geometry.attributes.position.array.length;
        let steps = arrayLength / 3;
        // Set the starting index into the array
        let start: number;
        switch (axis) {
          case 'x':
            start = 0;
            break;
          case 'y':
            start = 1;
            break;
          case 'z':
            start = 2;
            break;
        }
        // Make a range of how many times we need to take an element
        let indices = _.range(0, steps);
        indices.map(step => {
          let index = start + step * 3;
          let value = child.geometry.attributes.position.array[index];
          // Shift it!
          child.geometry.attributes.position.array[index] = value - shift;
        });
        // Move the whole thing back into position so that the
        // geometry is back in its original place
        switch (axis) {
          case 'x':
            child.translateX(shift);
            break;
          case 'y':
            child.translateY(shift);
            break;
          case 'z':
            child.translateZ(shift);
            break;
        }
        // Only necessary if the object has been rendered already
        // but might as well set it... right? Right.
        child.geometry.attributes.position.needsUpdate = true;
      }
    });
  }

En voila, we hebben een utility-functie waaraan je een geometrie kunt geven, een as kunt opgeven en of je de boven- of de onderkant van de geometrie op de gekozen as wilt leggen. Alle vertices worden verschoven en het geheel wordt netjes weer teruggezet op de oorspronkelijke plek, maar nu met de oorsprong waar we ‘m willen.


Jorma