Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Homepage on phones and tablets: the "Explore our Monitors & Geostories" link and the Cesium credits sit under the globe, centered and right above the footer, instead of overlapping each other over the globe
- Homepage on phones and tablets: the globe sits 10px lower so it clears the search and filter bar
- Homepage geostories and live feed drawers stop under the header instead of sliding over it
- Timeline playback: only one of the two mounted Timelines (desktop and mobile legend) drives playback, so dates are no longer skipped in pairs, and each date stays on screen for the full interval after its tiles load instead of switching the moment they land [OEMC-443](https://vizzuality.atlassian.net/browse/OEMC-443)
- Map tooltip treats a value of 0 as data: layers that are 0 over most of the map, such as Bare soil fraction (BSF) dynamics, no longer report "No data" and hide the point histogram there

### Removed
Expand Down
10 changes: 10 additions & 0 deletions src/app/store.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ export const timeSeriesPlaybackAtom = atom<boolean>(true);
/** Tiles in flight, keyed per WMS layer instance. Written by the map layers only. */
export const mapTilesLoadingAtom = atom<Record<string, boolean>>({});

/**
* Id of the Timeline instance that drives playback. The legend is rendered once per
* breakpoint, so two Timelines are mounted at any time with one hidden by CSS; only the
* owner steps, otherwise both advance the date and dates get skipped.
*
* No initial value on purpose: with `strict` off, `atom<string | null>(null)` resolves to
* the read-only overload and the setter types as `never`.
*/
export const timelinePlaybackOwnerAtom = atom<string>();

/** True while any WMS layer still has tiles loading. Paces timeline playback. */
export const areMapTilesLoadingAtom = atom<boolean>((get) =>
Object.values(get(mapTilesLoadingAtom)).some(Boolean)
Expand Down
62 changes: 46 additions & 16 deletions src/hooks/timeline.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,75 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useId, useRef, useState } from 'react';

import { useAtomValue } from 'jotai';
import { useAtom, useAtomValue } from 'jotai';

import { areMapTilesLoadingAtom } from '@/app/store';
import { areMapTilesLoadingAtom, timelinePlaybackOwnerAtom } from '@/app/store';

export const TIMELINE_STEP_DURATION = 2500;

/**
* Drives timeline playback on a fixed cadence that never runs ahead of the map: a step is
* held back while the WMS source still has tiles in flight, and fires as soon as it goes
* idle. The slider, the date label and the rendered tiles therefore stay on the same date
* instead of the map lagging behind the clock, and no date is skipped.
* Drives timeline playback in step with the map. A step is held back while the WMS source
* still has tiles in flight, and the interval only starts counting once the new date's tiles
* are on screen, so every date is visible for the full `TIMELINE_STEP_DURATION` with the
* slider, the date label and the rendered tiles on the same date. No date is skipped.
*
* Effective cadence is `max(TIMELINE_STEP_DURATION, tile load time)`.
* Effective cadence is `tile load time + TIMELINE_STEP_DURATION`; with cached tiles that is
* a few hundred milliseconds over the interval.
*
* Only one mounted Timeline drives playback. The legend is rendered once per breakpoint
* with one copy hidden by CSS, so two Timelines share the playback state; the first to
* claim `timelinePlaybackOwnerAtom` steps, the other stays passive and takes over if the
* owner unmounts.
*/
export function usePacedTimelineStep(isPlaying: boolean, step: () => void) {
const areMapTilesLoading = useAtomValue(areMapTilesLoadingAtom);

const instanceId = useId();
const [owner, setOwner] = useAtom(timelinePlaybackOwnerAtom);
const isOwner = owner === instanceId;

useEffect(() => {
if (owner === undefined) setOwner(instanceId);
}, [owner, instanceId, setOwner]);

useEffect(() => {
return () => setOwner((current) => (current === instanceId ? undefined : current));
}, [instanceId, setOwner]);

const stepRef = useRef(step);
stepRef.current = step;

const lastStepAtRef = useRef(0);
// When the current dwell started: the moment the map went idle after the last step, or
// the step itself if no tile load followed it.
const dwellStartRef = useRef(0);
const wasLoadingRef = useRef(false);
// Re-runs the scheduling effect after each step so the next one gets queued
const [stepCount, setStepCount] = useState(0);

useEffect(() => {
if (isPlaying) lastStepAtRef.current = Date.now();
if (isPlaying) dwellStartRef.current = Date.now();
}, [isPlaying]);

useEffect(() => {
if (!isPlaying || areMapTilesLoading) return;
if (areMapTilesLoading) {
wasLoadingRef.current = true;
return;
}
if (wasLoadingRef.current) {
// The new date's tiles have just landed: this is when the user starts seeing it
wasLoadingRef.current = false;
dwellStartRef.current = Date.now();
}

if (!isPlaying || !isOwner) return;

// Time already spent loading counts towards the step, so a slow date does not also
// pay the full interval on top of its load time
const elapsed = Date.now() - lastStepAtRef.current;
const elapsed = Date.now() - dwellStartRef.current;
const timeout = setTimeout(() => {
lastStepAtRef.current = Date.now();
// Fallback dwell start, for a step that no tile load follows
dwellStartRef.current = Date.now();
stepRef.current();
setStepCount((count) => count + 1);
}, Math.max(0, TIMELINE_STEP_DURATION - elapsed));

return () => clearTimeout(timeout);
}, [isPlaying, areMapTilesLoading, stepCount]);
}, [isPlaying, isOwner, areMapTilesLoading, stepCount]);
}
Loading