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
package/src/map.tsx ADDED
@@ -0,0 +1,107 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
4
+ import type { DeckGLRef } from 'deck.gl';
5
+ import DeckGL, { WebMercatorViewport } from 'deck.gl';
6
+ import type { MapRef } from 'react-map-gl/mapbox';
7
+ import Map from 'react-map-gl/mapbox';
8
+
9
+ import type { GlowTuning } from './glow-layer.js';
10
+ import { buildGlowLayers, DEFAULT_GLOW_TUNING } from './glow-layer.js';
11
+ import type { FrameBids } from './hooks.js';
12
+ import { mapStyle } from './map-style.js';
13
+ import type { MapBounds } from './types.js';
14
+
15
+ interface ViewState {
16
+ longitude: number;
17
+ latitude: number;
18
+ zoom: number;
19
+ pitch: number;
20
+ bearing: number;
21
+ }
22
+
23
+ const CONTINENTAL_US_VIEW: ViewState = {
24
+ longitude: -98.5,
25
+ latitude: 38.8,
26
+ zoom: 4,
27
+ pitch: 0,
28
+ bearing: 0,
29
+ };
30
+
31
+ export default function TimelapseMap({
32
+ frames,
33
+ mapboxAccessToken,
34
+ initialBounds,
35
+ tuning,
36
+ onBoundsChange,
37
+ }: {
38
+ frames: FrameBids[];
39
+ mapboxAccessToken: string;
40
+ initialBounds?: MapBounds;
41
+ tuning?: Partial<GlowTuning>;
42
+ onBoundsChange: (bounds: MapBounds) => void;
43
+ }) {
44
+ const deckRef = useRef<DeckGLRef>(null);
45
+ const mapRef = useRef<MapRef>(null);
46
+ const containerRef = useRef<HTMLDivElement>(null);
47
+ // Fitting bounds needs the container size, unknown before the first layout;
48
+ // the map mounts one frame later, invisible on the black stage.
49
+ const [initialViewState, setInitialViewState] = useState<ViewState | null>(
50
+ initialBounds ? null : CONTINENTAL_US_VIEW,
51
+ );
52
+
53
+ useLayoutEffect(() => {
54
+ if (!initialBounds || initialViewState) return;
55
+ const container = containerRef.current;
56
+ if (!container || container.clientWidth === 0 || container.clientHeight === 0) return;
57
+ const { longitude, latitude, zoom } = new WebMercatorViewport({
58
+ width: container.clientWidth,
59
+ height: container.clientHeight,
60
+ }).fitBounds([
61
+ [initialBounds.west, initialBounds.south],
62
+ [initialBounds.east, initialBounds.north],
63
+ ]);
64
+ setInitialViewState({ longitude, latitude, zoom, pitch: 0, bearing: 0 });
65
+ }, [initialBounds, initialViewState]);
66
+
67
+ // Mapbox misses the container resize on fullscreen toggles (deck redraws,
68
+ // the basemap stays stale); poke it once the new dimensions have settled.
69
+ useEffect(() => {
70
+ const onFullscreenChange = () => {
71
+ requestAnimationFrame(() => mapRef.current?.resize());
72
+ setTimeout(() => mapRef.current?.resize(), 350);
73
+ };
74
+ document.addEventListener('fullscreenchange', onFullscreenChange);
75
+ return () => document.removeEventListener('fullscreenchange', onFullscreenChange);
76
+ }, []);
77
+
78
+ // Read from the deck viewport (not the event's view state) so the numbers
79
+ // include the canvas dimensions; one frame of lag is fine for consumers.
80
+ const reportBounds = () => {
81
+ const viewport = deckRef.current?.deck?.getViewports()[0];
82
+ if (!viewport) return;
83
+ const [west, south, east, north] = viewport.getBounds();
84
+ const round = (value: number) => Math.round(value * 1000) / 1000;
85
+ onBoundsChange({ west: round(west), south: round(south), east: round(east), north: round(north) });
86
+ };
87
+
88
+ const layers = buildGlowLayers(frames, { ...DEFAULT_GLOW_TUNING, ...tuning });
89
+
90
+ return (
91
+ <div ref={containerRef} className="absolute inset-0">
92
+ {initialViewState && (
93
+ <DeckGL
94
+ ref={deckRef}
95
+ initialViewState={initialViewState}
96
+ controller
97
+ layers={layers}
98
+ onLoad={reportBounds}
99
+ onViewStateChange={reportBounds}
100
+ onResize={reportBounds}
101
+ >
102
+ <Map ref={mapRef} mapboxAccessToken={mapboxAccessToken} mapStyle={mapStyle} />
103
+ </DeckGL>
104
+ )}
105
+ </div>
106
+ );
107
+ }
@@ -0,0 +1,164 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+
5
+ import { DAY_SECONDS, useDaySeconds, useTimelapseClock } from './clock.js';
6
+ import { formatDayTime, formatDayTime24, formatHourLabel } from './utils.js';
7
+
8
+ const SEEK_STEP = 60;
9
+ const BAR_PITCH_PX = 1;
10
+
11
+ const PLAYED_ALPHA = 0.55;
12
+ const CURRENT_ALPHA = 1;
13
+ const FUTURE_ALPHA = 0.22;
14
+
15
+ // The activity plot is the seek bar: event volume per minute drawn as 1px
16
+ // bars, played minutes brighter than upcoming ones, current one highlighted.
17
+ function ActivitySeekBar({ activity }: { activity: Float32Array | null }) {
18
+ const clock = useTimelapseClock();
19
+ const daySeconds = useDaySeconds();
20
+ const canvasRef = useRef<HTMLCanvasElement>(null);
21
+
22
+ useEffect(() => {
23
+ const canvas = canvasRef.current;
24
+ if (!canvas) return;
25
+ const draw = () => {
26
+ const { width, height } = canvas.getBoundingClientRect();
27
+ const scale = window.devicePixelRatio || 1;
28
+ canvas.width = width * scale;
29
+ canvas.height = height * scale;
30
+ const ctx = canvas.getContext('2d');
31
+ if (!ctx) return;
32
+ ctx.scale(scale, scale);
33
+ const fraction = daySeconds / DAY_SECONDS;
34
+ if (activity) {
35
+ const bucketCount = Math.max(1, Math.floor(width / BAR_PITCH_PX));
36
+ // Average per minute, so a partial last bucket doesn't read as a dip.
37
+ const sums = new Float32Array(bucketCount);
38
+ const minutes = new Float32Array(bucketCount);
39
+ for (let minute = 0; minute < activity.length; minute++) {
40
+ const bucket = Math.min(Math.floor((minute / activity.length) * bucketCount), bucketCount - 1);
41
+ sums[bucket] += activity[minute];
42
+ minutes[bucket] += 1;
43
+ }
44
+ let max = 1;
45
+ for (let bucket = 0; bucket < bucketCount; bucket++) {
46
+ sums[bucket] /= Math.max(minutes[bucket], 1);
47
+ max = Math.max(max, sums[bucket]);
48
+ }
49
+ const pitch = width / bucketCount;
50
+ const barWidth = Math.max(pitch - 1, 1);
51
+ const currentBucket = Math.min(Math.floor(fraction * bucketCount), bucketCount - 1);
52
+ for (let bucket = 0; bucket < bucketCount; bucket++) {
53
+ const alpha = bucket < currentBucket ? PLAYED_ALPHA : bucket === currentBucket ? CURRENT_ALPHA : FUTURE_ALPHA;
54
+ ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`;
55
+ const barHeight = (sums[bucket] / max) * height;
56
+ ctx.fillRect(bucket * pitch, height - barHeight, barWidth, barHeight);
57
+ }
58
+ }
59
+ // Full-height playhead at the exact time, finer than the minute bars.
60
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
61
+ ctx.fillRect(fraction * (width - 1), 0, 1, height);
62
+ };
63
+ draw();
64
+ const observer = new ResizeObserver(draw);
65
+ observer.observe(canvas);
66
+ return () => observer.disconnect();
67
+ }, [activity, daySeconds]);
68
+
69
+ const seekFromPointer = (event: React.PointerEvent<HTMLCanvasElement>) => {
70
+ const rect = event.currentTarget.getBoundingClientRect();
71
+ const fraction = Math.min(Math.max((event.clientX - rect.left) / rect.width, 0), 1);
72
+ clock.seek(Math.round((fraction * DAY_SECONDS) / SEEK_STEP) * SEEK_STEP);
73
+ };
74
+
75
+ const seekBy = (offset: number) => {
76
+ clock.seek(Math.min(Math.max(clock.getDaySeconds() + offset, 0), DAY_SECONDS));
77
+ };
78
+
79
+ return (
80
+ <canvas
81
+ ref={canvasRef}
82
+ role="slider"
83
+ aria-label="Time of day"
84
+ aria-valuemin={0}
85
+ aria-valuemax={DAY_SECONDS}
86
+ aria-valuenow={daySeconds}
87
+ aria-valuetext={formatDayTime(daySeconds)}
88
+ tabIndex={0}
89
+ className="h-8 w-full cursor-pointer select-none focus-visible:outline-1 focus-visible:outline-white/60"
90
+ onPointerDown={(event) => {
91
+ seekFromPointer(event);
92
+ // Capture keeps the drag scrubbing outside the canvas bounds; a
93
+ // failed capture must never block the seek itself.
94
+ try {
95
+ event.currentTarget.setPointerCapture(event.pointerId);
96
+ } catch {
97
+ // no active pointer (synthetic events, stale pointer id)
98
+ }
99
+ }}
100
+ onPointerMove={(event) => {
101
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) seekFromPointer(event);
102
+ }}
103
+ onKeyDown={(event) => {
104
+ const step = event.shiftKey ? 10 * SEEK_STEP : SEEK_STEP;
105
+ if (event.key === 'ArrowLeft') seekBy(-step);
106
+ else if (event.key === 'ArrowRight') seekBy(step);
107
+ else if (event.key === 'PageDown') seekBy(-10 * SEEK_STEP);
108
+ else if (event.key === 'PageUp') seekBy(10 * SEEK_STEP);
109
+ else if (event.key === 'Home') clock.seek(0);
110
+ else if (event.key === 'End') clock.seek(DAY_SECONDS);
111
+ else return;
112
+ event.preventDefault();
113
+ }}
114
+ />
115
+ );
116
+ }
117
+
118
+ // Hour axis under the seek bar: a tick per hour, a label every two.
119
+ function HourAxis() {
120
+ return (
121
+ <div className="relative mt-0.5 h-3 font-mono text-[9px] text-white/40 tabular-nums">
122
+ {Array.from({ length: 25 }, (_, hour) => (
123
+ <div key={hour} className="absolute top-0" style={{ left: `${(hour / 24) * 100}%` }}>
124
+ <div className="h-1 w-px bg-white/30" />
125
+ {hour % 2 === 0 && (
126
+ <div className={hour === 0 ? '' : hour === 24 ? '-translate-x-full' : '-translate-x-1/2'}>
127
+ {formatHourLabel(hour)}
128
+ </div>
129
+ )}
130
+ </div>
131
+ ))}
132
+ </div>
133
+ );
134
+ }
135
+
136
+ export default function PlayerBar({ activity }: { activity: Float32Array | null }) {
137
+ const [hover, setHover] = useState<{ x: number; label: string } | null>(null);
138
+
139
+ return (
140
+ <div className="absolute inset-x-4 bottom-4 flex items-center gap-4 rounded-lg bg-black/60 px-4 py-3 text-white backdrop-blur-sm">
141
+ <div
142
+ className="relative min-w-0 flex-1"
143
+ onPointerMove={(event) => {
144
+ const rect = event.currentTarget.getBoundingClientRect();
145
+ const fraction = Math.min(Math.max((event.clientX - rect.left) / rect.width, 0), 1);
146
+ const minuteSeconds = Math.floor((fraction * DAY_SECONDS) / 60) * 60;
147
+ setHover({ x: event.clientX - rect.left, label: formatDayTime24(minuteSeconds) });
148
+ }}
149
+ onPointerLeave={() => setHover(null)}
150
+ >
151
+ {hover && (
152
+ <div
153
+ className="pointer-events-none absolute bottom-full mb-2 -translate-x-1/2 rounded-sm bg-black/80 px-1.5 py-0.5 font-mono text-[10px] tabular-nums"
154
+ style={{ left: hover.x }}
155
+ >
156
+ {hover.label}
157
+ </div>
158
+ )}
159
+ <ActivitySeekBar activity={activity} />
160
+ <HourAxis />
161
+ </div>
162
+ </div>
163
+ );
164
+ }
@@ -0,0 +1,117 @@
1
+ 'use client';
2
+
3
+ import type { ComponentType, SVGProps } from 'react';
4
+ import { useState } from 'react';
5
+ import {
6
+ ArrowPathIcon,
7
+ ArrowsPointingInIcon,
8
+ ArrowsPointingOutIcon,
9
+ Cog6ToothIcon,
10
+ PauseIcon,
11
+ PlayIcon,
12
+ ViewfinderCircleIcon,
13
+ } from '@heroicons/react/20/solid';
14
+
15
+ import { useMapBounds } from './bounds.js';
16
+ import { useIsPlaying, useTimelapseClock } from './clock.js';
17
+ import type { FrameBids } from './hooks.js';
18
+ import { FRAME_HISTORY } from './hooks.js';
19
+
20
+ interface RowProps {
21
+ icon: ComponentType<SVGProps<SVGSVGElement>>;
22
+ label: string;
23
+ hint?: string;
24
+ state?: boolean;
25
+ onClick: () => void;
26
+ }
27
+
28
+ function Row({ icon: Icon, label, hint, state, onClick }: RowProps) {
29
+ return (
30
+ <button
31
+ type="button"
32
+ onClick={onClick}
33
+ className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-white/10"
34
+ >
35
+ <Icon className="size-4 shrink-0" />
36
+ <span className="flex-1">
37
+ {label}
38
+ {hint && <span className="text-white/50"> {hint}</span>}
39
+ </span>
40
+ {state !== undefined && <span className={state ? '' : 'text-white/40'}>{state ? 'on' : 'off'}</span>}
41
+ </button>
42
+ );
43
+ }
44
+
45
+ interface SettingsMenuProps {
46
+ visible: boolean;
47
+ fullscreen: boolean;
48
+ onToggleFullscreen: () => void;
49
+ loop: boolean;
50
+ onToggleLoop: () => void;
51
+ scoped: boolean;
52
+ onToggleScoped: () => void;
53
+ frame?: FrameBids;
54
+ fps: number;
55
+ /** Ghost frames currently rendered by the adaptive trail. */
56
+ trailFrames: number;
57
+ }
58
+
59
+ export default function SettingsMenu({
60
+ visible,
61
+ fullscreen,
62
+ onToggleFullscreen,
63
+ loop,
64
+ onToggleLoop,
65
+ scoped,
66
+ onToggleScoped,
67
+ frame,
68
+ fps,
69
+ trailFrames,
70
+ }: SettingsMenuProps) {
71
+ const clock = useTimelapseClock();
72
+ const playing = useIsPlaying();
73
+ const bounds = useMapBounds();
74
+ const [open, setOpen] = useState(false);
75
+
76
+ return (
77
+ <div
78
+ className={`absolute top-4 left-4 font-mono text-xs text-white transition-opacity duration-250 ${
79
+ visible ? 'opacity-100' : 'pointer-events-none opacity-0'
80
+ }`}
81
+ >
82
+ <button
83
+ type="button"
84
+ aria-label="Settings"
85
+ onClick={() => setOpen((wasOpen) => !wasOpen)}
86
+ className="cursor-pointer rounded-lg bg-black/60 p-2 opacity-80 backdrop-blur-sm hover:opacity-100"
87
+ >
88
+ <Cog6ToothIcon className="size-5" />
89
+ </button>
90
+ {open && (
91
+ <div className="mt-2 w-64 rounded-lg bg-black/60 p-2 backdrop-blur-sm">
92
+ <Row
93
+ icon={fullscreen ? ArrowsPointingInIcon : ArrowsPointingOutIcon}
94
+ label={fullscreen ? 'Exit full screen' : 'Full screen'}
95
+ hint="([Esc] to quit)"
96
+ onClick={onToggleFullscreen}
97
+ />
98
+ <Row icon={playing ? PauseIcon : PlayIcon} label={playing ? 'Pause' : 'Play'} onClick={clock.toggle} />
99
+ <Row icon={ArrowPathIcon} label="Loop" state={loop} onClick={onToggleLoop} />
100
+ <Row icon={ViewfinderCircleIcon} label="Scope data to view" state={scoped} onClick={onToggleScoped} />
101
+ <div className="mt-2 space-y-1 border-t border-white/10 px-2 pt-2 text-white/50 tabular-nums">
102
+ {bounds && (
103
+ <div>
104
+ {bounds.west.toFixed(1)}, {bounds.south.toFixed(1)} → {bounds.east.toFixed(1)},{' '}
105
+ {bounds.north.toFixed(1)}
106
+ </div>
107
+ )}
108
+ <div>
109
+ {fps} fps · {trailFrames}/{FRAME_HISTORY} ghosts
110
+ {frame ? ` · ${frame.count.toLocaleString('en-US')} pts · ${frame.queryMs} ms` : ''}
111
+ </div>
112
+ </div>
113
+ </div>
114
+ )}
115
+ </div>
116
+ );
117
+ }
@@ -0,0 +1,58 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+
4
+ import { createSyntheticSource } from './synthetic-source.js';
5
+
6
+ test('a seed fully determines the day', async () => {
7
+ const first = createSyntheticSource({ total: 5_000, seed: 7 });
8
+ const second = createSyntheticSource({ total: 5_000, seed: 7 });
9
+ await first.load();
10
+ await second.load();
11
+ const [frameA, frameB] = await Promise.all([first.frame(0, 86_400), second.frame(0, 86_400)]);
12
+ assert.deepEqual(frameA.positions, frameB.positions);
13
+ assert.deepEqual(frameA.weights, frameB.weights);
14
+ await first.dispose();
15
+ await second.dispose();
16
+ });
17
+
18
+ test('totals accumulate to the requested volume', async () => {
19
+ const source = createSyntheticSource({ total: 5_000, seed: 42 });
20
+ await source.load();
21
+ assert.deepEqual(await source.totals(0), { count: 0, value: 0 });
22
+ assert.deepEqual(await source.totals(86_400), { count: 5_000, value: 5_000 });
23
+ await source.dispose();
24
+ });
25
+
26
+ test('the day mixes clusters, fog and geometric point stacks', async () => {
27
+ const source = createSyntheticSource({ total: 10_000, seed: 42 });
28
+ await source.load();
29
+ const day = await source.frame(0, 86_400);
30
+ const weightSum = Array.from(day.weights).reduce((sum, weight) => sum + weight, 0);
31
+ assert.equal(weightSum, 10_000);
32
+ const stacks = Array.from(day.weights)
33
+ .filter((weight) => weight > 1)
34
+ .sort((a, b) => b - a);
35
+ assert.equal(stacks.length, 5);
36
+ for (let i = 1; i < stacks.length; i++) {
37
+ const ratio = stacks[i - 1] / stacks[i];
38
+ assert.ok(ratio > 1.4 && ratio < 2.8, `stack ratio ${ratio}`);
39
+ }
40
+ await source.dispose();
41
+ });
42
+
43
+ test('setScope filters the reads and null restores them', async () => {
44
+ const source = createSyntheticSource({ total: 5_000, seed: 42 });
45
+ await source.load();
46
+ const world = await source.totals(86_400);
47
+
48
+ await source.setScope({ west: -100, south: 24.5, east: -66.9, north: 49.4 });
49
+ const east = await source.totals(86_400);
50
+ assert.ok(east.count > 0 && east.count < world.count);
51
+ const activity = await source.activity();
52
+ const activitySum = Array.from(activity).reduce((sum, count) => sum + count, 0);
53
+ assert.equal(activitySum, east.count);
54
+
55
+ await source.setScope(null);
56
+ assert.deepEqual(await source.totals(86_400), world);
57
+ await source.dispose();
58
+ });
@@ -0,0 +1,218 @@
1
+ import type { FramePoints, GeoTimelapseSource, MapBounds, Totals } from './types.js';
2
+
3
+ export interface SyntheticSourceOptions {
4
+ /** Total events over the day. */
5
+ total: number;
6
+ /** Area events land in; defaults to the continental US. */
7
+ bounds?: MapBounds;
8
+ clusterCount?: number;
9
+ /** Same seed, same day — handy to compare tunings. */
10
+ seed?: number;
11
+ }
12
+
13
+ // Every dataset mixes the three spatial regimes the renderer must handle:
14
+ // gaussian clusters, a diffuse background, and exact-spot stacks of
15
+ // geometrically increasing size (to exercise the weight-driven brightness).
16
+ const UNIFORM_SHARE = 0.25;
17
+ const POINT_SHARE = 0.05;
18
+ const POINT_STACKS = 5;
19
+
20
+ const CONTINENTAL_US: MapBounds = { west: -124.7, south: 24.5, east: -66.9, north: 49.4 };
21
+ const MINUTES = 24 * 60;
22
+
23
+ // Deterministic PRNG (mulberry32).
24
+ function makeRng(seed: number): () => number {
25
+ let state = seed >>> 0;
26
+ return () => {
27
+ state = (state + 0x6d2b79f5) | 0;
28
+ let t = Math.imul(state ^ (state >>> 15), 1 | state);
29
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
30
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
31
+ };
32
+ }
33
+
34
+ // Quiet night, morning bump, evening prime-time peak.
35
+ function diurnalWeight(minute: number): number {
36
+ const hour = minute / 60;
37
+ const morning = Math.exp(-((hour - 8.5) ** 2) / (2 * 2.2 ** 2)) * 0.6;
38
+ const evening = Math.exp(-((hour - 20.5) ** 2) / (2 * 2.8 ** 2)) * 1.4;
39
+ return 0.25 + morning + evening;
40
+ }
41
+
42
+ function lowerBound(array: Uint32Array, value: number): number {
43
+ let low = 0;
44
+ let high = array.length;
45
+ while (low < high) {
46
+ const mid = (low + high) >>> 1;
47
+ if (array[mid] < value) low = mid + 1;
48
+ else high = mid;
49
+ }
50
+ return low;
51
+ }
52
+
53
+ /**
54
+ * A GeoTimelapseSource generating a deterministic synthetic day in memory —
55
+ * no engine, no network. Made for benches, demos and tests: pick a volume and
56
+ * the component runs on it like on real data.
57
+ * Each event has value 1, so totals().value mirrors the event count.
58
+ */
59
+ export function createSyntheticSource({
60
+ total,
61
+ bounds = CONTINENTAL_US,
62
+ clusterCount = 12,
63
+ seed = 42,
64
+ }: SyntheticSourceOptions): GeoTimelapseSource {
65
+ let loadPromise: Promise<void> | null = null;
66
+ let seconds = new Uint32Array(0);
67
+ let lons = new Float32Array(0);
68
+ let lats = new Float32Array(0);
69
+ let minuteCounts = new Float32Array(MINUTES);
70
+ let scopedSeconds: Uint32Array | null = null;
71
+ let scopedMinuteCounts: Float32Array | null = null;
72
+
73
+ const load = (onProgress?: (loaded: number, totalBytes: number) => void) =>
74
+ (loadPromise ??= (async () => {
75
+ const rng = makeRng(seed);
76
+
77
+ // Spread the total over minutes following the diurnal curve.
78
+ const weights = Array.from({ length: MINUTES }, (_, minute) => diurnalWeight(minute));
79
+ const weightSum = weights.reduce((a, b) => a + b, 0);
80
+ const counts = weights.map((w) => Math.floor((total * w) / weightSum));
81
+ let remainder = total - counts.reduce((a, b) => a + b, 0);
82
+ for (let minute = 0; remainder > 0; minute = (minute + 7) % MINUTES) {
83
+ counts[minute] += 1;
84
+ remainder -= 1;
85
+ }
86
+
87
+ seconds = new Uint32Array(total);
88
+ lons = new Float32Array(total);
89
+ lats = new Float32Array(total);
90
+
91
+ const width = bounds.east - bounds.west;
92
+ const height = bounds.north - bounds.south;
93
+ const hotspots = Array.from({ length: POINT_STACKS }, (_, i) => ({
94
+ lon: bounds.west + width * (0.2 + 0.6 * rng()),
95
+ lat: bounds.south + height * (0.2 + 0.6 * rng()),
96
+ pull: 2 ** i,
97
+ }));
98
+ const hotspotPullSum = hotspots.reduce((a, h) => a + h.pull, 0);
99
+ const centers = Array.from({ length: clusterCount }, () => ({
100
+ lon: bounds.west + width * (0.1 + 0.8 * rng()),
101
+ lat: bounds.south + height * (0.1 + 0.8 * rng()),
102
+ pull: 0.3 + rng(),
103
+ }));
104
+ const pullSum = centers.reduce((a, c) => a + c.pull, 0);
105
+ const sigma = Math.min(width, height) / 40;
106
+ // Box-Muller, one gaussian pair per call.
107
+ const gaussian = () => {
108
+ const radius = Math.sqrt(-2 * Math.log(1 - rng()));
109
+ const angle = 2 * Math.PI * rng();
110
+ return [radius * Math.cos(angle), radius * Math.sin(angle)];
111
+ };
112
+
113
+ let index = 0;
114
+ for (let minute = 0; minute < MINUTES; minute++) {
115
+ const count = counts[minute];
116
+ minuteCounts[minute] = count;
117
+ for (let i = 0; i < count; i++, index++) {
118
+ seconds[index] = minute * 60 + Math.min(Math.floor((i + rng()) * (60 / Math.max(count, 1))), 59);
119
+ const roll = rng();
120
+ if (roll < POINT_SHARE) {
121
+ let pick = rng() * hotspotPullSum;
122
+ let hotspot = hotspots[0];
123
+ for (const candidate of hotspots) {
124
+ pick -= candidate.pull;
125
+ if (pick <= 0) {
126
+ hotspot = candidate;
127
+ break;
128
+ }
129
+ }
130
+ lons[index] = hotspot.lon;
131
+ lats[index] = hotspot.lat;
132
+ } else if (roll < POINT_SHARE + UNIFORM_SHARE) {
133
+ lons[index] = bounds.west + width * rng();
134
+ lats[index] = bounds.south + height * rng();
135
+ } else {
136
+ let pick = rng() * pullSum;
137
+ let center = centers[0];
138
+ for (const candidate of centers) {
139
+ pick -= candidate.pull;
140
+ if (pick <= 0) {
141
+ center = candidate;
142
+ break;
143
+ }
144
+ }
145
+ const [dx, dy] = gaussian();
146
+ lons[index] = center.lon + dx * sigma;
147
+ lats[index] = center.lat + dy * sigma;
148
+ }
149
+ }
150
+ if (minute % 288 === 0) onProgress?.(index, total);
151
+ }
152
+ onProgress?.(total, total);
153
+ })());
154
+
155
+ const frame = async (fromSecond: number, toSecond: number): Promise<FramePoints> => {
156
+ const start = lowerBound(seconds, Math.floor(fromSecond));
157
+ const end = lowerBound(seconds, Math.floor(toSecond));
158
+ const byLocation = new Map<string, { lon: number; lat: number; weight: number }>();
159
+ for (let i = start; i < end; i++) {
160
+ const key = `${lons[i]},${lats[i]}`;
161
+ const entry = byLocation.get(key);
162
+ if (entry) entry.weight += 1;
163
+ else byLocation.set(key, { lon: lons[i], lat: lats[i], weight: 1 });
164
+ }
165
+ const positions = new Float32Array(byLocation.size * 2);
166
+ const weights = new Float32Array(byLocation.size);
167
+ let i = 0;
168
+ for (const { lon, lat, weight } of byLocation.values()) {
169
+ positions[2 * i] = lon;
170
+ positions[2 * i + 1] = lat;
171
+ weights[i] = weight;
172
+ i += 1;
173
+ }
174
+ return { positions, weights, count: byLocation.size };
175
+ };
176
+
177
+ const totals = async (second: number): Promise<Totals> => {
178
+ const array = scopedSeconds ?? seconds;
179
+ const count = lowerBound(array, Math.floor(second));
180
+ return { count, value: count };
181
+ };
182
+
183
+ const activity = async (): Promise<Float32Array> => Float32Array.from(scopedMinuteCounts ?? minuteCounts);
184
+
185
+ const setScope = async (scope: MapBounds | null): Promise<void> => {
186
+ if (!scope) {
187
+ scopedSeconds = null;
188
+ scopedMinuteCounts = null;
189
+ return;
190
+ }
191
+ const kept = new Uint32Array(seconds.length);
192
+ const minutes = new Float32Array(MINUTES);
193
+ let size = 0;
194
+ for (let i = 0; i < seconds.length; i++) {
195
+ const lon = lons[i];
196
+ const lat = lats[i];
197
+ if (lon >= scope.west && lon <= scope.east && lat >= scope.south && lat <= scope.north) {
198
+ kept[size] = seconds[i];
199
+ size += 1;
200
+ minutes[Math.floor(seconds[i] / 60)] += 1;
201
+ }
202
+ }
203
+ scopedSeconds = kept.subarray(0, size);
204
+ scopedMinuteCounts = minutes;
205
+ };
206
+
207
+ const dispose = async (): Promise<void> => {
208
+ seconds = new Uint32Array(0);
209
+ lons = new Float32Array(0);
210
+ lats = new Float32Array(0);
211
+ minuteCounts = new Float32Array(MINUTES);
212
+ scopedSeconds = null;
213
+ scopedMinuteCounts = null;
214
+ loadPromise = null;
215
+ };
216
+
217
+ return { load, frame, totals, activity, setScope, dispose };
218
+ }