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:
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