geotimelapse 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +109 -0
  3. package/dist/bounds.d.ts +5 -0
  4. package/dist/bounds.d.ts.map +1 -0
  5. package/dist/bounds.js +9 -0
  6. package/dist/bounds.js.map +1 -0
  7. package/dist/clock.d.ts +24 -0
  8. package/dist/clock.d.ts.map +1 -0
  9. package/dist/clock.js +108 -0
  10. package/dist/clock.js.map +1 -0
  11. package/dist/counter.d.ts +18 -0
  12. package/dist/counter.d.ts.map +1 -0
  13. package/dist/counter.js +54 -0
  14. package/dist/counter.js.map +1 -0
  15. package/dist/duckdb-source.d.ts +24 -0
  16. package/dist/duckdb-source.d.ts.map +1 -0
  17. package/dist/duckdb-source.js +211 -0
  18. package/dist/duckdb-source.js.map +1 -0
  19. package/dist/geo-timelapse.d.ts +8 -0
  20. package/dist/geo-timelapse.d.ts.map +1 -0
  21. package/dist/geo-timelapse.js +143 -0
  22. package/dist/geo-timelapse.js.map +1 -0
  23. package/dist/glow-layer.d.ts +47 -0
  24. package/dist/glow-layer.d.ts.map +1 -0
  25. package/dist/glow-layer.js +129 -0
  26. package/dist/glow-layer.js.map +1 -0
  27. package/dist/hooks.d.ts +32 -0
  28. package/dist/hooks.d.ts.map +1 -0
  29. package/dist/hooks.js +142 -0
  30. package/dist/hooks.js.map +1 -0
  31. package/dist/index.d.ts +6 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +3 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/map-style.d.ts +3 -0
  36. package/dist/map-style.d.ts.map +1 -0
  37. package/dist/map-style.js +832 -0
  38. package/dist/map-style.js.map +1 -0
  39. package/dist/map.d.ts +11 -0
  40. package/dist/map.d.ts.map +1 -0
  41. package/dist/map.js +60 -0
  42. package/dist/map.js.map +1 -0
  43. package/dist/player-bar.d.ts +4 -0
  44. package/dist/player-bar.d.ts.map +1 -0
  45. package/dist/player-bar.js +118 -0
  46. package/dist/player-bar.js.map +1 -0
  47. package/dist/settings-menu.d.ts +17 -0
  48. package/dist/settings-menu.d.ts.map +1 -0
  49. package/dist/settings-menu.js +18 -0
  50. package/dist/settings-menu.js.map +1 -0
  51. package/dist/synthetic-source.d.ts +18 -0
  52. package/dist/synthetic-source.d.ts.map +1 -0
  53. package/dist/synthetic-source.js +192 -0
  54. package/dist/synthetic-source.js.map +1 -0
  55. package/dist/types.d.ts +62 -0
  56. package/dist/types.d.ts.map +1 -0
  57. package/dist/types.js +2 -0
  58. package/dist/types.js.map +1 -0
  59. package/dist/utils.d.ts +9 -0
  60. package/dist/utils.d.ts.map +1 -0
  61. package/dist/utils.js +28 -0
  62. package/dist/utils.js.map +1 -0
  63. package/package.json +67 -0
  64. package/src/bounds.tsx +14 -0
  65. package/src/clock.tsx +130 -0
  66. package/src/counter.tsx +96 -0
  67. package/src/duckdb-source.ts +238 -0
  68. package/src/geo-timelapse.tsx +204 -0
  69. package/src/glow-layer.test.ts +51 -0
  70. package/src/glow-layer.ts +166 -0
  71. package/src/hooks.ts +167 -0
  72. package/src/index.ts +5 -0
  73. package/src/map-style.ts +833 -0
  74. package/src/map.tsx +107 -0
  75. package/src/player-bar.tsx +164 -0
  76. package/src/settings-menu.tsx +117 -0
  77. package/src/synthetic-source.test.ts +58 -0
  78. package/src/synthetic-source.ts +218 -0
  79. package/src/types.ts +66 -0
  80. package/src/utils.test.ts +60 -0
  81. package/src/utils.ts +32 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
