Skip to content

Commit

Permalink
Merge pull request #92 from jameel-institute/jidea-181-refactor-globe
Browse files Browse the repository at this point in the history
JIDEA-181: Extract utility-like functions to separate file from Globe component
  • Loading branch information
david-mears-2 authored Nov 13, 2024
2 parents af05202 + ab0b852 commit dafd493
Show file tree
Hide file tree
Showing 3 changed files with 252 additions and 118 deletions.
135 changes: 17 additions & 118 deletions components/Globe.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import type { MultiPolygon } from "geojson";
import WHONationalBorders from "@/assets/geodata/2pc_downsampled_WHO_adm0_22102024";
import WHODisputedAreas from "@/assets/geodata/2pc_downsampled_WHO_disputed_areas_22102024";
import { animateSeriesColourChange, type Animation, createRotateAnimation, geoPointZoomDuration, getWideGeoBounds, handlePolygonActive, initializeSeries, pauseAnimations, removeSeries, rotateToCentroid, southEastAsiaXCoordinate } from "@/components/utils/globe";
import { rgba2hex } from "@amcharts/amcharts5/.internal/core/util/Color";
import * as am5 from "@amcharts/amcharts5/index";
import * as am5map from "@amcharts/amcharts5/map";
Expand All @@ -42,17 +43,10 @@ const hoverLandColour = am5.color("#1c6777"); // The midpoint between defaultLan
const lightGreyBackground = am5.color(rgba2hex("rgb(243, 244, 247)"));
const maxZindex = 29; // 30 is reserved by amCharts
// Animation variables
const southEastAsiaXCoordinate = -100;
const goHomeDuration = 500;
const rotateDuration = 2000;
const geoPointZoomDuration = 2000;
// To place any country of interest on the 'upper-facing' part of the globe. Just looks better.
const amountToTiltTheEarthUpwardsBy = 25;
const easing = am5.ease.inOut(am5.ease.cubic);
interface Animation { pause: () => void, stopped: boolean, play: () => void }
let gentleRotateAnimation: Animation;
let graduallyResetYAxis: Animation;
let animations: Animation[] = [];
let root: am5.Root;
let chart: am5map.MapChart;
Expand Down Expand Up @@ -173,69 +167,6 @@ const applyGlobeSettings = () => {
}
};
// https://www.amcharts.com/docs/v4/tutorials/dynamically-adding-and-removing-series/
const removeSeries = (seriesToRemove: am5map.MapPolygonSeries) => {
chart.series.removeIndex(
chart.series.indexOf(seriesToRemove),
).dispose();
};
const animateSeriesColourChange = (
series: am5map.MapPolygonSeries,
colour: am5.Color,
) => series.animate({ key: "fill", to: colour, duration: geoPointZoomDuration, easing });
const pauseAnimation = (animation: Animation) => {
if (animation && !animation.stopped) {
animation.pause();
}
};
const pauseAnimations = () => {
pauseAnimation(gentleRotateAnimation);
pauseAnimation(graduallyResetYAxis);
};
const rotateChart = (direction: "x" | "y", to: number) => {
if (direction === "x") {
const currentXRotation = chart.get("rotationX")!;
// calculates the smallest rotation between 0 amd 360 to get to the country
let diffRotation = (to - currentXRotation) % 360;
// translates rotation to between -180 and 180 because rotating 270 east
// is the same as 90 west
if (diffRotation > 180) {
diffRotation = diffRotation - 360;
}
// gets actual rotation destination by adding the difference
const toShortest = currentXRotation + diffRotation;
chart.animate({
key: "rotationX",
to: toShortest,
duration: rotateDuration,
easing,
});
} else {
chart.animate({
key: "rotationY",
to,
duration: rotateDuration,
easing,
});
}
};
const rotateToCentroid = (centroid: am5.IGeoPoint, countryIso: string) => {
return new Promise<void>((resolve) => {
pauseAnimations();
rotateChart("x", -centroid.longitude);
rotateChart("y", (amountToTiltTheEarthUpwardsBy - centroid.latitude));
setTimeout(() => {
rotatedToCountry.value = countryIso;
resolve();
}, rotateDuration);
});
};
const countryCentroid = (countryIso: string) => {
const geometry = findFeatureForCountry(countryIso)?.geometry as MultiPolygon;
if (geometry) {
Expand All @@ -246,61 +177,28 @@ const countryCentroid = (countryIso: string) => {
const rotateToCountry = async (countryIso: string) => {
const centroid = countryCentroid(countryIso);
if (chart && centroid && rotatedToCountry.value !== countryIso) {
pauseAnimations();
await rotateToCentroid(centroid, countryIso);
pauseAnimations(animations);
await rotateToCentroid(chart, centroid, countryIso, rotatedToCountry);
}
};
const zoomToCountry = (countryIso: string) => {
const centroid = countryCentroid(countryIso);
const geometry = findFeatureForCountry(countryIso)?.geometry as MultiPolygon;
const bounds = am5map.getGeoBounds(geometry);
// Don't zoom in very tightly on the country's bounds.
// Also avoid exceeding the globe's bounds.
bounds.left = Math.max(-180, bounds.left -= 10);
bounds.right = Math.min(180, bounds.right += 10);
bounds.top = Math.min(90, bounds.top += 10);
bounds.bottom = Math.max(-90, bounds.bottom -= 10);
if (chart && centroid) {
pauseAnimations();
chart.zoomToGeoBounds(bounds, geoPointZoomDuration);
}
};
// Reset the globe to slowly spinning.
const createRotateAnimation = () => {
const currentRotationX = chart.get("rotationX") || southEastAsiaXCoordinate;
return chart.animate({
key: "rotationX",
from: currentRotationX,
to: 360 + currentRotationX,
duration: 180000,
loops: Infinity,
});
};
const initializeSeries = (settings: am5map.IMapPolygonSeriesSettings) => {
const series = am5map.MapPolygonSeries.new(root, settings);
chart.series.push(series);
return series;
};
// When a polygon is activated (clicked), make sure the previously activated polygon is deactivated.
const handlePolygonActive = (target: am5map.MapPolygon, prevPolygonRef: Ref<am5map.MapPolygon | undefined>) => {
if (prevPolygonRef.value && prevPolygonRef.value !== target) {
prevPolygonRef.value.set("active", false);
pauseAnimations(animations);
chart.zoomToGeoBounds(getWideGeoBounds(geometry), geoPointZoomDuration);
}
prevPolygonRef.value = target;
};
const setUpBackgroundSeries = () => {
backgroundSeries = initializeSeries({ ...backgroundSeriesSettings, reverseGeodata: true });
backgroundSeries = initializeSeries(root, chart, { ...backgroundSeriesSettings, reverseGeodata: true });
backgroundSeries.mapPolygons.template.setAll({ tooltipText: "{name} is not currently available", toggleKey: "active", interactive: true });
backgroundSeries.mapPolygons.template.on("active", (_active, target) => handlePolygonActive(target, prevBackgroundPolygon));
};
const setUpSelectableCountriesSeries = () => {
selectableCountriesSeries = initializeSeries({ ...selectableCountriesSeriesSettings, reverseGeodata: false });
selectableCountriesSeries = initializeSeries(root, chart, { ...selectableCountriesSeriesSettings, reverseGeodata: false });
selectableCountriesSeries.mapPolygons.template.setAll({
tooltipText: "{name}",
toggleKey: "active",
Expand Down Expand Up @@ -335,14 +233,14 @@ const setUpSelectableCountriesSeries = () => {
const setUpDisputedAreasSeries = () => {
Object.keys(disputedLands).forEach((disputedArea) => {
disputedLands[disputedArea].mapSeries = initializeSeries({
disputedLands[disputedArea].mapSeries = initializeSeries(root, chart, {
...disputedLandSeriesSettings,
reverseGeodata: true,
include: [disputedArea],
});
});
initializeSeries({ ...disputedBodiesOfWaterSeriesSettings, reverseGeodata: true });
initializeSeries(root, chart, { ...disputedBodiesOfWaterSeriesSettings, reverseGeodata: true });
};
const setUpChart = () => {
Expand All @@ -352,7 +250,7 @@ const setUpChart = () => {
setUpBackgroundSeries();
setUpSelectableCountriesSeries();
setUpDisputedAreasSeries();
gentleRotateAnimation = createRotateAnimation();
gentleRotateAnimation = createRotateAnimation(chart);
applyGlobeSettings();
};
Expand Down Expand Up @@ -380,22 +278,23 @@ const disputedAreas = (countryId: string) => {
const resetGlobeZoomAndAnimation = () => {
if (chart) {
pauseAnimations();
pauseAnimations(animations);
// Probably the user has navigated back to the home page.
chart.goHome(goHomeDuration);
// Reset the globe to zoomed out and slowly spinning.
rotatedToCountry.value = "";
// TODO: Make more memory efficient by not re-creating the animations every time
gentleRotateAnimation = createRotateAnimation();
graduallyResetYAxis = chart.animate({ key: "rotationY", to: 0, duration: 20000, easing });
gentleRotateAnimation = createRotateAnimation(chart);
graduallyResetYAxis = chart.animate({ key: "rotationY", to: 0, duration: 20000, easing: am5.ease.inOut(am5.ease.cubic) });
animations = [gentleRotateAnimation, graduallyResetYAxis];
}
};
// When a country is selected (by form or globe), re-colour the country area, and, if we are
// on the new scenario page, rotate the globe to focus on that country.
const highlightCountry = async () => {
if (appStore.globe.highlightedCountry && highlightedCountrySeries.value) {
pauseAnimations();
pauseAnimations(animations);
chart.series.push(highlightedCountrySeries.value);
disputedAreas(appStore.globe.highlightedCountry!).forEach((disputedArea) => {
Expand All @@ -421,7 +320,7 @@ watch(() => highlightedCountrySeries.value, async (newSeries, oldSeries) => {
stopDisplayingAllDisputedAreas();
animateSeriesColourChange(oldSeries, defaultLandColour);
setTimeout(() => {
removeSeries(oldSeries);
removeSeries(chart, oldSeries);
}, geoPointZoomDuration);
}
if (newSeries) {
Expand Down
115 changes: 115 additions & 0 deletions components/utils/globe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import * as am5 from "@amcharts/amcharts5/index";
import * as am5map from "@amcharts/amcharts5/map";

// To place any country of interest on the 'upper-facing' part of the globe. Just looks better.
const amountToTiltTheEarthUpwardsBy = 25;
const rotateDuration = 2000;
export const geoPointZoomDuration = 2000;
export const southEastAsiaXCoordinate = -100;

export interface Animation { pause: () => void, stopped: boolean, play: () => void }

const pauseAnimation = (animation: Animation) => {
if (animation && !animation.stopped) {
animation.pause();
}
};

export const pauseAnimations = (animations: Animation[]) => {
animations.forEach((animation: Animation) => pauseAnimation(animation));
};

const rotateChart = (chart: am5map.MapChart, direction: "x" | "y", to: number) => {
if (direction === "x") {
const currentXRotation = chart.get("rotationX")!;
// calculates the smallest rotation between 0 amd 360 to get to the country
let diffRotation = (to - currentXRotation) % 360;
// translates rotation to between -180 and 180 because rotating 270 east
// is the same as 90 west
if (diffRotation > 180) {
diffRotation = diffRotation - 360;
}
// gets actual rotation destination by adding the difference
const toShortest = currentXRotation + diffRotation;
chart.animate({
key: "rotationX",
to: toShortest,
duration: rotateDuration,
easing: am5.ease.inOut(am5.ease.cubic),
});
} else {
chart.animate({
key: "rotationY",
to,
duration: rotateDuration,
easing: am5.ease.inOut(am5.ease.cubic),
});
}
};

export const rotateToCentroid = (chart: am5map.MapChart, centroid: am5.IGeoPoint, countryIso: string, rotatedToCountryRef: Ref<string>) => {
return new Promise<void>((resolve) => {
rotateChart(chart, "x", -centroid.longitude);
rotateChart(chart, "y", (amountToTiltTheEarthUpwardsBy - centroid.latitude));
setTimeout(() => {
rotatedToCountryRef.value = countryIso;
resolve();
}, rotateDuration);
});
};

export const initializeSeries = (root: am5.Root, chart: am5map.MapChart, settings: am5map.IMapPolygonSeriesSettings) => {
const series = am5map.MapPolygonSeries.new(root, settings);
chart.series.push(series);
return series;
};

// https://www.amcharts.com/docs/v4/tutorials/dynamically-adding-and-removing-series/
export const removeSeries = (chart: am5map.MapChart, seriesToRemove: am5map.MapPolygonSeries) => {
chart.series.removeIndex(
chart.series.indexOf(seriesToRemove),
).dispose();
};

export const animateSeriesColourChange = (
series: am5map.MapPolygonSeries,
colour: am5.Color,
) => series.animate({ key: "fill", to: colour, duration: geoPointZoomDuration, easing: am5.ease.inOut(am5.ease.cubic) });

// Reset the globe to slowly spinning.
export const createRotateAnimation = (chart: am5map.MapChart) => {
let currentRotationX: number;
if (chart.get("rotationX") === 0) {
currentRotationX = 0;
} else {
currentRotationX = chart.get("rotationX") || southEastAsiaXCoordinate;
}
return chart.animate({
key: "rotationX",
from: currentRotationX,
to: 360 + currentRotationX,
duration: 180000,
loops: Infinity,
});
};

// When a polygon is activated (clicked), make sure the previously activated polygon is deactivated.
export const handlePolygonActive = (target: am5map.MapPolygon, prevPolygonRef: Ref<am5map.MapPolygon | undefined>) => {
if (prevPolygonRef.value && prevPolygonRef.value !== target) {
prevPolygonRef.value.set("active", false);
}
prevPolygonRef.value = target;
};

// Given a country's geometry, return bounds for the country, for zooming in to.
export const getWideGeoBounds = (geometry: GeoJSON.GeometryObject) => {
// Add padding to bounds to avoid zooming in very tightly on the country's bounds.
const padding = 10;
const bounds = am5map.getGeoBounds(geometry);
// Avoid exceeding the globe's bounds (-180/180/90/-90).
bounds.left = Math.max(-180, bounds.left -= padding);
bounds.right = Math.min(180, bounds.right += padding);
bounds.top = Math.min(90, bounds.top += padding);
bounds.bottom = Math.max(-90, bounds.bottom -= padding);
return bounds;
};
Loading

0 comments on commit dafd493

Please sign in to comment.