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,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';
|