@@ -0,0 +1,9 @@
1
+ /** Manual on purpose: the end-of-day marker 24:00:00 has no date-fns spelling. */
2
+ export declare function formatDayTime(daySeconds: number): string;
3
+ /** US-style clock time, e.g. "2:30 AM"; the end of day wraps to "12:00 AM". */
4
+ export declare function formatDayTime12(daySeconds: number): string;
5
+ /** Compact 24-hour time, e.g. "2:30" or "13:05". */
6
+ export declare function formatDayTime24(daySeconds: number): string;
7
+ /** Compact 24-hour label for a full hour, e.g. 2 → "2:00"; 24 marks the end of day. */
8
+ export declare function formatHourLabel(hour: number): string;
9
+ //# sourceMappingURL=utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAEA,kFAAkF;AAClF,wBAAgB,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAOxD;AAQD,+EAA+E;AAC/E,wBAAgB,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAE1D;AAED,oDAAoD;AACpD,wBAAgB,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAE1D;AAED,uFAAuF;AACvF,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEpD"}
package/dist/utils.js ADDED
@@ -0,0 +1,28 @@
1
+ import { format } from 'date-fns';
2
+ /** Manual on purpose: the end-of-day marker 24:00:00 has no date-fns spelling. */
3
+ export function formatDayTime(daySeconds) {
4
+ const total = Math.max(Math.floor(daySeconds), 0);
5
+ const hours = Math.floor(total / 3600);
6
+ const minutes = Math.floor((total % 3600) / 60);
7
+ const seconds = total % 60;
8
+ const pad = (value) => String(value).padStart(2, '0');
9
+ return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
10
+ }
11
+ // An arbitrary DST-free local date carrying a time of day for date-fns.
12
+ function dayTimeAsDate(daySeconds) {
13
+ const total = Math.max(Math.floor(daySeconds), 0);
14
+ return new Date(2000, 0, 1, Math.floor(total / 3600) % 24, Math.floor((total % 3600) / 60), total % 60);
15
+ }
16
+ /** US-style clock time, e.g. "2:30 AM"; the end of day wraps to "12:00 AM". */
17
+ export function formatDayTime12(daySeconds) {
18
+ return format(dayTimeAsDate(daySeconds), 'h:mm a');
19
+ }
20
+ /** Compact 24-hour time, e.g. "2:30" or "13:05". */
21
+ export function formatDayTime24(daySeconds) {
22
+ return format(dayTimeAsDate(daySeconds), 'H:mm');
23
+ }
24
+ /** Compact 24-hour label for a full hour, e.g. 2 → "2:00"; 24 marks the end of day. */
25
+ export function formatHourLabel(hour) {
26
+ return hour === 24 ? '24:00' : formatDayTime24(hour * 3600);
27
+ }
28
+ //# sourceMappingURL=utils.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils.js","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAElC,kFAAkF;AAClF,MAAM,UAAU,aAAa,CAAC,UAAkB;IAC9C,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,EAAE,CAAC,CAAC,CAAC;IAClD,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,GAAG,IAAI,CAAC,CAAC;IACvC,MAAM,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,KAAK,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;IAChD,MAAM,OAAO,GAAG,KAAK,GAAG,EAAE,CAAC;IAC3B,MAAM,GAAG,GAAG,CAAC,KAAa,EAAE,EAAE,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;IAC9D,OAAO,GAAG,GAAG,CAAC,KAAK,CAAC,IAAI,GAAG,CAAC,OAAO,CAAC,IAAI,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC;AACzD,CAAC;AAED,wEAAwE;AACxE,SAAS,aAAa,CAAC,UAAkB;IACvC,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,EAAE,CAAC,CAAC,CAAC;IAClD,OAAO,IAAI,IAAI,CAAC,IAAI,EAAE,CAAC,EAAE,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,GAAG,IAAI,CAAC,GAAG,EAAE,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,KAAK,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,EAAE,KAAK,GAAG,EAAE,CAAC,CAAC;AAC1G,CAAC;AAED,+EAA+E;AAC/E,MAAM,UAAU,eAAe,CAAC,UAAkB;IAChD,OAAO,MAAM,CAAC,aAAa,CAAC,UAAU,CAAC,EAAE,QAAQ,CAAC,CAAC;AACrD,CAAC;AAED,oDAAoD;AACpD,MAAM,UAAU,eAAe,CAAC,UAAkB;IAChD,OAAO,MAAM,CAAC,aAAa,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC,CAAC;AACnD,CAAC;AAED,uFAAuF;AACvF,MAAM,UAAU,eAAe,CAAC,IAAY;IAC1C,OAAO,IAAI,KAAK,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,eAAe,CAAC,IAAI,GAAG,IAAI,CAAC,CAAC;AAC9D,CAAC"}
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "geotimelapse",
3
+ "version": "0.1.0",
4
+ "description": "Replay a day of geolocated events on a dark glowing map",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/FranckErnewein/geotimelapse.git",
9
+ "directory": "packages/geotimelapse"
10
+ },
11
+ "type": "module",
12
+ "sideEffects": false,
13
+ "main": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "import": "./dist/index.js",
19
+ "default": "./dist/index.js"
20
+ },
21
+ "./duckdb": {
22
+ "types": "./dist/duckdb-source.d.ts",
23
+ "import": "./dist/duckdb-source.js",
24
+ "default": "./dist/duckdb-source.js"
25
+ },
26
+ "./synthetic": {
27
+ "types": "./dist/synthetic-source.d.ts",
28
+ "import": "./dist/synthetic-source.js",
29
+ "default": "./dist/synthetic-source.js"
30
+ },
31
+ "./package.json": "./package.json"
32
+ },
33
+ "files": [
34
+ "dist",
35
+ "src",
36
+ "LICENSE"
37
+ ],
38
+ "scripts": {
39
+ "build": "tsc -p tsconfig.build.json",
40
+ "prepublishOnly": "pnpm build",
41
+ "typecheck": "tsc --noEmit",
42
+ "test": "tsx --test src/*.test.ts"
43
+ },
44
+ "peerDependencies": {
45
+ "deck.gl": ">=9.4.0",
46
+ "mapbox-gl": ">=3.0.0",
47
+ "react": ">=19.0.0",
48
+ "react-dom": ">=19.0.0",
49
+ "react-map-gl": ">=8.0.0"
50
+ },
51
+ "dependencies": {
52
+ "@duckdb/duckdb-wasm": "1.33.1-dev57.0",
53
+ "@heroicons/react": "^2.2.0",
54
+ "date-fns": "^4.1.0"
55
+ },
56
+ "devDependencies": {
57
+ "@types/node": "^26.0.0",
58
+ "@types/react": "^19.0.0",
59
+ "deck.gl": "^9.4.0",
60
+ "mapbox-gl": "^3.30.0",
61
+ "react": "^19.0.0",
62
+ "react-dom": "^19.0.0",
63
+ "react-map-gl": "^8.1.0",
64
+ "tsx": "^4.19.0",
65
+ "typescript": "^5.9.0"
66
+ }
67
+ }
package/src/bounds.tsx ADDED
@@ -0,0 +1,14 @@
1
+ 'use client';
2
+
3
+ import { createContext, useContext } from 'react';
4
+
5
+ import type { MapBounds } from './types.js';
6
+
7
+ const BoundsContext = createContext<MapBounds | null>(null);
8
+
9
+ export const MapBoundsProvider = BoundsContext.Provider;
10
+
11
+ /** The map's current viewport bounds; null until the map has loaded. */
12
+ export function useMapBounds(): MapBounds | null {
13
+ return useContext(BoundsContext);
14
+ }
package/src/clock.tsx ADDED
@@ -0,0 +1,130 @@
1
+ 'use client';
2
+
3
+ import type { PropsWithChildren } from 'react';
4
+ import { createContext, useCallback, useContext, useEffect, useState, useSyncExternalStore } from 'react';
5
+
6
+ export const DAY_SECONDS = 24 * 60 * 60;
7
+ // Wall-clock duration of a full-day replay.
8
+ const PLAYBACK_SECONDS = 180;
9
+ const RATE = DAY_SECONDS / PLAYBACK_SECONDS;
10
+ // Ticks are capped below the display refresh rate: extra updates are invisible
11
+ // and would only waste re-renders on high-refresh screens.
12
+ const MAX_TICKS_PER_SECOND = 24;
13
+ const MIN_TICK_MS = 1000 / MAX_TICKS_PER_SECOND;
14
+ // Bounds on the wall-clock delta applied per tick. The upper bound keeps a
15
+ // hidden tab (rAF suspended) or a main-thread stall from fast-forwarding the
16
+ // replay; the lower bound guards against the first rAF timestamp predating the
17
+ // performance.now() sampled in play() (vsync time is stamped before input).
18
+ const MAX_TICK_MS = 100;
19
+
20
+ export interface TimelapseClock {
21
+ play: () => void;
22
+ pause: () => void;
23
+ toggle: () => void;
24
+ seek: (daySeconds: number) => void;
25
+ /** Seconds elapsed since the start of the replayed day, in [0, DAY_SECONDS]. */
26
+ getDaySeconds: () => number;
27
+ /** Increments on every time jump (seek, end-of-day restart): consumers use it to invalidate history. */
28
+ getEpoch: () => number;
29
+ isPlaying: () => boolean;
30
+ /** Notified up to MAX_TICKS_PER_SECOND times while playing, and on play/pause/seek. */
31
+ subscribe: (listener: () => void) => () => void;
32
+ }
33
+
34
+ function createClock(): TimelapseClock {
35
+ let daySeconds = 0;
36
+ let epoch = 0;
37
+ let playing = false;
38
+ let rafId = 0;
39
+ let lastTick = 0;
40
+ // Next tick due date, advanced on its own grid so the effective rate stays
41
+ // at the cap instead of snapping down to a divisor of the refresh rate.
42
+ let nextTickAt = 0;
43
+ const listeners = new Set<() => void>();
44
+
45
+ const notify = () => listeners.forEach((listener) => listener());
46
+
47
+ const tick = (now: number) => {
48
+ if (now < nextTickAt) {
49
+ rafId = requestAnimationFrame(tick);
50
+ return;
51
+ }
52
+ nextTickAt = Math.max(nextTickAt + MIN_TICK_MS, now);
53
+ daySeconds += (Math.min(Math.max(now - lastTick, 0), MAX_TICK_MS) / 1000) * RATE;
54
+ lastTick = now;
55
+ if (daySeconds >= DAY_SECONDS) {
56
+ daySeconds = DAY_SECONDS;
57
+ playing = false;
58
+ } else {
59
+ rafId = requestAnimationFrame(tick);
60
+ }
61
+ notify();
62
+ };
63
+
64
+ const play = () => {
65
+ if (playing) return;
66
+ if (daySeconds >= DAY_SECONDS) {
67
+ daySeconds = 0;
68
+ epoch += 1;
69
+ }
70
+ playing = true;
71
+ lastTick = performance.now();
72
+ nextTickAt = lastTick;
73
+ rafId = requestAnimationFrame(tick);
74
+ notify();
75
+ };
76
+
77
+ const pause = () => {
78
+ if (!playing) return;
79
+ playing = false;
80
+ cancelAnimationFrame(rafId);
81
+ notify();
82
+ };
83
+
84
+ return {
85
+ play,
86
+ pause,
87
+ toggle: () => (playing ? pause() : play()),
88
+ seek: (value: number) => {
89
+ daySeconds = Math.min(Math.max(value, 0), DAY_SECONDS);
90
+ epoch += 1;
91
+ notify();
92
+ },
93
+ getDaySeconds: () => daySeconds,
94
+ getEpoch: () => epoch,
95
+ isPlaying: () => playing,
96
+ subscribe: (listener: () => void) => {
97
+ listeners.add(listener);
98
+ return () => listeners.delete(listener);
99
+ },
100
+ };
101
+ }
102
+
103
+ const ClockContext = createContext<TimelapseClock | null>(null);
104
+
105
+ export function TimelapseClockProvider({ children }: PropsWithChildren) {
106
+ const [clock] = useState(createClock);
107
+ useEffect(() => () => clock.pause(), [clock]);
108
+ return <ClockContext.Provider value={clock}>{children}</ClockContext.Provider>;
109
+ }
110
+
111
+ export function useTimelapseClock(): TimelapseClock {
112
+ const clock = useContext(ClockContext);
113
+ if (!clock) throw new Error('useTimelapseClock must be used within a TimelapseClockProvider');
114
+ return clock;
115
+ }
116
+
117
+ export function useIsPlaying(): boolean {
118
+ const clock = useTimelapseClock();
119
+ return useSyncExternalStore(clock.subscribe, clock.isPlaying, () => false);
120
+ }
121
+
122
+ /**
123
+ * Current day time, quantized: the component only re-renders when the value
124
+ * crosses a quantum boundary, so each consumer picks its own refresh rate.
125
+ */
126
+ export function useDaySeconds(quantum = 1): number {
127
+ const clock = useTimelapseClock();
128
+ const getSnapshot = useCallback(() => Math.floor(clock.getDaySeconds() / quantum) * quantum, [clock, quantum]);
129
+ return useSyncExternalStore(clock.subscribe, getSnapshot, () => 0);
130
+ }
@@ -0,0 +1,96 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+
5
+ import { useDaySeconds, useTimelapseClock } from './clock.js';
6
+ import type { GeoTimelapseSource, Totals } from './types.js';
7
+ import { formatDayTime12 } from './utils.js';
8
+
9
+ interface CounterProps {
10
+ source: GeoTimelapseSource;
11
+ ready: boolean;
12
+ error: string | null;
13
+ progress: { loadedBytes: number; totalBytes: number } | null;
14
+ scopeVersion: number;
15
+ dateLabel?: string;
16
+ timeZoneLabel?: string;
17
+ formatValue: (value: number) => string;
18
+ formatCount: (count: number) => string;
19
+ }
20
+
21
+ export default function Counter({
22
+ source,
23
+ ready,
24
+ error,
25
+ progress,
26
+ scopeVersion,
27
+ dateLabel,
28
+ timeZoneLabel,
29
+ formatValue,
30
+ formatCount,
31
+ }: CounterProps) {
32
+ const clock = useTimelapseClock();
33
+ const daySeconds = useDaySeconds(60);
34
+ const [totals, setTotals] = useState<Totals | null>(null);
35
+ const [queryError, setQueryError] = useState<string | null>(null);
36
+
37
+ useEffect(() => {
38
+ if (!ready) return;
39
+ let disposed = false;
40
+ let inFlight = false;
41
+ let lastQueried = -1;
42
+
43
+ // One query at a time: each tick tries, and is skipped while the previous
44
+ // query is still running, so the counter refreshes as fast as the source
45
+ // can answer and never queues up.
46
+ const query = async () => {
47
+ const second = Math.floor(clock.getDaySeconds());
48
+ if (inFlight || second === lastQueried) return;
49
+ inFlight = true;
50
+ try {
51
+ const result = await source.totals(second);
52
+ if (disposed) return;
53
+ lastQueried = second;
54
+ setQueryError(null);
55
+ setTotals(result);
56
+ } catch (totalsError) {
57
+ if (!disposed) setQueryError(totalsError instanceof Error ? totalsError.message : String(totalsError));
58
+ } finally {
59
+ inFlight = false;
60
+ }
61
+ };
62
+
63
+ void query();
64
+ const unsubscribe = clock.subscribe(() => void query());
65
+ return () => {
66
+ disposed = true;
67
+ unsubscribe();
68
+ };
69
+ // ready and scopeVersion re-run the effect so a re-scoped source is re-read.
70
+ // oxlint-disable-next-line react/exhaustive-effect-dependencies
71
+ }, [clock, source, ready, scopeVersion]);
72
+
73
+ return (
74
+ <div className="absolute top-4 right-4 h-24 w-64 rounded-lg bg-black/60 px-4 py-3 text-right font-mono text-white backdrop-blur-sm">
75
+ {(error ?? queryError) ? (
76
+ <div className="max-w-xs overflow-hidden text-xs text-red-400">{error ?? queryError}</div>
77
+ ) : ready && totals ? (
78
+ <>
79
+ <div className="text-xs text-white/50 tabular-nums">
80
+ {dateLabel && `${dateLabel}, `}
81
+ {formatDayTime12(daySeconds)}
82
+ {timeZoneLabel && ` ${timeZoneLabel}`}
83
+ </div>
84
+ <div className="text-2xl tabular-nums">{formatValue(totals.value)}</div>
85
+ <div className="text-xs text-white/70 tabular-nums">{formatCount(totals.count)}</div>
86
+ </>
87
+ ) : (
88
+ <div className="text-xs text-white/70 tabular-nums">
89
+ {progress
90
+ ? `loading day… ${Math.round(progress.loadedBytes / 1e6)}/${Math.round(progress.totalBytes / 1e6)} MB`
91
+ : 'loading…'}
92
+ </div>
93
+ )}
94
+ </div>
95
+ );
96
+ }
@@ -0,0 +1,238 @@
1
+ import * as duckdb from '@duckdb/duckdb-wasm';
2
+
3
+ import type { FramePoints, GeoTimelapseSource, MapBounds, Totals } from './types.js';
4
+
5
+ export interface DuckDbSourceOptions {
6
+ /** URL of the day's parquet: day_second INT (seconds since the day's
7
+ * midnight, sorted), a value column, lat/lon FLOAT. ~60M rows is fine. */
8
+ parquetUrl: string;
9
+ /**
10
+ * Where the duckdb-wasm engine files come from. Omitted: version-matched
11
+ * jsDelivr URLs (zero config, external CDN). A string: the base URL you
12
+ * serve `@duckdb/duckdb-wasm/dist` under — serve the copy THIS package
13
+ * resolves, so the wasm matches the JS. An object: full bundle control.
14
+ */
15
+ engine?: string | duckdb.DuckDBBundles;
16
+ /** Name of the parquet column summed into totals().value. */
17
+ valueColumn?: string;
18
+ }
19
+
20
+ function resolveBundles(engine: DuckDbSourceOptions['engine']): duckdb.DuckDBBundles {
21
+ if (!engine) return duckdb.getJsDelivrBundles();
22
+ if (typeof engine === 'string') {
23
+ const base = engine.replace(/\/$/, '');
24
+ return {
25
+ mvp: {
26
+ mainModule: `${base}/duckdb-mvp.wasm`,
27
+ mainWorker: `${base}/duckdb-browser-mvp.worker.js`,
28
+ },
29
+ eh: {
30
+ mainModule: `${base}/duckdb-eh.wasm`,
31
+ mainWorker: `${base}/duckdb-browser-eh.worker.js`,
32
+ },
33
+ };
34
+ }
35
+ return engine;
36
+ }
37
+
38
+ /**
39
+ * A GeoTimelapseSource backed by duckdb-wasm: the parquet is downloaded once
40
+ * and loaded into an in-browser table, then every read is a local query.
41
+ * Each source owns its own engine instance (worker), so two components never
42
+ * share state — at the price of one table copy per instance.
43
+ */
44
+ export function createDuckDbSource({
45
+ parquetUrl,
46
+ engine,
47
+ valueColumn = 'value',
48
+ }: DuckDbSourceOptions): GeoTimelapseSource {
49
+ let dbPromise: Promise<duckdb.AsyncDuckDB> | null = null;
50
+ let connPromise: Promise<duckdb.AsyncDuckDBConnection> | null = null;
51
+ let loadPromise: Promise<void> | null = null;
52
+ // Mutable slot so a load() call after the first (e.g. a React StrictMode
53
+ // remount) still gets progress reports from the single in-flight download.
54
+ let progressListener: ((loadedBytes: number, totalBytes: number) => void) | undefined;
55
+ let scopeFilter = 'TRUE';
56
+ let requestedFilter = 'TRUE';
57
+ let scopeChain: Promise<void> = Promise.resolve();
58
+
59
+ const getDB = () =>
60
+ (dbPromise ??= (async () => {
61
+ const bundle = await duckdb.selectBundle(resolveBundles(engine));
62
+ const workerUrl = bundle.mainWorker!;
63
+ // A same-origin worker loads directly (and its requests carry the
64
+ // page's cookies); cross-origin URLs (the jsDelivr default) need
65
+ // createWorker's same-origin blob wrapper or the browser blocks them.
66
+ const sameOrigin = new URL(workerUrl, globalThis.location.href).origin === globalThis.location.origin;
67
+ const worker = sameOrigin ? new Worker(workerUrl) : await duckdb.createWorker(workerUrl);
68
+ const db = new duckdb.AsyncDuckDB(new duckdb.ConsoleLogger(duckdb.LogLevel.WARNING), worker);
69
+ await db.instantiate(bundle.mainModule);
70
+ return db;
71
+ })());
72
+
73
+ const getConn = () => (connPromise ??= getDB().then((db) => db.connect()));
74
+
75
+ // Running totals per second, so counters read one row per tick.
76
+ const CREATE_TOTALS = `
77
+ CREATE OR REPLACE TABLE totals_by_second AS
78
+ SELECT day_second,
79
+ sum(events) OVER w AS events,
80
+ sum(value) OVER w AS value
81
+ FROM scope_seconds
82
+ WINDOW w AS (ORDER BY day_second)
83
+ `;
84
+
85
+ const load = (onProgress?: (loadedBytes: number, totalBytes: number) => void) => {
86
+ if (onProgress) progressListener = onProgress;
87
+ return (loadPromise ??= (async () => {
88
+ const response = await fetch(parquetUrl);
89
+ if (!response.ok || !response.body) throw new Error(`parquet download failed (${response.status})`);
90
+ const totalBytes = Number(response.headers.get('content-length') ?? 0);
91
+ const chunks: Uint8Array[] = [];
92
+ let loadedBytes = 0;
93
+ let lastReported = 0;
94
+ const reader = response.body.getReader();
95
+ for (;;) {
96
+ const { done, value } = await reader.read();
97
+ if (done) break;
98
+ chunks.push(value);
99
+ loadedBytes += value.byteLength;
100
+ if (loadedBytes - lastReported >= 8 * 1024 * 1024) {
101
+ lastReported = loadedBytes;
102
+ progressListener?.(loadedBytes, totalBytes);
103
+ }
104
+ }
105
+ const buffer = new Uint8Array(loadedBytes);
106
+ let offset = 0;
107
+ for (const chunk of chunks) {
108
+ buffer.set(chunk, offset);
109
+ offset += chunk.byteLength;
110
+ }
111
+ progressListener?.(loadedBytes, totalBytes);
112
+
113
+ const db = await getDB();
114
+ await db.registerFileBuffer('replay.parquet', buffer);
115
+ const conn = await getConn();
116
+ // The value column is normalized at ingest so every later query is
117
+ // schema-agnostic. The source order (sorted by day_second) is what
118
+ // gives the in-memory table its zonemap pruning.
119
+ await conn.query(`
120
+ CREATE OR REPLACE TABLE events AS
121
+ SELECT day_second, ${valueColumn} AS value, lat, lon
122
+ FROM read_parquet('replay.parquet')
123
+ `);
124
+ // Per-second counts within the current scope, the single base both
125
+ // aggregates derive from — so consumers never rescan the raw rows.
126
+ await conn.query(`
127
+ CREATE OR REPLACE TABLE scope_seconds AS
128
+ SELECT day_second, count(*) AS events, sum(value) AS value
129
+ FROM events
130
+ GROUP BY day_second
131
+ `);
132
+ await conn.query(CREATE_TOTALS);
133
+ await db.dropFile('replay.parquet');
134
+ })());
135
+ };
136
+
137
+ const frame = async (fromSecond: number, toSecond: number): Promise<FramePoints> => {
138
+ const conn = await getConn();
139
+ // Integer bounds keep the comparison on the INTEGER column: a double
140
+ // literal would disable zonemap pruning and full-scan the table.
141
+ const result = await conn.query(`
142
+ SELECT lon, lat, count(*)::DOUBLE AS weight FROM events
143
+ WHERE day_second >= ${Math.floor(fromSecond)} AND day_second < ${Math.floor(toSecond)}
144
+ AND lat IS NOT NULL AND lon IS NOT NULL
145
+ GROUP BY lon, lat
146
+ `);
147
+ const lon = result.getChild('lon')!.toArray() as Float32Array;
148
+ const lat = result.getChild('lat')!.toArray() as Float32Array;
149
+ const weight = result.getChild('weight')!.toArray() as Float64Array;
150
+ const positions = new Float32Array(lon.length * 2);
151
+ const weights = new Float32Array(lon.length);
152
+ for (let i = 0; i < lon.length; i++) {
153
+ positions[2 * i] = lon[i];
154
+ positions[2 * i + 1] = lat[i];
155
+ weights[i] = weight[i];
156
+ }
157
+ return { positions, weights, count: lon.length };
158
+ };
159
+
160
+ const totals = async (second: number): Promise<Totals> => {
161
+ const conn = await getConn();
162
+ const result = await conn.query(`
163
+ SELECT events::DOUBLE AS events, value::DOUBLE AS value
164
+ FROM totals_by_second
165
+ WHERE day_second < ${Math.floor(second)}
166
+ ORDER BY day_second DESC
167
+ LIMIT 1
168
+ `);
169
+ const row = result.numRows > 0 ? result.get(0) : null;
170
+ return { count: Number(row?.events ?? 0), value: Number(row?.value ?? 0) };
171
+ };
172
+
173
+ const activity = async (): Promise<Float32Array> => {
174
+ const conn = await getConn();
175
+ const result = await conn.query(`
176
+ SELECT (day_second // 60)::INT AS minute, sum(events)::DOUBLE AS events
177
+ FROM scope_seconds
178
+ GROUP BY minute
179
+ `);
180
+ const minutes = result.getChild('minute')!.toArray() as Int32Array;
181
+ const events = result.getChild('events')!.toArray() as Float64Array;
182
+ const counts = new Float32Array(24 * 60);
183
+ for (let i = 0; i < minutes.length; i++) counts[minutes[i]] = events[i];
184
+ return counts;
185
+ };
186
+
187
+ // The scan over the raw rows is chunked hour by hour (pruned on
188
+ // day_second) so playback tick queries interleave instead of queueing
189
+ // behind one long statement.
190
+ const setScope = (bounds: MapBounds | null): Promise<void> => {
191
+ const filter = bounds
192
+ ? `lat BETWEEN ${bounds.south} AND ${bounds.north} AND lon BETWEEN ${bounds.west} AND ${bounds.east}`
193
+ : 'TRUE';
194
+ requestedFilter = filter;
195
+ scopeChain = scopeChain
196
+ .then(async () => {
197
+ // Superseded while queued, or already active.
198
+ if (requestedFilter !== filter || filter === scopeFilter) return;
199
+ // The rebuild reads the events table: wait for the day to be loaded.
200
+ await load();
201
+ const conn = await getConn();
202
+ await conn.query(`
203
+ CREATE OR REPLACE TABLE scope_seconds_build AS
204
+ SELECT day_second, count(*) AS events, sum(value) AS value
205
+ FROM events WHERE false GROUP BY day_second
206
+ `);
207
+ for (let hour = 0; hour < 24; hour++) {
208
+ if (requestedFilter !== filter) return;
209
+ await conn.query(`
210
+ INSERT INTO scope_seconds_build
211
+ SELECT day_second, count(*), sum(value)
212
+ FROM events
213
+ WHERE day_second >= ${hour * 3600} AND day_second < ${(hour + 1) * 3600} AND ${filter}
214
+ GROUP BY day_second
215
+ `);
216
+ }
217
+ await conn.query(`CREATE OR REPLACE TABLE scope_seconds AS FROM scope_seconds_build`);
218
+ await conn.query(`DROP TABLE scope_seconds_build`);
219
+ await conn.query(CREATE_TOTALS);
220
+ scopeFilter = filter;
221
+ })
222
+ // A failed rebuild keeps the previous scope; never poison the chain.
223
+ .catch((error: unknown) => console.error('geotimelapse scope rebuild failed', error));
224
+ return scopeChain;
225
+ };
226
+
227
+ const dispose = async () => {
228
+ const conn = connPromise ? await connPromise.catch(() => null) : null;
229
+ connPromise = null;
230
+ await conn?.close().catch(() => {});
231
+ const db = dbPromise ? await dbPromise.catch(() => null) : null;
232
+ dbPromise = null;
233
+ loadPromise = null;
234
+ await db?.terminate().catch(() => {});
235
+ };
236
+
237
+ return { load, frame, totals, activity, setScope, dispose };
238
+ }