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 @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed

- Map controls style update to new designs [OEMC-355](https://vizzuality.atlassian.net/browse/OEMC-355)
- Timeline playback waits for the map tiles of the current date before advancing, keeping the slider and the map in sync and stopping GeoServer from rendering tiles the frontend discards [OEMC-443](https://vizzuality.atlassian.net/browse/OEMC-443)


## v1.0.0-alpha.6
Expand Down
8 changes: 8 additions & 0 deletions src/app/store.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ export const compareFunctionalityAtom = atom<boolean>(false);

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>>({});

/** True while any WMS layer still has tiles loading. Paces timeline playback. */
export const areMapTilesLoadingAtom = atom<boolean>((get) =>
Object.values(get(mapTilesLoadingAtom)).some(Boolean)
);

export const nutsDataParamsAtom = atom<{ NUTS_ID: string; LAYER_ID: string }>({
NUTS_ID: null,
LAYER_ID: null,
Expand Down
126 changes: 123 additions & 3 deletions src/components/map/layers/buffered-tile-wms.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
'use client';

import { FC, useEffect, useRef } from 'react';
import { FC, useCallback, useEffect, useId, useRef } from 'react';

import { useSetAtom } from 'jotai';
import TileLayer from 'ol/layer/Tile';
import { unByKey } from 'ol/Observable';
import TileWMS from 'ol/source/TileWMS';
import { RLayerTileWMSProps, useOL } from 'rlayers';

import { mapTilesLoadingAtom } from '@/app/store';

import { WMS_CRS } from '../constants';

/**
* Upper bound for how long a date change waits for in-flight tiles. Guards against a tile
* whose load event never arrives, which would otherwise stall playback for this layer.
*/
const PENDING_DATE_TIMEOUT = 10000;

interface BufferedTileWMSProps extends RLayerTileWMSProps {
layerName: string;
date: string | undefined;
Expand All @@ -17,6 +27,11 @@ interface BufferedTileWMSProps extends RLayerTileWMSProps {
/**
* WMS tile layer that uses OL's native `updateParams()` for date changes.
* Old tiles stay visible as interim tiles until new ones load — no blink.
*
* Date changes are throttled against the tiles still in flight: OL does not cancel pending
* tile requests on `updateParams()`, so firing one per playback tick leaves GeoServer
* rendering tiles nobody will use. Requests that arrive while tiles are loading are
* coalesced into the latest date, applied once the source goes idle.
*/
const BufferedTileWMS: FC<BufferedTileWMSProps> = ({
url,
Expand Down Expand Up @@ -44,6 +59,59 @@ const BufferedTileWMS: FC<BufferedTileWMSProps> = ({
const onLayerChangeRef = useRef(onLayerChange);
onLayerChangeRef.current = onLayerChange;

const loadingTilesRef = useRef(0);
const pendingDateRef = useRef<string | undefined>(undefined);
const hasPendingDateRef = useRef(false);
const appliedDateRef = useRef(date);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const instanceId = useId();
const setTilesLoading = useSetAtom(mapTilesLoadingAtom);

/** Publish this layer's load state so timeline playback can wait for it. */
const publishLoading = useCallback(
(isLoading: boolean) => {
setTilesLoading((prev) =>
prev[instanceId] === isLoading ? prev : { ...prev, [instanceId]: isLoading }
);
},
[instanceId, setTilesLoading]
);

useEffect(() => {
return () =>
setTilesLoading((prev) => {
if (!(instanceId in prev)) return prev;
const next = { ...prev };
delete next[instanceId];
return next;
});
}, [instanceId, setTilesLoading]);

const clearPendingTimeout = useCallback(() => {
if (timeoutRef.current === null) return;
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}, []);

const applyDate = useCallback((nextDate: string | undefined) => {
appliedDateRef.current = nextDate;
layerRef.current?.getSource()?.updateParams({ DIM_DATE: nextDate });
}, []);

/** Apply the coalesced date, if any. Called when the source goes idle or the guard fires. */
const flushPendingDate = useCallback(() => {
clearPendingTimeout();
if (!hasPendingDateRef.current) return;

const nextDate = pendingDateRef.current;
hasPendingDateRef.current = false;
pendingDateRef.current = undefined;

if (nextDate === appliedDateRef.current) return;
applyDate(nextDate);
}, [applyDate, clearPendingTimeout]);

// Create layer + source once; recreate only on url/layerName change
useEffect(() => {
if (!map) return;
Expand Down Expand Up @@ -78,11 +146,39 @@ const BufferedTileWMS: FC<BufferedTileWMSProps> = ({
properties,
});

// The source starts fresh: no tiles in flight, nothing coalesced from the previous one
clearPendingTimeout();
loadingTilesRef.current = 0;
hasPendingDateRef.current = false;
pendingDateRef.current = undefined;
appliedDateRef.current = dateRef.current;
publishLoading(false);

const onTileLoadStart = () => {
loadingTilesRef.current += 1;
publishLoading(true);
};

const onTileLoadSettled = () => {
loadingTilesRef.current = Math.max(0, loadingTilesRef.current - 1);
if (loadingTilesRef.current > 0) return;
publishLoading(false);
flushPendingDate();
};

const listenerKeys = [
source.on('tileloadstart', onTileLoadStart),
source.on('tileloadend', onTileLoadSettled),
source.on('tileloaderror', onTileLoadSettled),
];

layerRef.current = layer;
map.addLayer(layer);
onLayerChangeRef.current?.(layer);

return () => {
unByKey(listenerKeys);
clearPendingTimeout();
map.removeLayer(layer);
layerRef.current = null;
onLayerChangeRef.current?.(null);
Expand All @@ -92,8 +188,32 @@ const BufferedTileWMS: FC<BufferedTileWMSProps> = ({

// Date change → updateParams keeps old tiles visible until new ones load
useEffect(() => {
layerRef.current?.getSource()?.updateParams({ DIM_DATE: date });
}, [date]);
if (date === appliedDateRef.current) return;

// Source idle: request straight away
if (loadingTilesRef.current === 0) {
clearPendingTimeout();
hasPendingDateRef.current = false;
pendingDateRef.current = undefined;
applyDate(date);
return;
}

// Tiles still loading: keep only the latest date and wait for the source to go idle
pendingDateRef.current = date;
hasPendingDateRef.current = true;

clearPendingTimeout();
timeoutRef.current = setTimeout(() => {
// A tile load event never arrived: treat the source as idle so neither the pending
// date nor timeline playback stays blocked on it.
loadingTilesRef.current = 0;
publishLoading(false);
flushPendingDate();
}, PENDING_DATE_TIMEOUT);
}, [date, applyDate, clearPendingTimeout, flushPendingDate, publishLoading]);

useEffect(() => clearPendingTimeout, [clearPendingTimeout]);

useEffect(() => {
layerRef.current?.setOpacity(opacity);
Expand Down
18 changes: 8 additions & 10 deletions src/components/timeseries-comparative-layers/timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import type { FC, MouseEvent } from 'react';

import { TooltipPortal } from '@radix-ui/react-tooltip';
import { LuCirclePlay, LuCirclePause } from 'react-icons/lu';
import { useInterval } from 'usehooks-ts';

import cn from '@/lib/classnames';

import type { LayerParsed } from '@/types/layers';

import { useSyncCompareLayersSettings, useSyncLayersSettings } from '@/hooks/sync-query';
import { usePacedTimelineStep } from '@/hooks/timeline';

import {
IconTooltip,
Expand All @@ -19,7 +19,6 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip';

const TIMEOUT_STEP_DURATION = 2500;
const TICK_THRESHOLD = 20;

const Timeline: FC<{
Expand Down Expand Up @@ -59,14 +58,13 @@ const Timeline: FC<{
[layerId, compareLayers]
);

useInterval(
() => {
if (!range?.length) return;
const nextRange = range[(range.indexOf(currentRange) + 1) % range.length];
void setLayers([{ ...layers?.[0], date: nextRange.value }]);
},
isPlaying ? TIMEOUT_STEP_DURATION : null
);
const goToNextDate = useCallback(() => {
if (!range?.length) return;
const nextRange = range[(range.indexOf(currentRange) + 1) % range.length];
void setLayers([{ ...layers?.[0], date: nextRange.value }]);
}, [range, currentRange, layers, setLayers]);

usePacedTimelineStep(isPlaying, goToNextDate);

const handleTickClick = useCallback(
(e: MouseEvent, value: string) => {
Expand Down
18 changes: 8 additions & 10 deletions src/components/timeseries-layer/timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import type { FC, MouseEvent } from 'react';

import { TooltipPortal } from '@radix-ui/react-tooltip';
import { LuCirclePlay, LuCirclePause } from 'react-icons/lu';
import { useInterval } from 'usehooks-ts';

import cn from '@/lib/classnames';

import type { LayerParsed } from '@/types/layers';

import { useSyncCompareLayersSettings, useSyncLayersSettings } from '@/hooks/sync-query';
import { usePacedTimelineStep } from '@/hooks/timeline';

import {
IconTooltip,
Expand All @@ -19,7 +19,6 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip';

const TIMEOUT_STEP_DURATION = 2500;
const TICK_THRESHOLD = 20;

const Timeline: FC<{
Expand Down Expand Up @@ -59,14 +58,13 @@ const Timeline: FC<{
[layerId, compareLayers]
);

useInterval(
() => {
if (!range?.length) return;
const nextRange = range[(range.indexOf(currentRange) + 1) % range.length];
void setLayers([{ ...layers?.[0], date: nextRange.value }]);
},
isPlaying ? TIMEOUT_STEP_DURATION : null
);
const goToNextDate = useCallback(() => {
if (!range?.length) return;
const nextRange = range[(range.indexOf(currentRange) + 1) % range.length];
void setLayers([{ ...layers?.[0], date: nextRange.value }]);
}, [range, currentRange, layers, setLayers]);

usePacedTimelineStep(isPlaying, goToNextDate);

const handleTickClick = useCallback(
(e: MouseEvent, value: string) => {
Expand Down
45 changes: 45 additions & 0 deletions src/hooks/timeline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { useEffect, useRef, useState } from 'react';

import { useAtomValue } from 'jotai';

import { areMapTilesLoadingAtom } 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.
*
* Effective cadence is `max(TIMELINE_STEP_DURATION, tile load time)`.
*/
export function usePacedTimelineStep(isPlaying: boolean, step: () => void) {
const areMapTilesLoading = useAtomValue(areMapTilesLoadingAtom);

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

const lastStepAtRef = useRef(0);
// 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();
}, [isPlaying]);

useEffect(() => {
if (!isPlaying || areMapTilesLoading) 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 timeout = setTimeout(() => {
lastStepAtRef.current = Date.now();
stepRef.current();
setStepCount((count) => count + 1);
}, Math.max(0, TIMELINE_STEP_DURATION - elapsed));

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