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.
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/dist/bounds.d.ts +5 -0
- package/dist/bounds.d.ts.map +1 -0
- package/dist/bounds.js +9 -0
- package/dist/bounds.js.map +1 -0
- package/dist/clock.d.ts +24 -0
- package/dist/clock.d.ts.map +1 -0
- package/dist/clock.js +108 -0
- package/dist/clock.js.map +1 -0
- package/dist/counter.d.ts +18 -0
- package/dist/counter.d.ts.map +1 -0
- package/dist/counter.js +54 -0
- package/dist/counter.js.map +1 -0
- package/dist/duckdb-source.d.ts +24 -0
- package/dist/duckdb-source.d.ts.map +1 -0
- package/dist/duckdb-source.js +211 -0
- package/dist/duckdb-source.js.map +1 -0
- package/dist/geo-timelapse.d.ts +8 -0
- package/dist/geo-timelapse.d.ts.map +1 -0
- package/dist/geo-timelapse.js +143 -0
- package/dist/geo-timelapse.js.map +1 -0
- package/dist/glow-layer.d.ts +47 -0
- package/dist/glow-layer.d.ts.map +1 -0
- package/dist/glow-layer.js +129 -0
- package/dist/glow-layer.js.map +1 -0
- package/dist/hooks.d.ts +32 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +142 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/map-style.d.ts +3 -0
- package/dist/map-style.d.ts.map +1 -0
- package/dist/map-style.js +832 -0
- package/dist/map-style.js.map +1 -0
- package/dist/map.d.ts +11 -0
- package/dist/map.d.ts.map +1 -0
- package/dist/map.js +60 -0
- package/dist/map.js.map +1 -0
- package/dist/player-bar.d.ts +4 -0
- package/dist/player-bar.d.ts.map +1 -0
- package/dist/player-bar.js +118 -0
- package/dist/player-bar.js.map +1 -0
- package/dist/settings-menu.d.ts +17 -0
- package/dist/settings-menu.d.ts.map +1 -0
- package/dist/settings-menu.js +18 -0
- package/dist/settings-menu.js.map +1 -0
- package/dist/synthetic-source.d.ts +18 -0
- package/dist/synthetic-source.d.ts.map +1 -0
- package/dist/synthetic-source.js +192 -0
- package/dist/synthetic-source.js.map +1 -0
- package/dist/types.d.ts +62 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +9 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +28 -0
- package/dist/utils.js.map +1 -0
- package/package.json +67 -0
- package/src/bounds.tsx +14 -0
- package/src/clock.tsx +130 -0
- package/src/counter.tsx +96 -0
- package/src/duckdb-source.ts +238 -0
- package/src/geo-timelapse.tsx +204 -0
- package/src/glow-layer.test.ts +51 -0
- package/src/glow-layer.ts +166 -0
- package/src/hooks.ts +167 -0
- package/src/index.ts +5 -0
- package/src/map-style.ts +833 -0
- package/src/map.tsx +107 -0
- package/src/player-bar.tsx +164 -0
- package/src/settings-menu.tsx +117 -0
- package/src/synthetic-source.test.ts +58 -0
- package/src/synthetic-source.ts +218 -0
- package/src/types.ts +66 -0
- package/src/utils.test.ts +60 -0
- package/src/utils.ts +32 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
|
package/dist/utils.d.ts
ADDED
|
@@ -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
|
+
}
|
package/src/counter.tsx
ADDED
|
@@ -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
|
+
}
|