Select Git revision
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
Globe.vue 14.63 KiB
<script setup>
import { computed, nextTick, onMounted, watch } from "@vue/runtime-core";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { VueElement, ref } from "vue";
import * as TWEEN from "@tweenjs/tween.js";
import visualization from "../helpers/visualization.js";
import { getRegionName, getCountryName, getFlagUrl } from "../helpers/regionNames.js";
const props = defineProps({
geoData: Object,
covidData: Object,
selectedDate: Date,
selectedNutsCode: String,
textures: Object,
});
const emit = defineEmits(["regionSelected"]);
// Create globally usable variables for data
let tooltipElement;
const cameraTargetPosition = new THREE.Vector3(370, 370, -15);
const cameraInitialPosition = new THREE.Vector3(1000, 500, 2000);
const selectedColor = 0x0588e6;
const hoverColor = 0x6dc1fd;
const unavailableColor = "#333333";
const regionMeshData = {};
// structure of this variable:
// ATT2: [
// {
// mesh: mesh1,
// shape: [{x,y}, {x,y}, ...],
// },
// {
// mesh: mesh2,
// shape: [{x,y}, {x,y}, ...],
// },
// ],
// };
const offset = 700; // for the globes offset to the right, TODO: change based on window width
let rootObject = new THREE.Object3D(); // root object for the extruded elements
const worldLayer = 0,
objectLayer = 1;
// for the screen size
const sizes = {
width: window.innerWidth + offset,
height: window.innerHeight,
};
// Create the scene
var scene = new THREE.Scene();
// Create and init the camera
var camera = new THREE.PerspectiveCamera(
75,
sizes.width / sizes.height,
1,
10000
);
camera.position.set(
cameraInitialPosition.x,
cameraInitialPosition.y,
cameraInitialPosition.z
);
// Prepare Raycaster
var rayCaster = new THREE.Raycaster();
rayCaster.layers.set(objectLayer);
let mouse = new THREE.Vector2(-1, -1); // vector to store mouse position
let hoveredNutsCode = ref(null);
// Create and init lights
let worldLight = new THREE.DirectionalLight(0xffffff);
worldLight.position.set(
cameraTargetPosition.x,
cameraTargetPosition.y,
cameraTargetPosition.z
);
worldLight.layers.set(worldLayer, objectLayer);
scene.add(worldLight);
let objectLightInner = new THREE.DirectionalLight(0xffffff);
objectLightInner.position.set(0, 0, 1);
objectLightInner.layers.set(objectLayer);
scene.add(objectLightInner);
var objectLightOuter1 = new THREE.DirectionalLight(0xffffff);
objectLightOuter1.position.set(1, 0, 0);
objectLightOuter1.layers.set(objectLayer);
scene.add(objectLightOuter1);
var objectLightOuter2 = new THREE.DirectionalLight(0xffffff);
objectLightOuter2.position.set(-1, 0, 0);
objectLightOuter2.layers.set(objectLayer);
scene.add(objectLightOuter2);
let ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambientLight);
// Create globe mesh
let earthRadius = 300;
let segments = 128;
let rings = 128;
let mapMaterial = props.textures.globeTexture;
let geometry = new THREE.SphereGeometry(earthRadius, segments, rings);
mapMaterial.wrapS = THREE.RepeatWrapping;
mapMaterial.wrapT = THREE.RepeatWrapping;
let material = new THREE.MeshPhongMaterial({
map: mapMaterial,
color: 0xb0c8e8,
});
let mesh = new THREE.Mesh(geometry, material);
mesh.layers.set(worldLayer);
scene.add(mesh);
function createShapeGeometry(shapePoints, extrusion) {
scene.add(rootObject);
const shapeGeometry = new THREE.ExtrudeGeometry(
new THREE.Shape(shapePoints),
{
depth: 5,
bevelEnabled: false,
}
);
shapeGeometry.vertices.forEach((vertex, index) => {
let radius;
if (index < shapeGeometry.vertices.length / 2) {
radius = earthRadius;
} else {
radius = earthRadius + extrusion;
}
let phi = ((90.0 - vertex.y) * Math.PI) / 180.0;
let theta = ((360.0 - vertex.x) * Math.PI) / 180.0;
vertex.x = radius * Math.sin(phi) * Math.cos(theta);
vertex.y = radius * Math.cos(phi);
vertex.z = radius * Math.sin(phi) * Math.sin(theta);
});
return shapeGeometry;
}
function createRegions() {
let regions = props.geoData.features;
regions.forEach((region) => {
const regionNutsCode = region.properties.NUTS_ID;
//* Create the region 2d shapes
//* array including all 2d unwarped shapes for a region [[{x,y}, {x,y}, ...], [{x,y}, {x,y}, ...], ...]
// TODO: new THREE.Shape() instead of shapepoints as array?
const shapePointsArray = [];
if (region.geometry.coordinates.length === 1) {
// Single Polygon
const shapePoints = region.geometry.coordinates[0].map((point) => {
return new THREE.Vector2(point[0], point[1]);
});
shapePointsArray.push(shapePoints);
} else {
// Multipolygon
region.geometry.coordinates.forEach((coordinateSet) => {
// TODO: maybe this can be simplified with the new structure?
if (coordinateSet.length === 1) {
// nested one level deeper
const shapePoints = coordinateSet[0].map((point) => {
return new THREE.Vector2(point[0], point[1]);
});
shapePointsArray.push(shapePoints);
} else {
// nested on the same level
const shapePoints = coordinateSet.map((point) => {
return new THREE.Vector2(point[0], point[1]);
});
shapePointsArray.push(shapePoints);
}
});
}
//* Create the region mesh objects
//* add region recevied an object of shapepoints, so an inner array [{x,y}, {x,y}, ...]
const meshes = [];
shapePointsArray.forEach((shapePoints) => {
// with one shapePoints array [{x,y}, {x,y}, ...] create a new mesh
let extrusion = 1; // ? adjust this value maybe? just an original extrusion from which can be animated
// TODO: implement this
const shapeGeometry = createShapeGeometry(shapePoints, extrusion);
// TODO: enough to do this once on the top probably (allthough not sure with the color since that needs to be different)
const shapeMaterial = new THREE.MeshPhongMaterial({
color: 0xffffff, // TODO: use color scheme
side: THREE.DoubleSide,
});
const mesh = new THREE.Mesh(shapeGeometry, shapeMaterial);
mesh.layers.set(objectLayer);
mesh.name = regionNutsCode;
rootObject.add(mesh);
meshes.push(mesh);
});
// Add to regionMeshData
const output = [];
shapePointsArray.forEach((shapePoints, index) => {
output.push({
mesh: meshes[index],
shape: shapePoints,
});
});
regionMeshData[regionNutsCode] = output;
});
}
function updateRegions(animationTime) {
for (const nuts in regionMeshData) {
regionMeshData[nuts].forEach(({ mesh, shape }) => {
const covidDataWeek = props.covidData[props.selectedDate.toJSON()][nuts];
let color;
let extrusion;
if (covidDataWeek?.incidence) {
color = new THREE.Color(
visualization.colorByIncidence(covidDataWeek.incidence)
);
extrusion = visualization.extrusion(covidDataWeek.incidence);
} else {
color = new THREE.Color(unavailableColor);
extrusion = visualization.extrusion(100);
}
const shapeGeometry = createShapeGeometry(shape, extrusion);
// animate color if necessary
if (nuts != props.selectedNutsCode) {
new TWEEN.Tween(mesh.material.color)
.to(color, animationTime)
.easing(TWEEN.Easing.Quadratic.InOut)
.onUpdate(() => {
mesh.geometry.verticesNeedUpdate = true;
})
.start();
}
// animate vertices
mesh.geometry.vertices.forEach((vertex, index) => {
new TWEEN.Tween(vertex)
.to(shapeGeometry.vertices[index], animationTime)
.easing(TWEEN.Easing.Quadratic.InOut)
.onUpdate(() => {
mesh.geometry.verticesNeedUpdate = true;
})
.start();
});
});
}
}
function setRegionColor(nuts, color) {
if (!color) {
const covidDataWeek = props.covidData[props.selectedDate.toJSON()][nuts];
color = new THREE.Color(
covidDataWeek?.incidence
? visualization.colorByIncidence(covidDataWeek.incidence)
: unavailableColor
);
}
const selectedRegion = regionMeshData[nuts];
selectedRegion.forEach(({ mesh }) => {
mesh.material.color.set(color);
});
}
function onNutsCodeSelected(newValue, oldValue) {
if (oldValue) setRegionColor(oldValue);
if (newValue) setRegionColor(newValue, selectedColor);
}
const animateToRegion = function () {
const vertex =
regionMeshData[props.selectedNutsCode][0].mesh.geometry.vertices[0];
const vertexVector = new THREE.Vector3(vertex.x, vertex.y, vertex.z);
const cameraDistance = camera.position.length();
vertexVector.multiplyScalar(cameraDistance / vertexVector.length());
new TWEEN.Tween(camera.position)
.to(vertexVector, 2000)
.easing(TWEEN.Easing.Cubic.Out)
.start();
};
defineExpose({ animateToRegion });
const getHoveredIncidence = computed({
get() {
const covidDataWeek =
props.covidData[props.selectedDate.toJSON()][hoveredNutsCode.value];
if (covidDataWeek?.incidence) {
return `Incidencia: ${parseInt(covidDataWeek.incidence)}`;
} else {
return 'Incidencia: no disponible'
}
},
});
const getHoveredNewCases = computed({
get() {
const covidDataWeek =
props.covidData[props.selectedDate.toJSON()][hoveredNutsCode.value];
if (covidDataWeek?.count) {
return `Nuevos casos: ${covidDataWeek.count} (${((covidDataWeek.count / covidDataWeek.population) * 100).toFixed(2)}%)`;
} else {
return 'Nuevos casos: no disponible'
}
},
});
onMounted(async () => {
const canvas = document.querySelector(".webgl");
var renderer = new THREE.WebGLRenderer({
antialias: true,
canvas: canvas,
});
renderer.setSize(sizes.width, sizes.height);
// Create and init OrbitControls
var controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; // an animation loop is required when either damping or auto-rotation are enabled
controls.dampingFactor = 0.05;
controls.screenSpacePanning = true;
controls.zoomSpeed = 0.2;
controls.minDistance = 350;
controls.maxDistance = 2000;
let container = document.querySelector("#container");
container.style.right = `${offset}px`;
// Setup skybox
const skyboxGeo = new THREE.BoxGeometry(10000, 10000, 10000);
const skybox = new THREE.Mesh(skyboxGeo, props.textures.skyboxTexture);
scene.add(skybox);
// Setup tooltip
tooltipElement = document.querySelector("#tooltip");
canvas.addEventListener("mousemove", (event) => {
tooltipElement.style = `top: ${event.clientY - 6}px; left: ${
event.clientX + 15
}px;`;
// update the mouse variable
mouse.x = (event.clientX / (window.innerWidth + offset)) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
});
// Properly resize window when necessary
window.addEventListener("resize", () => {
// Update sizes
sizes.width = window.innerWidth + offset;
sizes.height = window.innerHeight;
// Update camera
camera.aspect = sizes.width / sizes.height;
camera.updateProjectionMatrix();
// Update renderer
renderer.setSize(sizes.width, sizes.height);
});
canvas.addEventListener(
"click",
() => {
emit("regionSelected", hoveredNutsCode.value);
},
false
);
const tick = () => {
renderer.autoClear = true;
camera.layers.set(worldLayer);
renderer.render(scene, camera);
renderer.autoClear = false;
camera.layers.set(objectLayer);
renderer.render(scene, camera);
// Shoot ray to mouse position
rayCaster.setFromCamera(mouse, camera);
var intersects = rayCaster.intersectObjects(rootObject.children);
if (intersects.length > 0) {
// found an intersecting object
if (intersects[0].object.name != hoveredNutsCode.value) {
// if the closest object intersected is not the currently stored intersection object
tooltipElement.classList.remove("hidden");
// restore previous intersection object (if it exists) to its original color
if (hoveredNutsCode.value && hoveredNutsCode.value != props.selectedNutsCode) {
setRegionColor(hoveredNutsCode.value);
}
// store reference to closest object as current intersection object
hoveredNutsCode.value = intersects[0].object.name;
setRegionColor(hoveredNutsCode.value, hoverColor);
if (
props.selectedNutsCode &&
props.selectedNutsCode != hoveredNutsCode
) {
setRegionColor(props.selectedNutsCode, selectedColor);
}
} // else: it is the same object that is already intersected, so dont do anything
} else {
// restore previous intersection object (if it exists) to its original color
if (hoveredNutsCode.value) {
setRegionColor(hoveredNutsCode.value);
}
if (props.selectedNutsCode) {
setRegionColor(props.selectedNutsCode, selectedColor);
}
// remove previous intersection object reference
hoveredNutsCode.value = null;
tooltipElement.classList.add("hidden");
}
controls.update(); // let the globe keep spinning if user lets go of the mouse
TWEEN.update();
window.requestAnimationFrame(tick); // request a new animation frame
};
watch(
props.covidData,
() => {
const animationTime = 3000;
createRegions();
updateRegions(animationTime * 1.3);
new TWEEN.Tween(camera.position)
.to(cameraTargetPosition, animationTime)
.easing(TWEEN.Easing.Cubic.Out)
.start();
},
{ immediate: true }
);
watch(
() => props.selectedDate,
() => updateRegions(300)
);
watch(() => props.selectedNutsCode, onNutsCodeSelected);
tick();
});
</script>
<template>
<div id="container">
<canvas class="webgl"></canvas>
<div id="tooltip" class="box hidden">
<h5 class="title is-5">{{ hoveredNutsCode ? getRegionName(hoveredNutsCode) : null }}</h5>
<div class="subtitle">
<img :src="getFlagUrl(hoveredNutsCode, true)" class="vertical-align tooltip-flag-image"/>
<span class="vertical-align">{{ hoveredNutsCode ? getCountryName(hoveredNutsCode) : null }}</span>
</div>
<p>{{ getHoveredIncidence }}</p>
<p>{{ getHoveredNewCases }}</p>
</div>
</div>
</template>
<style scoped>
html,
body {
height: 100%;
margin: 0;
overflow: hidden;
}
canvas {
width: 100%;
height: 100%;
}
#container {
position: absolute;
top: 0px;
left: 0px;
}
div#tooltip {
position: absolute;
background: white;
padding: 0.5rem;
width: 16rem;
}
#tooltip .subtitle {
font-size: 0.8rem;
}
.hidden {
display: none;
}
img.tooltip-flag-image {
margin-right: 0.3rem;
}
.vertical-align {
vertical-align: middle;
}
</style>