From a014706657ff5a7d2829cbc9bfb0cb8125e16cd2 Mon Sep 17 00:00:00 2001 From: David Mears Date: Mon, 11 Nov 2024 16:11:41 +0000 Subject: [PATCH 1/3] Extract utility-like functions to separate file from Globe component --- components/Globe.vue | 135 +++++--------------------------------- components/utils/globe.ts | 110 +++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 118 deletions(-) create mode 100644 components/utils/globe.ts diff --git a/components/Globe.vue b/components/Globe.vue index f4c00b7..06d259e 100644 --- a/components/Globe.vue +++ b/components/Globe.vue @@ -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"; @@ -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; @@ -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((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) { @@ -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) => { - 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", @@ -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 = () => { @@ -352,7 +250,7 @@ const setUpChart = () => { setUpBackgroundSeries(); setUpSelectableCountriesSeries(); setUpDisputedAreasSeries(); - gentleRotateAnimation = createRotateAnimation(); + gentleRotateAnimation = createRotateAnimation(chart); applyGlobeSettings(); }; @@ -380,14 +278,15 @@ 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]; } }; @@ -395,7 +294,7 @@ const resetGlobeZoomAndAnimation = () => { // 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) => { @@ -421,7 +320,7 @@ watch(() => highlightedCountrySeries.value, async (newSeries, oldSeries) => { stopDisplayingAllDisputedAreas(); animateSeriesColourChange(oldSeries, defaultLandColour); setTimeout(() => { - removeSeries(oldSeries); + removeSeries(chart, oldSeries); }, geoPointZoomDuration); } if (newSeries) { diff --git a/components/utils/globe.ts b/components/utils/globe.ts new file mode 100644 index 0000000..4369a4e --- /dev/null +++ b/components/utils/globe.ts @@ -0,0 +1,110 @@ +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) => { + return new Promise((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) => { + const 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) => { + 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; +}; From 3d3704b596eb28a8f735757a707b01dd8ad3c824 Mon Sep 17 00:00:00 2001 From: David Mears Date: Mon, 11 Nov 2024 17:08:04 +0000 Subject: [PATCH 2/3] Add unit tests for Globe component *utils* --- tests/unit/components/utils/globe.spec.ts | 120 ++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 tests/unit/components/utils/globe.spec.ts diff --git a/tests/unit/components/utils/globe.spec.ts b/tests/unit/components/utils/globe.spec.ts new file mode 100644 index 0000000..8d2e6fb --- /dev/null +++ b/tests/unit/components/utils/globe.spec.ts @@ -0,0 +1,120 @@ +import { + createRotateAnimation, + getWideGeoBounds, + handlePolygonActive, + pauseAnimations, + removeSeries, + rotateToCentroid, +} from "@/components/utils/globe"; +import * as am5map from "@amcharts/amcharts5/map"; + +describe("pauseAnimations", () => { + it("should pause animations passed to it, unless they have been stopped", () => { + const stoppedAnimation = { pause: vi.fn(), stopped: true, play: vi.fn() }; + const unstoppedAnimation = { pause: vi.fn(), stopped: false, play: vi.fn() }; + pauseAnimations([stoppedAnimation, unstoppedAnimation]); + expect(stoppedAnimation.pause).not.toHaveBeenCalled(); + expect(unstoppedAnimation.pause).toHaveBeenCalled(); + }); +}); + +describe("rotateToCentroid", () => { + it("should rotate the chart to the centroid", async () => { + const chart = { animate: vi.fn(), get: vi.fn().mockReturnValue(0) } as unknown as am5map.MapChart; + const centroid = { longitude: 100, latitude: 50 }; + const rotatedToCountryRef = ref(""); + await rotateToCentroid(chart, centroid, "XYZ", rotatedToCountryRef); + expect(chart.animate).toHaveBeenCalledTimes(2); + expect(chart.animate).toHaveBeenNthCalledWith(1, expect.objectContaining({ + key: "rotationX", + to: -100, + })); + expect(chart.animate).toHaveBeenNthCalledWith(2, expect.objectContaining({ + key: "rotationY", + to: -25, // amountToTiltTheEarthUpwardsBy - centroid.latitude + })); + + expect(rotatedToCountryRef.value).toBe("XYZ"); + }); +}); + +describe("removeSeries", () => { + it("should remove and dispose the series", () => { + const seriesToRemove = {} as am5map.MapPolygonSeries; + const disposeFn = vi.fn(); + const chart = { + series: { + indexOf: vi.fn().mockReturnValue(0), + removeIndex: vi.fn().mockReturnValue({ dispose: disposeFn }), + }, + } as unknown as am5map.MapChart; + removeSeries(chart, seriesToRemove); + expect(disposeFn).toHaveBeenCalled(); + }); +}); + +describe("createRotateAnimation", () => { + it("should create a rotate animation when the current rotationX is 0", () => { + const chart = { animate: vi.fn(), get: vi.fn().mockReturnValue(0) } as unknown as am5map.MapChart; + createRotateAnimation(chart); + expect(chart.animate).toHaveBeenCalledWith(expect.objectContaining({ + key: "rotationX", + from: 0, + to: 360, + loops: Infinity, + })); + }); + + it("should create a rotate animation when the current rotationX is not 0", () => { + const chart = { animate: vi.fn(), get: vi.fn().mockReturnValue(30) } as unknown as am5map.MapChart; + createRotateAnimation(chart); + expect(chart.animate).toHaveBeenCalledWith(expect.objectContaining({ + key: "rotationX", + from: 30, + to: 390, + loops: Infinity, + })); + }); +}); + +describe("handlePolygonActive", () => { + it("when there is a previous polygon which differs from the current one (the 'target'), it should deactivate the previous polygon and assign the current one to the previous polygon ref", () => { + const target = { set: vi.fn(), uid: 1 } as unknown as am5map.MapPolygon; + const setFn = vi.fn(); + const prevPolygonRef = ref({ set: setFn, uid: 2 } as unknown as am5map.MapPolygon) as Ref; + handlePolygonActive(target, prevPolygonRef); + expect(setFn).toHaveBeenCalledWith("active", false); + expect(prevPolygonRef.value.uid).toBe(1); + }); + + it("when there is no previous polygon, it should assign the current one to the previous polygon ref", () => { + const target = { set: vi.fn() } as unknown as am5map.MapPolygon; + const prevPolygonRef = ref(undefined) as Ref; + handlePolygonActive(target, prevPolygonRef); + expect(prevPolygonRef.value).toStrictEqual(target); + }); +}); + +describe("getWideGeoBounds", () => { + it("should return the bounds with padding", () => { + const geometry = {} as GeoJSON.GeometryObject; + const bounds = { left: -170, right: 170, top: 80, bottom: -80 }; + vi.spyOn(am5map, "getGeoBounds").mockReturnValue(bounds); + const result = getWideGeoBounds(geometry); + expect(result.left).toBe(-180); + expect(result.right).toBe(180); + expect(result.top).toBe(90); + expect(result.bottom).toBe(-90); + }); + + it("should return the padded bounds without exceeding any of the global bounds", () => { + const geometry = {} as GeoJSON.GeometryObject; + const bounds = { left: -175, right: 170, top: 80, bottom: -85 }; + vi.spyOn(am5map, "getGeoBounds").mockReturnValue(bounds); + const result = getWideGeoBounds(geometry); + expect(result.left).toBe(-180); + expect(result.right).toBe(180); + expect(result.top).toBe(90); + expect(result.bottom).toBe(-90); + }); +}); From ab0b852b156fe9836ccf6cf8c6e57d2e7df4fdb1 Mon Sep 17 00:00:00 2001 From: David Mears Date: Mon, 11 Nov 2024 17:17:55 +0000 Subject: [PATCH 3/3] Fix createRotateAnimation in case where rotationX is 0 --- components/utils/globe.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/components/utils/globe.ts b/components/utils/globe.ts index 4369a4e..92ac5ff 100644 --- a/components/utils/globe.ts +++ b/components/utils/globe.ts @@ -78,7 +78,12 @@ export const animateSeriesColourChange = ( // Reset the globe to slowly spinning. export const createRotateAnimation = (chart: am5map.MapChart) => { - const currentRotationX = chart.get("rotationX") || southEastAsiaXCoordinate; + let currentRotationX: number; + if (chart.get("rotationX") === 0) { + currentRotationX = 0; + } else { + currentRotationX = chart.get("rotationX") || southEastAsiaXCoordinate; + } return chart.animate({ key: "rotationX", from: currentRotationX,