Tilt Map
Basic re-implementation of Tilt Map by Yang et al. using Anu and Babylon. Grab and tilt the chart in XR or drag the bottom slider on the browser to transition the chart between a bar, prism, and choropleth map based on tilt angle. For the best effect, smoothly change the tilt angle of the chart.
js
// SPDX-License-Identifier: Apache-2.0
// Copyright : J.P. Morgan Chase & Co.
import { Vector3, Scene, HemisphericLight, ArcRotateCamera, Color3, Quaternion, SixDofDragBehavior } from '@babylonjs/core';
import { AdvancedDynamicTexture, Slider } from '@babylonjs/gui';
import { GradientMaterial } from '@babylonjs/materials';
import * as anu from '@jpmorganchase/anu';
import * as d3 from 'd3';
import data from './data/population_engineers_hurricanes.csv';
import centroids from './data/centroids.json';
import geoJ from "./data/gz_2010_us_040_00_5m.json";
export function tiltMap(engine){
//Babylon boilerplate
const scene = new Scene(engine);
const light = new HemisphericLight('light1', new Vector3(0, 10, -10), scene)
const camera = new ArcRotateCamera("Camera", -(Math.PI / 4) * 3, Math.PI / 4, 10, new Vector3(0, 0, 0), scene);
camera.wheelPrecision = 20;
camera.minZ = 0;
camera.attachControl(true);
camera.position = new Vector3(0, 2.5, -2)
//Merge our population data and our centroid data (of each US state) together into a single dataset
function mergeBy(key, dataL, dataR) {
const rMap = dataR.reduce((m, o) => m.set(o[key], { ...m.get(o[key]), ...o }), new Map);
return dataL.filter(x => rMap.get(x[key])).map(x => ({...x, ...rMap.get(x[key]) }));
}
let population = mergeBy("id", data, centroids);
//Only show the contiguous US states
population = population.filter((d) => d.state != "AK" && d.state != "HI");
//Filter our GeoJSON such that it only includes states that we have data for
let filteredGeoJ = {};
filteredGeoJ.type = geoJ.type;
filteredGeoJ.features = geoJ.features.filter(d => population.find(x => Number(x.id) == Number(d.properties.STATE)));
//Create parent object and child visualizations (choropleth, prism, bar)
let tiltMap = anu.create('cot', 'tiltMap');
let choroplethMap = anu.createMeshMap('choropleth', { geoJson: filteredGeoJ, depth: 0.01, projection: d3.geoAlbers().reflectY(true), size: [3,3], simplification: 0.00005 });
choroplethMap.setParent(tiltMap);
let prismMap = anu.createMeshMap('prism', { geoJson: filteredGeoJ, depth: 1, projection: d3.geoAlbers().reflectY(true), size: [3,3], simplification: 0.00005 });
prismMap.setParent(tiltMap);
let barChart = anu.create('cot', 'barChart'); //Parent object that holds each individual bar
barChart.setParent(tiltMap);
let bars = anu.selectName('barChart', scene) //The bars themselves
.bind('box', { height: 1, width: 0.05, depth: 0.01 }, population);
//Set scales
let scaleC = d3.scaleSequential(anu.sequentialChromatic('OrRd').toStandardMaterial()).domain([0,40000000]); //Shared by all
let scaleY = d3.scaleLinear().domain([0,40000000]).range([0, 0.5]); //Shared by prism and bar
let scaleX = d3.scaleBand().domain([...population.sort((a, b) => a.longitude - b.longitude).map(d => d.state)]).range([-1.5,1.5]); //Used only by bar chart
//Sorted only based on longitude
//Set material and color of the choropleth map
choroplethMap.selection
.material((d,n,i) => {
let stateData = population.find(x => Number(x.id) == Number(d.STATE)); //Access the population stored in another dataset based on the data 'id' bound to each state's mesh
return scaleC(stateData.population);
})
.specularColor(Color3.Black()); //Disable specular to minimize reflections
//Set the origin point, material, and color of the prism map
prismMap.selection
.run((d,n,i) => {
//The local origin for all meshes in the map is at world (0,0,0) regardless of where the vertices actually are, making transformations awkward
//Here we move the mesh so that the vertices are centered around world (0,0,0), bake the transform to "reset" the local origin, then move it back to its original position
let centroidData = population.find(x => Number(x.id) == Number(d.STATE));
let pos = prismMap.projection([centroidData.longitude, centroidData.latitude]);
n.position = new Vector3(-pos[0], 0, -pos[1]);
n.bakeCurrentTransformIntoVertices();
n.computeWorldMatrix(); //Make sure transformation matrix is now up to date
n.position = new Vector3(pos[0], 0, pos[1]);
})
.material((d,n,i) => {
let stateData = population.find(x => Number(x.id) == Number(d.STATE));
return scaleC(stateData.population);
})
.scalingY((d,n,i) => {
let stateData = population.find(x => Number(x.id) == Number(d.STATE));
return -scaleY(stateData.population);
})
.specularColor(Color3.Black()); //Disable specular to minimize reflections
//Set material, color, and default height of the bar chart
bars.material((d,n,i) => scaleC(d.population))
.scalingY((d,n,i) => scaleY(d.population));
//Create labels for each state
let labels = anu.selectName('barChart', scene)
.bind('planeText', { text: (d) => d.state, size: 0.075, parent: barChart, color: Color3.Black() }, population)
.position((d,n,i) => new Vector3(scaleX(d.state), -0.05, 0));
//We want to be a bit fancier with our axes, so we create our own axes instead of the Axis prefab
//Create a GradientMaterial that will provide a gradient on the Mesh it is applied to (imported from https://www.npmjs.com/package/@babylonjs/materials)
let gradMat = new GradientMaterial('gradMat', scene);
gradMat.topColor = Color3.FromHexString('#b30000');
gradMat.bottomColor = Color3.FromHexString('#fee8c8');
gradMat.offset = 0.5;
gradMat.smoothness = 3;
//Create and position planes that will display these gradients, one for the left and right side of the TiltMap
let legends = anu.selectName('tiltMap', scene)
.bind('plane', { width: 0.05, height: 1, sideOrientation: 2 }, [undefined, undefined])
.name((d,n,i) => i == 0 ? "leftLegend" : "rightLegend")
.material(gradMat)
.positionX((d,n,i) => i == 0 ? -1.6 : 1.6)
.positionY(0.25)
.scalingY(0.5)
//Create and position labels for the axes
legends.bind('planeText', { text: (d) => d, size: 0.1 }, [ "0M", "10M", "20M", "30M", "40M"])
.positionX((d,n,i) => n.parent.name.startsWith('left') ? -0.1 : 0.1)
.positionY((d,n,i) => n.parent.name.startsWith('left') ? i * 0.25 - 0.5 : (i - 5) * 0.25 - 0.5)
//Helper function
function lerp(start, end, t) {
return (1 - t) * start + t * end;
}
//Force The tiltMap to use Quaternions. This will disable eulerAngles, but ensures consistency with Babylon behaviors that also use Quaternions
//See https://doc.babylonjs.com/features/featuresDeepDive/mesh/transforms/center_origin/rotation_quaternions#warning
tiltMap.rotationQuaternion = Quaternion.Identity();
//React whenever the TitMap is moved
tiltMap.onAfterWorldMatrixUpdateObservable.add(() => {
//Get the pitch angle of the TiltMap
var angle = tiltMap.rotationQuaternion.toEulerAngles().x;
//Assign that angle to our axes so that it stays vertical
legends.rotationX(-angle);
//Convert to degrees for our convenience
angle = angle * 180 / Math.PI;
//Modify the TiltMap based on the angle. Thresholds are directly from the original paper
//Note that here we do not use Anu transitions as TiltMap animations are based on tilt angle and not time, thus we manually change encodings through interpolation similar to .tween()
//Choropleth
if (angle <= -70) {
choroplethMap.selection.prop('isVisible', true);
prismMap.selection.prop('isVisible', false);
bars.prop('isVisible', false);
}
//Choropleth <-> Prism
else if (angle <= -45) {
choroplethMap.selection.prop('isVisible', true);
prismMap.selection.prop('isVisible', true);
bars.prop('isVisible', false);
//t=0: Choropleth, t=1: Prism
let t = Math.abs((angle - -70) / (-70 - -45));
prismMap.selection.scalingY((d,n,i) => {
let stateData = population.find(x => Number(x.id) == Number(d.STATE));
return lerp(0, -scaleY(stateData.population), t);
});
labels.positionY((d,n,i) => {
return lerp(0.05, scaleY(d.population) + 0.05, t);
});
}
//Prism
else if (angle <= -15) {
choroplethMap.selection.prop('isVisible', true);
prismMap.selection.prop('isVisible', true);
bars.prop('isVisible', false);
prismMap.selection.scalingX(1)
.scalingZ(1);
}
//Prism <-> Bar chart (prisms growing/shrinking)
else if (angle <= -7.5) {
choroplethMap.selection.prop('isVisible', true);
prismMap.selection.prop('isVisible', true);
bars.prop('isVisible', true);
//t=0: Large prisms, t=1: Shrunk prisms
let t = Math.abs((angle - -15) / (-15 - -7.5));
prismMap.selection.scalingX(lerp(1, 0.05, t))
.scalingZ(lerp(1, 0.05, t));
}
//Prism <-> Bar chart (bars moving)
else if (angle < 0) {
choroplethMap.selection.prop('isVisible', false);
prismMap.selection.prop('isVisible', false);
bars.prop('isVisible', true);
//t=0: Bars at geographical positions, t=1: Bars in linear order
let t = Math.abs((angle - -7.5) / (-7.5 - 0));
bars.position((d,n,i) => {
let proj = prismMap.projection([d.longitude, d.latitude]);
let geoPos = new Vector3(proj[0], scaleY(d.population) / 2, proj[1]);
let chartPos = new Vector3(scaleX(d.state), scaleY(d.population) / 2, 0);
return Vector3.Lerp(geoPos, chartPos, t);
});
labels.position((d,n,i) => {
let proj = prismMap.projection([d.longitude, d.latitude]);
let geoPos = new Vector3(proj[0], scaleY(d.population) + 0.05, proj[1]);
let chartPos = new Vector3(scaleX(d.state), -0.05, 0)
return Vector3.Lerp(geoPos, chartPos, t);
});
}
//Bar chart
else {
choroplethMap.selection.prop('isVisible', false);
prismMap.selection.prop('isVisible', false);
bars.prop('isVisible', true);
bars.position((d,n,i) => new Vector3(scaleX(d.state), scaleY(d.population) / 2, 0));
labels.position((d,n,i) => new Vector3(scaleX(d.state), -0.05, 0));
}
});
//Billboard the state labels every frame. mesh.billboardMode can do this, though it causes erratic behavior with our PlaneText prefab
scene.onBeforeRenderObservable.add(() => {
labels.run((d,n,i) => {
n.lookAt(Vector3.Zero().subtract(camera.position), undefined, undefined, undefined, 1)
});
})
//Add a slider to control the angle of TiltMap (mainly meant for desktop)
let advancedTexture = AdvancedDynamicTexture.CreateFullscreenUI("UI");
let slider = new Slider();
slider.minimum = 0;
slider.maximum = Math.PI / 2;
slider.value = 0;
slider.height = '2.5%';
slider.width = '30%';
slider.top = '40%';
slider.color = 'white';
slider.background = 'white';
slider.borderColor = 'black';
slider.onValueChangedObservable.add((value) => {
let euler = tiltMap.rotationQuaternion.toEulerAngles();
tiltMap.rotationQuaternion = Quaternion.FromEulerAngles(-value, euler.y, euler.z);
})
advancedTexture.addControl(slider);
//Add a behavior so that the TiltMap can be grabbed and moved (mainly meant for XR)
const sixDofDragBehavior = new SixDofDragBehavior();
tiltMap.addBehavior(sixDofDragBehavior);
return scene;
}