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,204 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
+
5
+ import { MapBoundsProvider } from './bounds.js';
6
+ import { DAY_SECONDS, TimelapseClockProvider, useTimelapseClock } from './clock.js';
7
+ import Counter from './counter.js';
8
+ import { useAdaptiveTrailFrames, useFps, useFrameHistory, useMinuteActivity } from './hooks.js';
9
+ import TimelapseMap from './map.js';
10
+ import PlayerBar from './player-bar.js';
11
+ import SettingsMenu from './settings-menu.js';
12
+ import type { GeoTimelapseProps, MapBounds } from './types.js';
13
+
14
+ const defaultFormat = (value: number) => Math.round(value).toLocaleString('en-US');
15
+
16
+ function Stage({
17
+ source,
18
+ mapboxAccessToken,
19
+ initialBounds,
20
+ tuning,
21
+ dateLabel,
22
+ timeZoneLabel,
23
+ formatValue = defaultFormat,
24
+ formatCount = defaultFormat,
25
+ }: GeoTimelapseProps) {
26
+ const clock = useTimelapseClock();
27
+ const stageRef = useRef<HTMLDivElement>(null);
28
+ const [fullscreen, setFullscreen] = useState(false);
29
+ const [loop, setLoop] = useState(true);
30
+ const [scoped, setScoped] = useState(true);
31
+ const [progress, setProgress] = useState<{ loadedBytes: number; totalBytes: number } | null>(null);
32
+ const [ready, setReady] = useState(false);
33
+ const [loadError, setLoadError] = useState<string | null>(null);
34
+ // Bumped after every completed re-scope so readers re-query the source.
35
+ const [scopeVersion, setScopeVersion] = useState(0);
36
+ // Mouse-idle state: the settings gear and the cursor itself fade out so
37
+ // nothing overlays a TV-screen replay; any mouse move brings them back.
38
+ const [uiVisible, setUiVisible] = useState(true);
39
+ const [bounds, setBounds] = useState<MapBounds | null>(null);
40
+
41
+ const frames = useFrameHistory(source, ready);
42
+ const trailFrames = useAdaptiveTrailFrames();
43
+ const fps = useFps();
44
+ const activity = useMinuteActivity(source, ready, scopeVersion);
45
+
46
+ const onBoundsChange = useCallback((next: MapBounds) => {
47
+ setBounds((prev) =>
48
+ prev &&
49
+ prev.west === next.west &&
50
+ prev.south === next.south &&
51
+ prev.east === next.east &&
52
+ prev.north === next.north
53
+ ? prev
54
+ : next,
55
+ );
56
+ }, []);
57
+
58
+ // Load the day once, then start the replay. A swapped source re-gates every
59
+ // reader until its own load resolves.
60
+ useEffect(() => {
61
+ let disposed = false;
62
+ // Re-gating must track the source swap itself, not a render.
63
+ // oxlint-disable-next-line react/set-state-in-effect
64
+ setReady(false);
65
+ setProgress(null);
66
+ setLoadError(null);
67
+ source
68
+ .load((loadedBytes, totalBytes) => {
69
+ if (!disposed) setProgress({ loadedBytes, totalBytes });
70
+ })
71
+ .then(() => {
72
+ if (disposed) return;
73
+ setReady(true);
74
+ clock.play();
75
+ })
76
+ .catch((error: unknown) => {
77
+ if (!disposed) setLoadError(error instanceof Error ? error.message : String(error));
78
+ });
79
+ return () => {
80
+ disposed = true;
81
+ };
82
+ }, [source, clock]);
83
+
84
+ useEffect(() => {
85
+ let timer: ReturnType<typeof setTimeout>;
86
+ const show = () => {
87
+ setUiVisible(true);
88
+ clearTimeout(timer);
89
+ timer = setTimeout(() => setUiVisible(false), 5000);
90
+ };
91
+ show();
92
+ window.addEventListener('pointermove', show);
93
+ return () => {
94
+ clearTimeout(timer);
95
+ window.removeEventListener('pointermove', show);
96
+ };
97
+ }, []);
98
+
99
+ useEffect(() => {
100
+ const onChange = () => setFullscreen(document.fullscreenElement === stageRef.current);
101
+ document.addEventListener('fullscreenchange', onChange);
102
+ return () => document.removeEventListener('fullscreenchange', onChange);
103
+ }, []);
104
+
105
+ // Unscoping goes back to the whole world, once per toggle.
106
+ useEffect(() => {
107
+ if (!ready || scoped) return;
108
+ let disposed = false;
109
+ void source.setScope(null).then(() => {
110
+ if (!disposed) setScopeVersion((version) => version + 1);
111
+ });
112
+ return () => {
113
+ disposed = true;
114
+ };
115
+ }, [source, scoped, ready]);
116
+
117
+ // Re-scope the aggregates once the viewport settles.
118
+ useEffect(() => {
119
+ if (!ready || !scoped || !bounds) return;
120
+ let disposed = false;
121
+ const handle = setTimeout(
122
+ () =>
123
+ void source.setScope(bounds).then(() => {
124
+ if (!disposed) setScopeVersion((version) => version + 1);
125
+ }),
126
+ 400,
127
+ );
128
+ return () => {
129
+ disposed = true;
130
+ clearTimeout(handle);
131
+ };
132
+ }, [source, bounds, scoped, ready]);
133
+
134
+ // The clock pauses at the end of the day; loop restarts it (play() rewinds).
135
+ useEffect(() => {
136
+ if (!loop) return;
137
+ return clock.subscribe(() => {
138
+ if (!clock.isPlaying() && clock.getDaySeconds() >= DAY_SECONDS) clock.play();
139
+ });
140
+ }, [loop, clock]);
141
+
142
+ const toggleFullscreen = () => {
143
+ if (document.fullscreenElement) void document.exitFullscreen();
144
+ else void stageRef.current?.requestFullscreen();
145
+ };
146
+
147
+ return (
148
+ <div
149
+ ref={stageRef}
150
+ className={[
151
+ 'relative h-full w-full overflow-hidden bg-black',
152
+ /* important beats the inline cursor deck.gl sets on its canvas */
153
+ uiVisible ? '' : 'cursor-none! **:cursor-none!',
154
+ ].join(' ')}
155
+ >
156
+ <TimelapseMap
157
+ frames={frames.slice(0, trailFrames)}
158
+ mapboxAccessToken={mapboxAccessToken}
159
+ initialBounds={initialBounds}
160
+ tuning={tuning}
161
+ onBoundsChange={onBoundsChange}
162
+ />
163
+ <MapBoundsProvider value={bounds}>
164
+ <SettingsMenu
165
+ visible={uiVisible}
166
+ fullscreen={fullscreen}
167
+ onToggleFullscreen={toggleFullscreen}
168
+ loop={loop}
169
+ onToggleLoop={() => setLoop((value) => !value)}
170
+ scoped={scoped}
171
+ onToggleScoped={() => setScoped((value) => !value)}
172
+ frame={frames[0]}
173
+ fps={fps}
174
+ trailFrames={trailFrames}
175
+ />
176
+ <Counter
177
+ source={source}
178
+ ready={ready}
179
+ error={loadError}
180
+ progress={progress}
181
+ scopeVersion={scopeVersion}
182
+ dateLabel={dateLabel}
183
+ timeZoneLabel={timeZoneLabel}
184
+ formatValue={formatValue}
185
+ formatCount={formatCount}
186
+ />
187
+ </MapBoundsProvider>
188
+ <PlayerBar activity={activity} />
189
+ </div>
190
+ );
191
+ }
192
+
193
+ /**
194
+ * A full-day replay of geolocated events: dark glowing map, waveform seek
195
+ * bar, running totals, and a TV mode (autoplay, loop, self-hiding UI).
196
+ * Fills its parent — give the wrapping element an explicit size.
197
+ */
198
+ export function GeoTimelapse(props: GeoTimelapseProps) {
199
+ return (
200
+ <TimelapseClockProvider>
201
+ <Stage {...props} />
202
+ </TimelapseClockProvider>
203
+ );
204
+ }
@@ -0,0 +1,51 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+
4
+ import { buildGlowLayers, DEFAULT_GLOW_TUNING } from './glow-layer.js';
5
+ import type { FrameBids } from './hooks.js';
6
+
7
+ function frame(key: number): FrameBids {
8
+ return { key, positions: new Float32Array([0, 0]), radii: new Float32Array([1]), count: 1, queryMs: 0 };
9
+ }
10
+
11
+ const fillAlpha = (layer: ReturnType<typeof buildGlowLayers>[number]) =>
12
+ (layer.props.getFillColor as unknown as number[])[3];
13
+
14
+ test('layer ids are stable ring-buffer slots', () => {
15
+ const layers = buildGlowLayers([frame(14), frame(13), frame(12)]);
16
+ assert.deepEqual(
17
+ layers.map((layer) => layer.id),
18
+ ['glow-2', 'glow-1', 'glow-0'],
19
+ );
20
+ });
21
+
22
+ test('radii taper with age from the flash down to the ghost floor', () => {
23
+ const frames = Array.from({ length: DEFAULT_GLOW_TUNING.trailFrames }, (_, age) => frame(23 - age));
24
+ const scales = buildGlowLayers(frames).map((layer) => layer.props.radiusScale);
25
+ assert.equal(scales[0], DEFAULT_GLOW_TUNING.flashRadius);
26
+ assert.equal(scales[1], DEFAULT_GLOW_TUNING.ghostMaxRadius);
27
+ assert.equal(scales.at(-1), DEFAULT_GLOW_TUNING.ghostMinRadius);
28
+ for (let age = 2; age < scales.length; age++) assert.ok(scales[age] < scales[age - 1]);
29
+ });
30
+
31
+ test('the pixel floor tapers with the same ratio as the radius', () => {
32
+ const layers = buildGlowLayers([frame(1), frame(0)]);
33
+ const { minRadiusPx, flashRadius, ghostMaxRadius } = DEFAULT_GLOW_TUNING;
34
+ assert.equal(layers[0].props.radiusMinPixels, minRadiusPx);
35
+ assert.equal(layers[1].props.radiusMinPixels, (minRadiusPx * ghostMaxRadius) / flashRadius);
36
+ });
37
+
38
+ test('ghosts fade geometrically in the fill alpha', () => {
39
+ const frames = Array.from({ length: 3 }, (_, age) => frame(2 - age));
40
+ const alphas = buildGlowLayers(frames).map(fillAlpha);
41
+ assert.equal(alphas[0], DEFAULT_GLOW_TUNING.pointAlpha);
42
+ assert.equal(alphas[1], Math.round(DEFAULT_GLOW_TUNING.pointAlpha * 0.8));
43
+ assert.equal(alphas[2], Math.round(DEFAULT_GLOW_TUNING.pointAlpha * 0.8 ** 2));
44
+ });
45
+
46
+ test('tuning overrides reach the layers', () => {
47
+ const [layer] = buildGlowLayers([frame(0)], { ...DEFAULT_GLOW_TUNING, pointAlpha: 50, maxRadiusPx: 42 });
48
+ assert.equal(fillAlpha(layer), 50);
49
+ assert.equal(layer.props.radiusMaxPixels, 42);
50
+ assert.equal(layer.props.radiusUnits, 'meters');
51
+ });
@@ -0,0 +1,166 @@
1
+ import { ScatterplotLayer } from 'deck.gl';
2
+
3
+ import type { FrameBids } from './hooks.js';
4
+
5
+ // ScatterplotLayer with the disc fill replaced by a point light: a ~1px core
6
+ // plus an inverse-square halo. Rendered additively (see GLOW_PARAMETERS), so
7
+ // stacked points accumulate into a bloom that reveals density.
8
+ const fs = /* glsl */ `\
9
+ #version 300 es
10
+ #define SHADER_NAME glow-layer-fragment-shader
11
+
12
+ precision highp float;
13
+
14
+ in vec4 vFillColor;
15
+ in vec2 unitPosition;
16
+ in float outerRadiusPixels;
17
+ in float vSqrtWeight;
18
+ in float vSoftRadiusPixels;
19
+
20
+ out vec4 fragColor;
21
+
22
+ void main(void) {
23
+ geometry.uv = unitPosition;
24
+
25
+ float distPixels = length(unitPosition) * outerRadiusPixels;
26
+ float core = smoothstep(1.4, 0.3, distPixels);
27
+ // Gaussian halo: a soft, wide shoulder instead of a spiky falloff, with a
28
+ // per-point amplitude low enough that white needs hundreds of stacked
29
+ // points — that slow additive build-up is what reads as glow. Its sigma
30
+ // follows the soft-capped radius so heavy stacks stay distinguishable.
31
+ float sigma = vSoftRadiusPixels * 0.35;
32
+ // Aggregation collapsed stacked points into one draw; sqrt(weight) raises
33
+ // the halo AMPLITUDE rather than the summed profile, so heavy stacks keep a
34
+ // gaussian gradient instead of clipping into a flat disc. The exponential
35
+ // shoulder (film-exposure response) approaches 1 without ever clipping, so
36
+ // intensity keeps discriminating stack sizes. Weight 1 is near-identical.
37
+ float gain = 1.0 - exp(-vSqrtWeight * 0.035);
38
+ float halo = gain * exp(-distPixels * distPixels / (2.0 * sigma * sigma));
39
+ // The core reads pure white; the halo carries the fill color's tint.
40
+ vec3 color = mix(vFillColor.rgb, vec3(1.0), core);
41
+ fragColor = vec4(color, vFillColor.a * min(core + halo, 1.0));
42
+
43
+ DECKGL_FILTER_COLOR(fragColor, geometry);
44
+ }
45
+ `;
46
+
47
+ export default class GlowLayer<DataT> extends ScatterplotLayer<DataT> {
48
+ static layerName = 'GlowLayer';
49
+
50
+ getShaders() {
51
+ return {
52
+ ...super.getShaders(),
53
+ fs,
54
+ // The radius attribute carries sqrt(weight); hand it to the fragment
55
+ // stage, along with a soft-capped radius: instead of the hard clamp at
56
+ // radiusMaxPixels, tanh approaches it asymptotically so ever-heavier
57
+ // stacks keep growing (less and less) instead of all looking alike.
58
+ inject: {
59
+ 'vs:#decl': 'out float vSqrtWeight;\nout float vSoftRadiusPixels;',
60
+ 'vs:#main-end': `\
61
+ vSqrtWeight = instanceRadius;
62
+ float rawRadiusPixels = project_size_to_pixel(scatterplot.radiusScale * instanceRadius, scatterplot.radiusUnits);
63
+ vSoftRadiusPixels = max(
64
+ scatterplot.radiusMaxPixels * tanh(rawRadiusPixels / scatterplot.radiusMaxPixels),
65
+ scatterplot.radiusMinPixels
66
+ );`,
67
+ },
68
+ };
69
+ }
70
+ }
71
+
72
+ // Additive blending: overlapping halos sum toward white instead of occluding.
73
+ export const GLOW_PARAMETERS = {
74
+ blendColorOperation: 'add',
75
+ blendColorSrcFactor: 'src-alpha',
76
+ blendColorDstFactor: 'one',
77
+ blendAlphaOperation: 'add',
78
+ blendAlphaSrcFactor: 'one',
79
+ blendAlphaDstFactor: 'one',
80
+ } as const;
81
+
82
+ /**
83
+ * The visual knobs of the glow trail. Radii are ground distances in meters so
84
+ * the on-screen density — and therefore the additive glow intensity — stays
85
+ * invariant across zoom levels.
86
+ */
87
+ export interface GlowTuning {
88
+ /** Master per-point transparency, 0-255: density does the brightening. */
89
+ pointAlpha: number;
90
+ /** Halo tint; the shader keeps the point core pure white. */
91
+ haloTint: [number, number, number];
92
+ /** Radius of the freshest frame's points, in meters (per unit of weight). */
93
+ flashRadius: number;
94
+ /** Ghost radii taper linearly from this down to ghostMinRadius, in meters. */
95
+ ghostMaxRadius: number;
96
+ ghostMinRadius: number;
97
+ /** Floor in pixels so isolated points survive deep zoom-outs. Applies to
98
+ * the freshest frame; ghosts get it scaled down with their age taper. */
99
+ minRadiusPx: number;
100
+ /** Asymptote of the rendered radius, in pixels: the halo tends toward it
101
+ * (tanh) as stacks grow, without ever flattening against a hard clamp. */
102
+ maxRadiusPx: number;
103
+ /** How many past frames stay visible as fading ghosts. */
104
+ trailFrames: number;
105
+ }
106
+
107
+ // Defaults calibrated on the glow-lab bench (apps/website /lab/glow); the
108
+ // ghost radii keep the 2/3 and 1/15 taper ratios of the flash radius.
109
+ export const DEFAULT_GLOW_TUNING: GlowTuning = {
110
+ pointAlpha: 115,
111
+ haloTint: [190, 222, 255],
112
+ flashRadius: 80_000,
113
+ ghostMaxRadius: 53_000,
114
+ ghostMinRadius: 5_000,
115
+ minRadiusPx: 12,
116
+ maxRadiusPx: 80,
117
+ trailFrames: 12,
118
+ };
119
+
120
+ function radiusForAge(age: number, tuning: GlowTuning): number {
121
+ if (age === 0) return tuning.flashRadius;
122
+ const taper = (age - 1) / Math.max(tuning.trailFrames - 2, 1);
123
+ return tuning.ghostMaxRadius - (tuning.ghostMaxRadius - tuning.ghostMinRadius) * taper;
124
+ }
125
+
126
+ // Ghosts fade geometrically with age. Applied in the fill color, not the
127
+ // layer opacity — deck gamma-corrects opacity (^1/2.2), which would keep the
128
+ // oldest ghost at ~32% instead of ~9%.
129
+ const GHOST_FADE = 0.8;
130
+
131
+ function alphaForFrame(age: number, tuning: GlowTuning): number {
132
+ return Math.round(tuning.pointAlpha * GHOST_FADE ** age);
133
+ }
134
+
135
+ /**
136
+ * One layer per remembered frame, fading and tightening with age into a ghost
137
+ * trail. The layer id is the frame's ring-buffer slot: a slot's data only
138
+ * changes when its frame rotates out, so per tick a single layer re-uploads
139
+ * attributes. Pure: same frames + tuning, same layers.
140
+ */
141
+ export function buildGlowLayers(frames: FrameBids[], tuning: GlowTuning = DEFAULT_GLOW_TUNING): GlowLayer<unknown>[] {
142
+ return frames.map(
143
+ (frame, age) =>
144
+ new GlowLayer({
145
+ id: `glow-${frame.key % tuning.trailFrames}`,
146
+ data: {
147
+ length: frame.count,
148
+ attributes: {
149
+ // sqrt(weight) radii: point AREA stays linear in the weight.
150
+ getPosition: { value: frame.positions, size: 2 },
151
+ getRadius: { value: frame.radii, size: 1 },
152
+ },
153
+ },
154
+ getFillColor: [...tuning.haloTint, alphaForFrame(age, tuning)],
155
+ // Fill-rate is the frame budget: cost grows with the quad area (r²),
156
+ // so ghost frames shrink with age — the glow cools as it fades. The
157
+ // pixel floor tapers along, or it would resurrect every ghost.
158
+ radiusScale: radiusForAge(age, tuning),
159
+ radiusMinPixels: (tuning.minRadiusPx * radiusForAge(age, tuning)) / tuning.flashRadius,
160
+ radiusMaxPixels: tuning.maxRadiusPx,
161
+ radiusUnits: 'meters',
162
+ stroked: false,
163
+ parameters: GLOW_PARAMETERS,
164
+ }),
165
+ );
166
+ }
package/src/hooks.ts ADDED
@@ -0,0 +1,167 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+
5
+ import { useTimelapseClock } from './clock.js';
6
+ import type { GeoTimelapseSource } from './types.js';
7
+
8
+ // One clock tick of replayed time (480x speed, 24 ticks/s).
9
+ const DEFAULT_WINDOW_S = 20;
10
+ // Widened-window cap when ticks were skipped (slow queries, render lag).
11
+ // Kept tight: giant catch-up frames feed the lag they compensate for.
12
+ const MAX_WINDOW_S = 3 * DEFAULT_WINDOW_S;
13
+
14
+ export interface FrameBids {
15
+ /** Monotonic frame number, also the identity of the frame's layer slot. */
16
+ key: number;
17
+ /** Interleaved [lon, lat] pairs, one per distinct location, ready for a binary deck.gl attribute. */
18
+ positions: Float32Array;
19
+ /** Per-location sqrt(weight): scaling the radius by it keeps the point AREA linear in the weight. */
20
+ radii: Float32Array;
21
+ /** Distinct locations in the frame (the instance count). */
22
+ count: number;
23
+ queryMs: number;
24
+ }
25
+
26
+ /** How many past frames stay visible as fading ghost trails. */
27
+ export const FRAME_HISTORY = 12;
28
+
29
+ /**
30
+ * The last FRAME_HISTORY frames of events, newest first. Each frame holds
31
+ * the window since the frame before it; a seek resets the history.
32
+ */
33
+ export function useFrameHistory(source: GeoTimelapseSource, ready: boolean): FrameBids[] {
34
+ const clock = useTimelapseClock();
35
+ const [frames, setFrames] = useState<FrameBids[]>([]);
36
+
37
+ useEffect(() => {
38
+ if (!ready) return;
39
+ let disposed = false;
40
+ let inFlight = false;
41
+ let lastQueried = -1;
42
+ let lastEpoch = -1;
43
+ let frameKey = 0;
44
+
45
+ const query = async () => {
46
+ const daySeconds = Math.floor(clock.getDaySeconds());
47
+ const epoch = clock.getEpoch();
48
+ if (inFlight || (daySeconds === lastQueried && epoch === lastEpoch)) return;
49
+ inFlight = true;
50
+ try {
51
+ const started = performance.now();
52
+ // A time jump (seek, restart) starts the trail over; mere lag only
53
+ // widens the window, capped, so skipped ticks don't drop events.
54
+ const jumped = epoch !== lastEpoch || lastQueried < 0 || daySeconds <= lastQueried;
55
+ const span = jumped ? DEFAULT_WINDOW_S : Math.min(daySeconds - lastQueried, MAX_WINDOW_S);
56
+ lastQueried = daySeconds;
57
+ lastEpoch = epoch;
58
+ const result = await source.frame(daySeconds - span, daySeconds);
59
+ if (disposed) return;
60
+ const radii = new Float32Array(result.count);
61
+ for (let i = 0; i < result.count; i++) radii[i] = Math.sqrt(result.weights[i]);
62
+ const frame: FrameBids = {
63
+ key: frameKey++,
64
+ positions: result.positions,
65
+ radii,
66
+ count: result.count,
67
+ queryMs: Math.round(performance.now() - started),
68
+ };
69
+ // Ghost trails only make sense over contiguous playback.
70
+ setFrames((history) => (jumped ? [frame] : [frame, ...history.slice(0, FRAME_HISTORY - 1)]));
71
+ } finally {
72
+ inFlight = false;
73
+ }
74
+ };
75
+
76
+ void query();
77
+ const unsubscribe = clock.subscribe(() => void query());
78
+ return () => {
79
+ disposed = true;
80
+ unsubscribe();
81
+ };
82
+ // ready re-arms the reader once the source has loaded.
83
+ // oxlint-disable-next-line react/exhaustive-effect-dependencies
84
+ }, [clock, source, ready]);
85
+
86
+ return frames;
87
+ }
88
+
89
+ /** Event count for each minute of the replayed day, within the current scope. */
90
+ export function useMinuteActivity(
91
+ source: GeoTimelapseSource,
92
+ ready: boolean,
93
+ scopeVersion: number,
94
+ ): Float32Array | null {
95
+ const [activity, setActivity] = useState<Float32Array | null>(null);
96
+
97
+ useEffect(() => {
98
+ if (!ready) return;
99
+ let disposed = false;
100
+ void source.activity().then((counts) => {
101
+ if (!disposed) setActivity(counts);
102
+ });
103
+ return () => {
104
+ disposed = true;
105
+ };
106
+ // ready and scopeVersion re-run the effect so a (re)loaded source is re-read.
107
+ // oxlint-disable-next-line react/exhaustive-effect-dependencies
108
+ }, [source, ready, scopeVersion]);
109
+
110
+ return activity;
111
+ }
112
+
113
+ /**
114
+ * Trail length that adapts to the achieved frame rate: under 24 fps ghosts
115
+ * are shed one per second, above 58 fps they come back, up to FRAME_HISTORY.
116
+ * (58, not 60: a 60 Hz display — the TV target — never reads past 60.) The
117
+ * 1s sampling window doubles as a cooldown so the count settles instead of
118
+ * oscillating across the wide 24-58 dead band.
119
+ */
120
+ export function useAdaptiveTrailFrames(): number {
121
+ const [count, setCount] = useState(FRAME_HISTORY);
122
+
123
+ useEffect(() => {
124
+ let rafId = 0;
125
+ let ticks = 0;
126
+ let windowStart = performance.now();
127
+ const loop = (now: number) => {
128
+ ticks += 1;
129
+ if (now - windowStart >= 1000) {
130
+ const fps = (ticks * 1000) / (now - windowStart);
131
+ if (fps < 24) setCount((current) => Math.max(current - 1, 2));
132
+ else if (fps > 58) setCount((current) => Math.min(current + 1, FRAME_HISTORY));
133
+ ticks = 0;
134
+ windowStart = now;
135
+ }
136
+ rafId = requestAnimationFrame(loop);
137
+ };
138
+ rafId = requestAnimationFrame(loop);
139
+ return () => cancelAnimationFrame(rafId);
140
+ }, []);
141
+
142
+ return count;
143
+ }
144
+
145
+ /** Rendered frames per second, sampled twice a second. */
146
+ export function useFps(): number {
147
+ const [fps, setFps] = useState(0);
148
+
149
+ useEffect(() => {
150
+ let rafId = 0;
151
+ let count = 0;
152
+ let windowStart = performance.now();
153
+ const loop = (now: number) => {
154
+ count += 1;
155
+ if (now - windowStart >= 500) {
156
+ setFps(Math.round((count * 1000) / (now - windowStart)));
157
+ count = 0;
158
+ windowStart = now;
159
+ }
160
+ rafId = requestAnimationFrame(loop);
161
+ };
162
+ rafId = requestAnimationFrame(loop);
163
+ return () => cancelAnimationFrame(rafId);
164
+ }, []);
165
+
166
+ return fps;
167
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export { GeoTimelapse } from './geo-timelapse.js';
2
+ export { buildGlowLayers, DEFAULT_GLOW_TUNING, default as GlowLayer, GLOW_PARAMETERS } from './glow-layer.js';
3
+ export type { GlowTuning } from './glow-layer.js';
4
+ export type { FrameBids } from './hooks.js';
5
+ export type { FramePoints, GeoTimelapseProps, GeoTimelapseSource, MapBounds, Totals } from './types.js';