Skip to content

Commit 4a691d9

Browse files
author
Oussama
committed
feat: animate globe idle and story focus
1 parent f430821 commit 4a691d9

4 files changed

Lines changed: 218 additions & 102 deletions

File tree

website/src/lib/charts/Globe.svelte

Lines changed: 99 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,37 @@
55
import { feature } from 'topojson-client';
66
import { worldAtlasIdToIso3 } from '../data/worldIso3.js';
77
import { filters, setSelectedCountry } from '../stores/filters.js';
8-
9-
let { countryByDecade = [], step = 0 } = $props();
8+
import {
9+
closestRotationStart,
10+
easeInOutCubic,
11+
buildIsoCounts,
12+
globeFillFor,
13+
IDLE_DEGREES_PER_MS,
14+
IDLE_RESUME_DELAY,
15+
TOP_THREE_HOLD_DELAY,
16+
TOP_THREE_ROTATION,
17+
} from '../utils/globeMotion.js';
18+
import './globe.css';
19+
20+
let { countryByDecade = [], step = 0, active = false } = $props();
1021
1122
const width = 760;
1223
const height = 760;
13-
const topThree = new Set(['USA', 'FRA', 'DEU']);
1424
1525
let countries = $state([]);
1626
let rotation = $state([-35, -20]);
1727
let dragging = $state(false);
28+
let scripted = $state(false);
1829
let dragStart = null;
1930
let rotateStart = null;
2031
let pointerDownCountryIndex = null;
2132
let pointerMoved = false;
22-
let frame = 0;
23-
let reduceMotion = false;
33+
let motionFrame = 0;
34+
let idleFrame = 0;
35+
let idleLast = 0;
36+
let idlePausedUntil = 0;
37+
let lastFocusedStep = -1;
38+
let reduceMotion = $state(false);
2439
2540
let projection = $derived(
2641
geoOrthographic()
@@ -33,27 +48,9 @@
3348
let graticulePath = $derived(path(geoGraticule10()));
3449
let range = $derived($filters.decadeRange);
3550
let selectedCountry = $derived($filters.selectedCountry);
36-
let countByIso = $derived(buildCounts(countryByDecade, range));
51+
let countByIso = $derived(buildIsoCounts(countryByDecade, range));
3752
let maxCount = $derived(Math.max(1, ...Object.values(countByIso)));
3853
39-
function buildCounts(rows, decadeRange) {
40-
const counts = {};
41-
for (const row of rows) {
42-
if (row.decade === null || row.decade < decadeRange[0] || row.decade > decadeRange[1]) continue;
43-
counts[row.iso3] = (counts[row.iso3] || 0) + row.n;
44-
}
45-
return counts;
46-
}
47-
48-
function fillFor(iso3) {
49-
const n = countByIso[iso3] || 0;
50-
if (n === 0) return 'transparent';
51-
if (step >= 2 && topThree.has(iso3)) return 'var(--accent-primary)';
52-
if (step === 0) return 'var(--seq-0)';
53-
const bucket = Math.min(4, Math.max(0, Math.floor((Math.log10(n) / Math.log10(maxCount)) * 4)));
54-
return `var(--seq-${bucket})`;
55-
}
56-
5754
function showTooltip(event, country) {
5855
const iso3 = worldAtlasIdToIso3[String(country.id).padStart(3, '0')];
5956
const count = countByIso[iso3] || 0;
@@ -73,33 +70,78 @@
7370
window.dispatchEvent(new CustomEvent('darts:tooltip-hide'));
7471
}
7572
76-
function ease(t) {
77-
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
73+
function pauseIdle(duration = IDLE_RESUME_DELAY) {
74+
idlePausedUntil = performance.now() + duration;
75+
idleLast = 0;
7876
}
7977
80-
function centerCountry(country) {
81-
const iso3 = worldAtlasIdToIso3[String(country.id).padStart(3, '0')];
82-
if (!iso3 || !countByIso[iso3]) return;
83-
setSelectedCountry(iso3);
84-
const [lon, lat] = geoCentroid(country);
85-
const target = [-lon, -lat];
78+
function cancelMotion() {
79+
cancelAnimationFrame(motionFrame);
80+
motionFrame = 0;
81+
scripted = false;
82+
}
83+
84+
function animateRotationTo(target, duration = 900, holdDelay = 0) {
85+
pauseIdle(duration + holdDelay);
86+
cancelMotion();
8687
if (reduceMotion) {
8788
rotation = target;
8889
return;
8990
}
90-
const start = rotation;
91+
scripted = true;
92+
const start = closestRotationStart(rotation, target);
9193
const interp = interpolate(start, target);
9294
const started = performance.now();
93-
cancelAnimationFrame(frame);
9495
const tick = (now) => {
95-
const t = Math.min(1, (now - started) / 900);
96-
rotation = interp(ease(t));
97-
if (t < 1) frame = requestAnimationFrame(tick);
96+
const t = Math.min(1, (now - started) / duration);
97+
rotation = interp(easeInOutCubic(t));
98+
if (t < 1) {
99+
motionFrame = requestAnimationFrame(tick);
100+
} else {
101+
rotation = target;
102+
scripted = false;
103+
motionFrame = 0;
104+
}
98105
};
99-
frame = requestAnimationFrame(tick);
106+
motionFrame = requestAnimationFrame(tick);
107+
}
108+
109+
function startIdleLoop() {
110+
if (idleFrame || reduceMotion) return;
111+
idleFrame = requestAnimationFrame(idleTick);
112+
}
113+
114+
function stopIdleLoop() {
115+
cancelAnimationFrame(idleFrame);
116+
idleFrame = 0;
117+
idleLast = 0;
118+
}
119+
120+
function idleTick(now) {
121+
idleFrame = requestAnimationFrame(idleTick);
122+
if (!active || reduceMotion) return;
123+
const elapsed = idleLast ? Math.min(40, now - idleLast) : 0;
124+
idleLast = now;
125+
if (!elapsed || dragging || scripted || selectedCountry || now < idlePausedUntil) return;
126+
rotation = [rotation[0] + elapsed * IDLE_DEGREES_PER_MS, rotation[1]];
127+
}
128+
129+
function focusTopThreeCountries() {
130+
animateRotationTo(TOP_THREE_ROTATION, 900, TOP_THREE_HOLD_DELAY);
131+
}
132+
133+
function centerCountry(country) {
134+
const iso3 = worldAtlasIdToIso3[String(country.id).padStart(3, '0')];
135+
if (!iso3 || !countByIso[iso3]) return;
136+
setSelectedCountry(iso3);
137+
const [lon, lat] = geoCentroid(country);
138+
const target = [-lon, -lat];
139+
animateRotationTo(target, 900, IDLE_RESUME_DELAY);
100140
}
101141
102142
function onPointerDown(event) {
143+
cancelMotion();
144+
pauseIdle();
103145
dragging = true;
104146
dragStart = [event.clientX, event.clientY];
105147
rotateStart = rotation;
@@ -123,17 +165,34 @@
123165
pointerDownCountryIndex = null;
124166
if (event.currentTarget.hasPointerCapture?.(event.pointerId))
125167
event.currentTarget.releasePointerCapture(event.pointerId);
168+
pauseIdle();
126169
if (shouldSelect) centerCountry(countries[Number(countryIndex)]);
127170
}
128171
172+
$effect(() => {
173+
if (active && !reduceMotion) {
174+
startIdleLoop();
175+
} else {
176+
stopIdleLoop();
177+
}
178+
});
179+
180+
$effect(() => {
181+
if (active && step === 2 && lastFocusedStep !== 2 && !selectedCountry) focusTopThreeCountries();
182+
lastFocusedStep = step;
183+
});
184+
129185
onMount(async () => {
130186
reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
131187
const response = await fetch('./topology/world-110m.json');
132188
const topology = await response.json();
133189
countries = feature(topology, topology.objects.countries).features;
134190
});
135191
136-
onDestroy(() => cancelAnimationFrame(frame));
192+
onDestroy(() => {
193+
cancelMotion();
194+
stopIdleLoop();
195+
});
137196
</script>
138197
139198
<figure class="globe-figure" aria-label="Orthographic globe showing credited works by artist country">
@@ -167,7 +226,7 @@
167226
d={path(country)}
168227
data-country-index={index}
169228
data-iso3={iso3}
170-
fill={fillFor(iso3)}
229+
fill={globeFillFor(iso3, countByIso[iso3] || 0, maxCount, step)}
171230
class:selected={selectedCountry === iso3}
172231
class:clickable={Boolean(iso3 && countByIso[iso3])}
173232
onpointermove={(event) => showTooltip(event, country)}
@@ -187,63 +246,3 @@
187246
</g>
188247
</svg>
189248
</figure>
190-
191-
<style>
192-
.globe-figure {
193-
margin: 0;
194-
width: min(90vh, 100%);
195-
}
196-
197-
svg {
198-
display: block;
199-
width: 100%;
200-
height: auto;
201-
}
202-
203-
.sphere {
204-
fill: var(--bg-dark);
205-
stroke: var(--fg-on-dark-mute);
206-
stroke-width: 0.5;
207-
opacity: 0.9;
208-
}
209-
210-
.graticule {
211-
fill: none;
212-
stroke: var(--fg-on-dark-mute);
213-
stroke-width: 0.35;
214-
opacity: 0.35;
215-
pointer-events: none;
216-
}
217-
218-
.countries {
219-
cursor: grab;
220-
}
221-
222-
.countries.dragging {
223-
cursor: grabbing;
224-
}
225-
226-
path {
227-
stroke: var(--bg-dark);
228-
stroke-width: 0.35;
229-
transition:
230-
fill 450ms var(--ease-inout),
231-
stroke 180ms var(--ease-out);
232-
}
233-
234-
path.clickable {
235-
cursor: pointer;
236-
}
237-
238-
path.clickable:hover,
239-
path.selected {
240-
stroke: var(--fg-on-dark-strong);
241-
stroke-width: 1.2;
242-
}
243-
244-
@media (prefers-reduced-motion: reduce) {
245-
path {
246-
transition: none;
247-
}
248-
}
249-
</style>

website/src/lib/charts/globe.css

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
.globe-figure {
2+
margin: 0;
3+
width: min(90vh, 100%);
4+
}
5+
6+
.globe-figure svg {
7+
display: block;
8+
width: 100%;
9+
height: auto;
10+
}
11+
12+
.globe-figure .sphere {
13+
fill: var(--bg-dark);
14+
stroke: var(--fg-on-dark-mute);
15+
stroke-width: 0.5;
16+
opacity: 0.9;
17+
}
18+
19+
.globe-figure .graticule {
20+
fill: none;
21+
stroke: var(--fg-on-dark-mute);
22+
stroke-width: 0.35;
23+
opacity: 0.35;
24+
pointer-events: none;
25+
}
26+
27+
.globe-figure .countries {
28+
cursor: grab;
29+
}
30+
31+
.globe-figure .countries.dragging {
32+
cursor: grabbing;
33+
}
34+
35+
.globe-figure path {
36+
stroke: var(--bg-dark);
37+
stroke-width: 0.35;
38+
transition:
39+
fill 450ms var(--ease-inout),
40+
stroke 180ms var(--ease-out);
41+
}
42+
43+
.globe-figure path.clickable {
44+
cursor: pointer;
45+
}
46+
47+
.globe-figure path.clickable:hover,
48+
.globe-figure path.selected {
49+
stroke: var(--fg-on-dark-strong);
50+
stroke-width: 1.2;
51+
}
52+
53+
@media (prefers-reduced-motion: reduce) {
54+
.globe-figure path {
55+
transition: none;
56+
}
57+
}

website/src/lib/scenes/Scene2Globe.svelte

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
1313
const topThree = new Set(['USA', 'FRA', 'DEU']);
1414
15-
let step = $derived($filters.activeScene === 2 ? $filters.activeStep : 0);
15+
let active = $derived($filters.activeScene === 2);
16+
let step = $derived(active ? $filters.activeStep : 0);
1617
let range = $derived($filters.decadeRange);
1718
let selectedCountry = $derived($filters.selectedCountry);
1819
let summaryByIso = $derived(new Map(countrySummary.map((d) => [d.iso3, d])));
@@ -94,7 +95,7 @@
9495
{/if}
9596
</div>
9697
<div class="globe-wrap">
97-
<Globe {countryByDecade} {step} />
98+
<Globe {countryByDecade} {step} {active} />
9899
</div>
99100
{#if selectedSummary}
100101
<aside class="country-panel" aria-label={`Selected country ${selectedSummary.country_name}`}>

0 commit comments

Comments
 (0)