724 lines
21 KiB
Svelte
724 lines
21 KiB
Svelte
<script lang="ts">
|
||
import { onMount } from 'svelte';
|
||
import { SvelteDate } from 'svelte/reactivity';
|
||
import { get } from 'svelte/store';
|
||
|
||
import LoaderIcon from '@lucide/svelte/icons/loader-circle';
|
||
import PauseIcon from '@lucide/svelte/icons/pause';
|
||
import PlayIcon from '@lucide/svelte/icons/play';
|
||
import { updateCurrentBounds } from '@openmeteo/weather-map-layer';
|
||
import * as maplibregl from 'maplibre-gl';
|
||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||
|
||
import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings';
|
||
|
||
import { FrameAnimator, type FrameChannel } from '$lib/maps/frame-animator';
|
||
import {
|
||
clockPlaying,
|
||
clockScrub,
|
||
clockSpan,
|
||
clockSpeed,
|
||
sharedCamera
|
||
} from '$lib/maps/map-sync';
|
||
import {
|
||
type ResolvedSource,
|
||
omSourceUrl,
|
||
registerOmProtocol,
|
||
resolveSource
|
||
} from '$lib/maps/om';
|
||
|
||
import { modelGroups } from '../../routes/weather/options';
|
||
|
||
interface Props {
|
||
/** om-file variable to render, e.g. `precipitation`, `wind_u_component_10m`. */
|
||
variable: string;
|
||
/** Human label for the status chip / errors, e.g. "Precipitation", "Wind". */
|
||
title: string;
|
||
/**
|
||
* Accumulation variable (e.g. precipitation): its first timestep is the
|
||
* analysis time with no accumulation window, so skip it. Leave off for
|
||
* instantaneous variables like wind.
|
||
*/
|
||
accumulation?: boolean;
|
||
/** Overlay animated wind-arrow vectors on top of the raster field. */
|
||
arrows?: boolean;
|
||
}
|
||
|
||
let { variable, title, accumulation = false, arrows = false }: Props = $props();
|
||
|
||
const STYLE_LIGHT = 'https://map-assets.open-meteo.com/styles/minimal-planet-maps.json';
|
||
const STYLE_DARK = 'https://map-assets.open-meteo.com/styles/minimal-planet-maps-dark.json';
|
||
const DAY_MS = 86_400_000;
|
||
// ~8 frames loading at once ≈ 50 concurrent tile requests (a frame is a few
|
||
// tiles) - the requested preload parallelism.
|
||
const PRELOAD_MAX_FRAMES = 8;
|
||
|
||
// Playback speeds: ms of minimum wall-time per forecast frame.
|
||
const SPEEDS = [
|
||
{ label: '0.5×', ms: 900 },
|
||
{ label: '1×', ms: 450 },
|
||
{ label: '2×', ms: 220 }
|
||
];
|
||
// Loop / preload span presets (Infinity = the complete model run).
|
||
const SPANS = [
|
||
{ label: '2½ d', days: 2.5 },
|
||
{ label: '5 d', days: 5 },
|
||
{ label: 'Full run', days: Infinity }
|
||
];
|
||
|
||
const modelLabel = (id: string): string => {
|
||
for (const g of modelGroups) {
|
||
for (const m of g.models) if (m.value === id) return m.label;
|
||
}
|
||
return id;
|
||
};
|
||
|
||
let mapEl: HTMLDivElement;
|
||
let map: maplibregl.Map | undefined;
|
||
let animator: FrameAnimator | undefined;
|
||
let marker: maplibregl.Marker | undefined;
|
||
let syncingCamera = false;
|
||
|
||
// resolved source (full run) + windowed timeline
|
||
let sourceBase: ResolvedSource | null = null;
|
||
let fullTimes: Date[] = [];
|
||
let source = $state<ResolvedSource | null>(null);
|
||
let index = $state(0);
|
||
let loading = $state(true);
|
||
let firstFrameLoading = $state(true);
|
||
let error = $state<string | null>(null);
|
||
/** Per-frame load state, aligned with `times`. */
|
||
let loaded = $state<boolean[]>([]);
|
||
|
||
// dark mode mirrors the root layout's `.dark` class on <html>
|
||
let dark = $state(false);
|
||
|
||
// preload covers the whole windowed timeline (the loop == the window)
|
||
let loopStart = 0;
|
||
let loopEnd = 0;
|
||
|
||
let loadController: AbortController | undefined;
|
||
let lastKey = '';
|
||
let appliedSpan = get(clockSpan);
|
||
let appliedScrubEpoch = get(clockScrub).epoch;
|
||
|
||
const times = $derived(source?.times ?? []);
|
||
const currentTime = $derived(times[index]);
|
||
const loadedCount = $derived(loaded.reduce((n, v) => n + (v ? 1 : 0), 0));
|
||
|
||
// ---- helpers -------------------------------------------------------------
|
||
|
||
const closestIndexToNow = (list: Date[]): number => {
|
||
const now = Date.now();
|
||
let best = 0;
|
||
let bestDelta = Infinity;
|
||
for (let i = 0; i < list.length; i++) {
|
||
const delta = Math.abs(list[i].getTime() - now);
|
||
if (delta < bestDelta) {
|
||
bestDelta = delta;
|
||
best = i;
|
||
}
|
||
}
|
||
return best;
|
||
};
|
||
|
||
/** Keep steps within `spanDays` days from today 00:00 (Infinity = all). */
|
||
const windowTimes = (all: Date[], spanDays: number): Date[] => {
|
||
if (!isFinite(spanDays) || spanDays <= 0) return all;
|
||
const dayStart = new SvelteDate();
|
||
dayStart.setHours(0, 0, 0, 0);
|
||
const cutoff = dayStart.getTime() + spanDays * DAY_MS;
|
||
const windowed = all.filter((t) => t.getTime() >= dayStart.getTime() && t.getTime() <= cutoff);
|
||
return windowed.length ? windowed : all;
|
||
};
|
||
|
||
const buildUrl = (i: number): string => {
|
||
const s = source;
|
||
if (!s) return '';
|
||
return omSourceUrl(s.domain, s.modelRun, s.times[i], variable, dark, arrows);
|
||
};
|
||
|
||
const firstSymbolLayer = (m: maplibregl.Map): string | undefined =>
|
||
m.getStyle()?.layers?.find((l) => l.type === 'symbol')?.id;
|
||
|
||
// ---- animator / channels -------------------------------------------------
|
||
|
||
const makeChannels = (): FrameChannel[] => {
|
||
const raster: FrameChannel = {
|
||
key: 'raster',
|
||
sourceSpec: (url) => ({ type: 'raster', url, maxzoom: 14 }),
|
||
addLayer: (m, sourceId, layerId, before) =>
|
||
m.addLayer(
|
||
{
|
||
id: layerId,
|
||
type: 'raster',
|
||
source: sourceId,
|
||
paint: {
|
||
'raster-opacity': 0,
|
||
'raster-opacity-transition': { duration: 0, delay: 0 },
|
||
'raster-fade-duration': 0
|
||
}
|
||
},
|
||
before
|
||
),
|
||
opacityProp: 'raster-opacity',
|
||
peakOpacity: dark ? 0.85 : 0.9
|
||
};
|
||
if (!arrows) return [raster];
|
||
|
||
const arrowLayer: FrameChannel = {
|
||
key: 'arrows',
|
||
sourceSpec: (url) => ({ type: 'vector', url }),
|
||
addLayer: (m, sourceId, layerId, before) =>
|
||
m.addLayer(
|
||
{
|
||
id: layerId,
|
||
type: 'line',
|
||
source: sourceId,
|
||
'source-layer': 'wind-arrows',
|
||
layout: { 'line-cap': 'round' },
|
||
paint: {
|
||
'line-opacity': 0,
|
||
'line-opacity-transition': { duration: 0, delay: 0 },
|
||
'line-color': dark ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.72)',
|
||
'line-width': ['interpolate', ['linear'], ['zoom'], 3, 1.2, 9, 1.8]
|
||
}
|
||
},
|
||
before
|
||
),
|
||
opacityProp: 'line-opacity',
|
||
peakOpacity: 1
|
||
};
|
||
return [raster, arrowLayer];
|
||
};
|
||
|
||
const makeAnimator = (m: maplibregl.Map): void => {
|
||
animator?.destroy();
|
||
animator = new FrameAnimator({
|
||
map: m,
|
||
urlForFrame: buildUrl,
|
||
channels: makeChannels(),
|
||
beforeLayer: firstSymbolLayer(m),
|
||
opacity: dark ? 0.85 : 0.9,
|
||
onShow: (i) => {
|
||
index = i;
|
||
if (i < loaded.length) loaded[i] = true;
|
||
},
|
||
onReady: (i) => {
|
||
if (i < loaded.length) loaded[i] = true;
|
||
},
|
||
onFirstFrame: () => {
|
||
firstFrameLoading = false;
|
||
}
|
||
});
|
||
};
|
||
|
||
/** Chronological preload so the current day fills front-to-back, no gap. */
|
||
const preloadOrder = (): number[] => {
|
||
const order: number[] = [];
|
||
for (let i = loopStart; i <= loopEnd; i++) order.push(i);
|
||
return order;
|
||
};
|
||
|
||
const startAnimation = (m: maplibregl.Map): void => {
|
||
if (!source) return;
|
||
makeAnimator(m);
|
||
animator!.setFrameCount(source.times.length);
|
||
animator!.setLoopRange(loopStart, loopEnd);
|
||
const speed = get(clockSpeed);
|
||
animator!.setSpeed(speed);
|
||
const initial = closestIndexToNow(source.times);
|
||
index = initial;
|
||
if (get(clockPlaying)) animator!.play(speed, initial);
|
||
else animator!.scrubTo(initial);
|
||
animator!.preload(preloadOrder(), PRELOAD_MAX_FRAMES);
|
||
};
|
||
|
||
// ---- windowing / (re)load ------------------------------------------------
|
||
|
||
const applyWindow = (): void => {
|
||
if (!sourceBase || !map) return;
|
||
const list = windowTimes(fullTimes, get(clockSpan));
|
||
source = { ...sourceBase, times: list };
|
||
loopStart = 0;
|
||
loopEnd = Math.max(0, list.length - 1);
|
||
loaded = new Array(list.length).fill(false);
|
||
firstFrameLoading = true;
|
||
startAnimation(map);
|
||
};
|
||
|
||
const load = async (model: string): Promise<void> => {
|
||
loadController?.abort();
|
||
const controller = new AbortController();
|
||
loadController = controller;
|
||
|
||
loading = true;
|
||
error = null;
|
||
|
||
const resolved = await resolveSource(model, variable, {
|
||
signal: controller.signal,
|
||
skipFirst: accumulation
|
||
});
|
||
if (controller.signal.aborted) return;
|
||
|
||
if (!resolved) {
|
||
error = `No ${title.toLowerCase()} data available for this model right now.`;
|
||
loading = false;
|
||
return;
|
||
}
|
||
|
||
sourceBase = resolved;
|
||
fullTimes = resolved.times;
|
||
loading = false;
|
||
applyWindow();
|
||
};
|
||
|
||
// ---- theme ---------------------------------------------------------------
|
||
|
||
const applyStyle = async (m: maplibregl.Map): Promise<void> => {
|
||
m.setStyle(dark ? STYLE_DARK : STYLE_LIGHT);
|
||
await new Promise<void>((resolve) => m.once('style.load', () => resolve()));
|
||
applyPadding(m);
|
||
// setStyle wipes all layers; rebuild the animator for the new theme
|
||
if (source) startAnimation(m);
|
||
};
|
||
|
||
// ---- camera / marker -----------------------------------------------------
|
||
|
||
// 20% bottom padding keeps the focused location above the time scrubber.
|
||
const applyPadding = (m: maplibregl.Map): void => {
|
||
const h = mapEl?.clientHeight ?? 0;
|
||
m.setPadding({ top: 0, right: 0, bottom: Math.round(h * 0.2), left: 0 });
|
||
};
|
||
|
||
const makeMarkerEl = (): HTMLDivElement => {
|
||
const el = document.createElement('div');
|
||
el.className = 'wx-marker';
|
||
el.innerHTML = '<span class="wx-marker__ring"></span><span class="wx-marker__dot"></span>';
|
||
return el;
|
||
};
|
||
|
||
// ---- lifecycle -----------------------------------------------------------
|
||
|
||
onMount(() => {
|
||
registerOmProtocol();
|
||
dark = document.documentElement.classList.contains('dark');
|
||
lastKey = `${$storedModel}`;
|
||
|
||
map = new maplibregl.Map({
|
||
container: mapEl,
|
||
style: dark ? STYLE_DARK : STYLE_LIGHT,
|
||
center: [$storedLocation.longitude, $storedLocation.latitude],
|
||
zoom: 7.5,
|
||
attributionControl: { compact: true },
|
||
maxPitch: 0,
|
||
dragRotate: false
|
||
});
|
||
const m = map;
|
||
m.scrollZoom.disable(); // don't hijack page scroll to zoom
|
||
m.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
|
||
|
||
const pushBounds = () => {
|
||
const b = m.getBounds();
|
||
updateCurrentBounds([b.getWest(), b.getSouth(), b.getEast(), b.getNorth()]);
|
||
};
|
||
// mirror this map's camera to siblings (unless the move came from a sync)
|
||
m.on('move', () => {
|
||
pushBounds();
|
||
if (syncingCamera) return;
|
||
const c = m.getCenter();
|
||
sharedCamera.set({ center: [c.lng, c.lat], zoom: m.getZoom() });
|
||
});
|
||
m.on('resize', () => applyPadding(m));
|
||
|
||
m.on('load', () => {
|
||
pushBounds();
|
||
applyPadding(m);
|
||
marker = new maplibregl.Marker({ element: makeMarkerEl(), anchor: 'center' })
|
||
.setLngLat([$storedLocation.longitude, $storedLocation.latitude])
|
||
.addTo(m);
|
||
void load($storedModel);
|
||
});
|
||
|
||
// follow a sibling map's camera
|
||
const unsubCamera = sharedCamera.subscribe((cam) => {
|
||
if (!cam || !map) return;
|
||
const c = map.getCenter();
|
||
if (
|
||
Math.abs(c.lng - cam.center[0]) < 1e-6 &&
|
||
Math.abs(c.lat - cam.center[1]) < 1e-6 &&
|
||
Math.abs(map.getZoom() - cam.zoom) < 1e-6
|
||
)
|
||
return;
|
||
syncingCamera = true;
|
||
map.jumpTo({ center: cam.center, zoom: cam.zoom });
|
||
syncingCamera = false;
|
||
});
|
||
|
||
// keep dark in sync with the app theme toggle (root layout flips `.dark`)
|
||
const observer = new MutationObserver(() => {
|
||
const isDark = document.documentElement.classList.contains('dark');
|
||
if (isDark !== dark) {
|
||
dark = isDark;
|
||
if (map && map.isStyleLoaded()) void applyStyle(map);
|
||
}
|
||
});
|
||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||
|
||
return () => {
|
||
observer.disconnect();
|
||
unsubCamera();
|
||
animator?.destroy();
|
||
loadController?.abort();
|
||
marker?.remove();
|
||
map?.remove();
|
||
map = undefined;
|
||
};
|
||
});
|
||
|
||
// reload when the shared model selection changes
|
||
$effect(() => {
|
||
const key = `${$storedModel}`;
|
||
if (map && map.isStyleLoaded() && key !== lastKey) {
|
||
lastKey = key;
|
||
void load($storedModel);
|
||
}
|
||
});
|
||
|
||
// recenter + move the marker when the user picks a new location
|
||
$effect(() => {
|
||
const loc = $storedLocation;
|
||
marker?.setLngLat([loc.longitude, loc.latitude]);
|
||
if (map && map.isStyleLoaded()) {
|
||
map.easeTo({ center: [loc.longitude, loc.latitude], duration: 800 });
|
||
}
|
||
});
|
||
|
||
// ---- shared-clock effects ------------------------------------------------
|
||
|
||
// play / pause together
|
||
$effect(() => {
|
||
const playing = $clockPlaying;
|
||
if (!animator) return;
|
||
if (playing) {
|
||
const from = index >= loopStart && index <= loopEnd ? index : loopStart;
|
||
animator.play(get(clockSpeed), from);
|
||
} else {
|
||
animator.pause();
|
||
}
|
||
});
|
||
|
||
// speed together
|
||
$effect(() => {
|
||
const ms = $clockSpeed;
|
||
animator?.setSpeed(ms);
|
||
});
|
||
|
||
// span together (re-window + restart)
|
||
$effect(() => {
|
||
const days = $clockSpan;
|
||
if (days !== appliedSpan) {
|
||
appliedSpan = days;
|
||
if (sourceBase) applyWindow();
|
||
}
|
||
});
|
||
|
||
// scrub together
|
||
$effect(() => {
|
||
const s = $clockScrub;
|
||
if (s.epoch !== appliedScrubEpoch) {
|
||
appliedScrubEpoch = s.epoch;
|
||
if (s.index >= 0) {
|
||
index = s.index;
|
||
animator?.scrubTo(s.index);
|
||
}
|
||
}
|
||
});
|
||
|
||
// ---- controls (write to the shared clock so every map follows) -----------
|
||
|
||
const togglePlay = (): void => clockPlaying.update((p) => !p);
|
||
const setSpeed = (ms: number): void => clockSpeed.set(ms);
|
||
const setSpan = (days: number): void => clockSpan.set(days);
|
||
|
||
const onScrubInput = (e: Event): void => {
|
||
const v = Number((e.currentTarget as HTMLInputElement).value);
|
||
clockScrub.set({ index: v, epoch: get(clockScrub).epoch + 1 });
|
||
};
|
||
const onScrubStart = (): void => clockPlaying.set(false);
|
||
const onScrubEnd = (): void => clockPlaying.set(true);
|
||
|
||
// ---- formatting / scrubber decorations -----------------------------------
|
||
|
||
const hour12 = $derived($storedUnits.time_format === '12h');
|
||
const fmtTime = (d: Date | undefined): string =>
|
||
d
|
||
? new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit', hour12 }).format(d)
|
||
: '--:--';
|
||
const fmtDay = (d: Date | undefined): string =>
|
||
d
|
||
? new Intl.DateTimeFormat(undefined, {
|
||
weekday: 'short',
|
||
day: 'numeric',
|
||
month: 'short'
|
||
}).format(d)
|
||
: '';
|
||
|
||
const pct = (i: number): number => (times.length > 1 ? (i / (times.length - 1)) * 100 : 0);
|
||
|
||
const dayMarkers = $derived.by(() => {
|
||
const out: { i: number; label: string; left: number }[] = [];
|
||
let prevDay = '';
|
||
times.forEach((t, i) => {
|
||
const day = t.toDateString();
|
||
if (day !== prevDay) {
|
||
prevDay = day;
|
||
out.push({
|
||
i,
|
||
label: new Intl.DateTimeFormat(undefined, { weekday: 'short' }).format(t),
|
||
left: pct(i)
|
||
});
|
||
}
|
||
});
|
||
return out;
|
||
});
|
||
|
||
const nowLeft = $derived(times.length ? pct(closestIndexToNow(times)) : 0);
|
||
</script>
|
||
|
||
<div class="relative h-full w-full">
|
||
<div bind:this={mapEl} class="h-full w-full"></div>
|
||
|
||
<!-- top-left status: variable + model + fallback notice -->
|
||
<div class="pointer-events-none absolute left-3 top-3 z-10 flex flex-col gap-1">
|
||
<div
|
||
class="pointer-events-auto rounded-md bg-background/85 px-3 py-1.5 text-sm font-medium shadow-sm backdrop-blur"
|
||
>
|
||
{title} · {modelLabel($storedModel)}
|
||
</div>
|
||
{#if source?.fellBack}
|
||
<div
|
||
class="pointer-events-auto w-fit rounded-md bg-amber-500/90 px-2 py-1 text-xs font-medium text-white shadow-sm"
|
||
>
|
||
No map for this model - showing global {source.domain}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
{#if error}
|
||
<div class="absolute inset-x-0 top-16 z-10 flex justify-center">
|
||
<div class="rounded-md bg-destructive px-4 py-2 text-sm text-destructive-foreground shadow">
|
||
{error}
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- big scrubber -->
|
||
<div class="absolute inset-x-0 bottom-0 z-10 p-3 sm:p-4">
|
||
<div
|
||
class="mx-auto w-full rounded-xl border border-border bg-background/90 p-3 shadow-lg backdrop-blur sm:p-4"
|
||
>
|
||
<div class="flex items-center gap-3 sm:gap-4">
|
||
<button
|
||
type="button"
|
||
onclick={togglePlay}
|
||
disabled={loading || !!error || times.length < 2}
|
||
aria-label={$clockPlaying ? 'Pause' : 'Play'}
|
||
class="flex size-12 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground shadow transition hover:opacity-90 disabled:opacity-40 sm:size-14"
|
||
>
|
||
{#if loading || firstFrameLoading}
|
||
<LoaderIcon class="size-6 animate-spin" />
|
||
{:else if $clockPlaying}
|
||
<PauseIcon class="size-6" />
|
||
{:else}
|
||
<PlayIcon class="size-6 translate-x-0.5" />
|
||
{/if}
|
||
</button>
|
||
|
||
<div class="flex min-w-0 flex-col">
|
||
<span class="text-2xl font-semibold leading-none tabular-nums sm:text-3xl">
|
||
{fmtTime(currentTime)}
|
||
</span>
|
||
<span class="mt-1 truncate text-xs text-muted-foreground sm:text-sm">
|
||
{fmtDay(currentTime)}
|
||
</span>
|
||
</div>
|
||
|
||
<div class="ml-auto flex flex-wrap items-center justify-end gap-2">
|
||
{#if times.length}
|
||
<span class="hidden text-xs text-muted-foreground tabular-nums xl:inline">
|
||
{loadedCount}/{times.length} cached
|
||
</span>
|
||
{/if}
|
||
<!-- range: how much to loop / preload -->
|
||
<div
|
||
class="flex items-center gap-1 rounded-lg bg-muted p-1"
|
||
title="Loop & preload range"
|
||
>
|
||
{#each SPANS as s (s.label)}
|
||
<button
|
||
type="button"
|
||
onclick={() => setSpan(s.days)}
|
||
class="rounded-md px-2 py-1 text-xs font-medium tabular-nums transition
|
||
{$clockSpan === s.days
|
||
? 'bg-background text-foreground shadow-sm'
|
||
: 'text-muted-foreground hover:text-foreground'}"
|
||
>
|
||
{s.label}
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
<!-- playback speed -->
|
||
<div class="flex items-center gap-1 rounded-lg bg-muted p-1" title="Playback speed">
|
||
{#each SPEEDS as s (s.ms)}
|
||
<button
|
||
type="button"
|
||
onclick={() => setSpeed(s.ms)}
|
||
class="rounded-md px-2 py-1 text-xs font-medium tabular-nums transition
|
||
{$clockSpeed === s.ms
|
||
? 'bg-background text-foreground shadow-sm'
|
||
: 'text-muted-foreground hover:text-foreground'}"
|
||
>
|
||
{s.label}
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- track -->
|
||
<div class="relative mt-3">
|
||
<input
|
||
type="range"
|
||
class="scrubber relative"
|
||
min="0"
|
||
max={Math.max(0, times.length - 1)}
|
||
step="1"
|
||
value={index}
|
||
disabled={loading || !!error || !times.length}
|
||
oninput={onScrubInput}
|
||
onpointerdown={onScrubStart}
|
||
onpointerup={onScrubEnd}
|
||
onpointercancel={onScrubEnd}
|
||
aria-label="Forecast time"
|
||
/>
|
||
|
||
<!-- now marker -->
|
||
{#if times.length}
|
||
<div
|
||
class="pointer-events-none absolute -top-1 h-3 w-0.5 bg-red-500"
|
||
style="left: {nowLeft}%"
|
||
title="Now"
|
||
></div>
|
||
{/if}
|
||
|
||
<!-- buffered-frames strip: shows which steps are cached -->
|
||
{#if times.length}
|
||
<div class="mt-2 flex h-1.5 w-full overflow-hidden rounded-full bg-muted-foreground/15">
|
||
{#each times as _t, i (i)}
|
||
<div
|
||
class="h-full flex-1 transition-colors {loaded[i]
|
||
? 'bg-primary'
|
||
: 'bg-transparent'} {i === index ? 'bg-red-500' : ''}"
|
||
></div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- day labels -->
|
||
<div class="relative mt-1 h-4">
|
||
{#each dayMarkers as d (d.i)}
|
||
<span
|
||
class="pointer-events-none absolute -translate-x-1/2 text-[10px] text-muted-foreground"
|
||
style="left: {Math.min(96, Math.max(4, d.left))}%"
|
||
>
|
||
{d.label}
|
||
</span>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<style>
|
||
/* Big, prominent scrubber that works in light and dark. */
|
||
.scrubber {
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
width: 100%;
|
||
height: 10px;
|
||
border-radius: 9999px;
|
||
background: color-mix(in oklab, var(--color-primary) 25%, transparent);
|
||
cursor: pointer;
|
||
outline: none;
|
||
}
|
||
.scrubber:disabled {
|
||
opacity: 0.5;
|
||
cursor: default;
|
||
}
|
||
.scrubber::-webkit-slider-thumb {
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
width: 26px;
|
||
height: 26px;
|
||
border-radius: 9999px;
|
||
background: var(--color-primary);
|
||
border: 3px solid var(--color-background);
|
||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
|
||
cursor: grab;
|
||
}
|
||
.scrubber::-webkit-slider-thumb:active {
|
||
cursor: grabbing;
|
||
}
|
||
.scrubber::-moz-range-thumb {
|
||
width: 26px;
|
||
height: 26px;
|
||
border-radius: 9999px;
|
||
background: var(--color-primary);
|
||
border: 3px solid var(--color-background);
|
||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
|
||
cursor: grab;
|
||
}
|
||
.scrubber::-moz-range-progress {
|
||
height: 10px;
|
||
border-radius: 9999px;
|
||
background: var(--color-primary);
|
||
}
|
||
|
||
/* Pulsing "current location" marker (added to the map's own DOM, so styles
|
||
must be global). */
|
||
:global(.wx-marker) {
|
||
position: relative;
|
||
width: 20px;
|
||
height: 20px;
|
||
pointer-events: none;
|
||
}
|
||
:global(.wx-marker__dot) {
|
||
position: absolute;
|
||
inset: 6px;
|
||
border-radius: 9999px;
|
||
background: #ef4444;
|
||
box-shadow:
|
||
0 0 0 2px #fff,
|
||
0 1px 3px rgba(0, 0, 0, 0.5);
|
||
}
|
||
:global(.wx-marker__ring) {
|
||
position: absolute;
|
||
inset: 0;
|
||
border-radius: 9999px;
|
||
background: rgba(239, 68, 68, 0.55);
|
||
animation: wx-pulse 1.5s ease-out infinite;
|
||
}
|
||
@keyframes -global-wx-pulse {
|
||
0% {
|
||
transform: scale(0.4);
|
||
opacity: 0.8;
|
||
}
|
||
100% {
|
||
transform: scale(2);
|
||
opacity: 0;
|
||
}
|
||
}
|
||
</style>
|