@motionstudies/data 0.1.0-alpha.5 → 0.1.0-alpha.7

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/README.md CHANGED
@@ -5,7 +5,7 @@ Shared packages for the Motion Studies transport instrument. The source workspac
5
5
  - `@motionstudies/core`: transport contracts, indexing, interpolation and visual theme contracts; no browser or Node dependencies.
6
6
  - `@motionstudies/three`: `NationalNetworkScene`, `HubPulseScene`, `StationFlowScene`, camera framing and label-mode contracts. React, React Three Fiber and Three.js are peers; rendering internals are not public subpaths.
7
7
  - `@motionstudies/web`: picker, button tooltips, theme application, mounting, progressive loaders, observed operations and recording. Import `tokens.css` and `mobile-picker.css` for isolated widgets. `shell.css` is an optional full-page study shell scoped to `.motion-study`; `mountMotionStudy` applies that class. Fonts and edition layouts belong to consumers.
8
- - `@motionstudies/data`: Node-only GTFS readers, network chunking, merging and station ranking. ZIP reading requires `unzip` on the host. Source selection, provenance overrides and compilation commands belong to each edition.
8
+ - `@motionstudies/data`: Node-only GTFS readers, ADS-B heatmap compilation, air endpoint enrichment, network chunking, merging and station ranking. ZIP reading requires `unzip` on the host. Source selection, provenance overrides and compilation commands belong to each edition.
9
9
 
10
10
  ```tsx
11
11
  import { MobilePicker } from '@motionstudies/web/components/MobilePicker'
@@ -25,6 +25,16 @@ Build release candidates with `npm run build:packages`. Distribution manifests a
25
25
 
26
26
  Source workspace manifests always stay private. `npm run check:release` builds public candidates, tests their packed consumer and records the tested tarball hashes; `npm run release:dry-run` inspects the publication without writing to npm. The manual main-branch `release.yml` workflow publishes those same tarballs with public access and provenance. See [release instructions](https://github.com/emmettl/motionstudies/blob/main/docs/RELEASING.md) for bootstrap-token and trusted-publisher setup. All four shared packages are MIT-licensed; each distribution includes `LICENSE`.
27
27
 
28
+ ## Now and browser location
29
+
30
+ `useNowClock` from `@motionstudies/web/use-now-clock` follows the wall clock on each animation frame, so returning from a suspended tab catches up immediately. Call `start()` from a Now button, set the consumer's playback rate to 1, and render its `time` while `active`. With `NationalNetworkScene`, pass `isPlaying={false}` while this external clock owns time. Call `stop()` before pausing, seeking or changing speed, and retain the last clock time for ordinary playback. Clear moving vehicle selections on entry to keep the camera still; panning and zooming remain available.
31
+
32
+ Supply a resolver `(instant: Date) => number | null` that maps the instant into the edition's service-time coordinates. The edition owns timezone, service date, daylight-saving rules and source coverage. Now is appropriate when the data gives a meaningful sense of this place at this time; exact live vehicle positions are not required. An edition may explicitly map the current local clock onto a suitable representative weekday or seasonal timetable, with a quiet label such as “Typical weekday · realtime pace”. Historical or modelled sources need that same meaningful relationship to the present; merely having timestamps is insufficient. Return null outside the chosen data's coverage: the hook stops, retains the last valid time and exposes `unavailable`. Do not silently relabel a recording as live or wrap a partial study window. Timetable and observed-data labels remain the consumer's responsibility; 1× playback does not imply a live vehicle feed.
33
+
34
+ `useBrowserLocation` from `@motionstudies/web/use-browser-location` requests a single position only when `locate()` is called. It exposes `location`, `status` and `clear()`; it neither stores nor sends coordinates. Render localized messages for denied, timeout and unavailable states. Clearing or unmounting discards late responses. A secure browser context and user permission are required.
35
+
36
+ Pass an in-coverage position as `NationalNetworkScene`'s `userLocation` for a steady glowing dot. Use the existing `focus-location` camera command once after locating, with `[longitude, latitude]` and a suitable `distanceScale`. Validate coverage in the consumer and preserve the view when the user is outside it. Show the reported `accuracy` in accessible text; the dot is an approximate position. The marker hides in diagram layouts. The lab's **Now** specimen uses a clearly labelled synthetic UTC timetable and covers clock, permission and out-of-coverage behavior. Public editions still require independent adoption and releases.
37
+
28
38
  ## Selection labels
29
39
 
30
40
  `NationalNetworkScene` gives the selected station first label priority, followed by the selected route's terminal stops (including branches), then intermediate stops. Selected services use their own endpoints. Priority precedes retained labels and ordinary rank/tier admission; clearing selection restores edition ranking. This is built in for every consumer, including geographic and diagram layouts. Supply complete enabled infrastructure as `referenceSnapshot` to preserve endpoints through timetable gaps; its stop indexes need not match the active snapshot. See the [edition behaviour contract](https://github.com/emmettl/motionstudies/blob/main/docs/EDITIONS.md#selection-and-station-labels).
@@ -73,3 +83,19 @@ Empty messages also appear on the flaps, in the widest column (the destination/o
73
83
  Rail and other transport consumers can share the same time filtering through `movementBoardWindow(study, horizon)` and `movementsForBoard(entries, window, maxRows)` from `@motionstudies/core/domain/movement-board`. Format the returned numeric times when mapping them into `SplitFlapBoard` cells. The lab's rail board follows the same study clock and horizon as its airport card.
74
84
 
75
85
  `@motionstudies/data/air-endpoints` provides offline `enrichAirEndpoints` for existing air manifests, chunks and opening snapshots. Supply cached same-date global ADSB.lol heatmaps, an OurAirports CSV and the service date's local UTC offset. It associates only unambiguous low-altitude endpoints near a reference airport; cruise-only traces and uncertain routes stay unknown. Optional `AirEndpoint` origin/destination fields carry airport identity, observed boundary time and `observed-endpoint` evidence. `airportBoardMovements` maps full manifest entries to board rows without confusing playback chunk boundaries with flight endpoints. Input hashes and source/licence attribution are recorded in fixture metadata. These fields describe inferred observations, never flight schedules, gates or live status.
86
+
87
+ ## Optional live airport feed
88
+
89
+ `AirportBoard` from `@motionstudies/web/components/AirportBoard` adds Study/Now controls around an existing `AirportHeroCard` configuration. Pass `studyCard` with the usual card props and `live={{ baseUrl, edition, airport }}` for the shared service. Import `airport-hero-card.css`. `labels` localizes the wrapper's control and availability messages. The lower-level `useAirportFeed` hook and core `domain/live-airport` contract are also public exports.
90
+
91
+ Live timestamps are Unix seconds and use the airport's timezone for display, independently of recorded service time. The wrapper does not pass live flight IDs to the recorded scene's selection callback. The recorded card remains mounted while hidden; the edition still owns playback and can pause its study when appropriate. Only Now mode fetches flight boards; checking capabilities does not query the paid provider. Stale results carry their retrieval time and disappear when expired. Source data never falls back to synthetic or recorded flights under a live label.
92
+
93
+ See [service architecture and operations](../docs/LIVE-AIRPORTS.md). The Worker is deployed separately; npm publication and edition adoption remain explicit release steps.
94
+
95
+ ## Shared recorded air compilation
96
+
97
+ `@motionstudies/data/adsb-heatmap` consolidates the offline heatmap pipeline previously copied between editions. `ingestAdsbHeatmaps` reads cached gzip slices, decodes observations, filters transport-scale tracks, splits flights, and writes either an opening snapshot or an indexed day with overlapping chunks. Source hashes, chunk hashes and ODbL attribution accompany the output. It makes no network requests.
98
+
99
+ The same `decodeAdsbHeatmap` now powers `enrichAirEndpoints`; endpoint inference retains full coordinate precision, while playback compilation retains the existing five-decimal coordinates. `transportAirTracks` and `chunkAirSnapshot` are also available for consumers that assemble their own pipeline. All functions have public TypeScript declarations and work in the packed Node package.
100
+
101
+ Editions supply geographic bounds, service date, explicit UTC offset, optional timezone, input files and output paths. Flight IDs and chunk overlap retain the existing contracts. The default splits known callsign changes and gaps over 30 minutes. Set `splitTracks: false` only when reproducing a legacy opening snapshot with one ID per aircraft. See [adoption and compatibility](../docs/AIR-DATA.md).
@@ -0,0 +1,56 @@
1
+ import type { AirSnapshot, AirTrack } from '@motionstudies/core/domain/air'
2
+ import type { AirDayManifest, AirDayChunk, AirDayChunkDescriptor } from '@motionstudies/core/domain/air-day'
3
+
4
+ export interface HeatmapWindow {
5
+ serviceDate: string
6
+ /** Explicit offset for this recording; the caller owns DST and source date selection. */
7
+ utcOffsetHours: number
8
+ windowStart: number
9
+ windowEnd: number
10
+ bounds?: readonly [west: number, south: number, east: number, north: number]
11
+ /** Numeric 24-bit ICAO addresses; omit to decode all aircraft. */
12
+ addresses?: ReadonlySet<number>
13
+ }
14
+ export type HeatmapSample = [time: number, longitude: number, latitude: number, altitudeFeet: number, speedKnots: number, callsign: string | undefined]
15
+ export interface HeatmapRecord {
16
+ id: string
17
+ callsign: string
18
+ samples: readonly HeatmapSample[]
19
+ }
20
+ export interface HeatmapSource {
21
+ name: string
22
+ sha256: string
23
+ sourceUrl: string
24
+ }
25
+ export type HeatmapMetadata = AirSnapshot['metadata'] & {
26
+ timezone?: string
27
+ utcOffsetHours: number
28
+ sourceFiles: readonly HeatmapSource[]
29
+ }
30
+ export type HeatmapSnapshot = Omit<AirSnapshot, 'metadata'> & { readonly metadata: HeatmapMetadata }
31
+ export type AirChunkIntegrity = AirDayChunkDescriptor & { readonly bytes: number; readonly sha256: string }
32
+ export type ChunkedAirManifest = Omit<AirDayManifest, 'chunks'> & { readonly chunks: readonly AirChunkIntegrity[] }
33
+ export type HeatmapManifest = Omit<ChunkedAirManifest, 'metadata'> & { readonly metadata: HeatmapMetadata }
34
+
35
+ /** Uncompressed binary input; returns the number of accepted observations. Does not round coordinates. An absent callsign is undefined; an explicitly cleared one is empty. */
36
+ export function decodeAdsbHeatmap(bytes: Uint8Array, options: HeatmapWindow, onSample: (address: number, sample: HeatmapSample) => void): number
37
+ /** Does not mutate the input records. Legacy unsplit snapshots can opt out of flight segmentation. */
38
+ export function transportAirTracks(records: Iterable<HeatmapRecord>, options?: { splitTracks?: boolean }): AirTrack[]
39
+ export function chunkAirSnapshot(snapshot: AirSnapshot, options: {
40
+ chunkSeconds: number
41
+ stem: string
42
+ overlapBefore?: number
43
+ overlapAfter?: number
44
+ }): { manifest: ChunkedAirManifest; chunks: { descriptor: AirChunkIntegrity; payload: AirDayChunk }[] }
45
+ export interface HeatmapIngestOptions extends HeatmapWindow {
46
+ inputs: readonly string[]
47
+ output: string
48
+ bounds: readonly [west: number, south: number, east: number, north: number]
49
+ timezone?: string
50
+ chunkHours?: number
51
+ /** Defaults true; use false only to preserve a legacy single-track-per-aircraft snapshot. */
52
+ splitTracks?: boolean
53
+ }
54
+ export function ingestAdsbHeatmaps(options: HeatmapIngestOptions & { chunkHours: number }): Promise<HeatmapManifest>
55
+ export function ingestAdsbHeatmaps(options: HeatmapIngestOptions & { chunkHours?: undefined }): Promise<HeatmapSnapshot>
56
+ export function ingestAdsbHeatmaps(options: HeatmapIngestOptions): Promise<HeatmapSnapshot | HeatmapManifest>
@@ -0,0 +1,223 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { readFile, writeFile } from 'node:fs/promises'
3
+ import { basename, dirname, join } from 'node:path'
4
+ import { gunzipSync } from 'node:zlib'
5
+
6
+ const MAGIC = 0x0e7f7c9d
7
+ const CALLSIGN_MARKER = 0x40000000
8
+ const NON_ICAO_MARKER = 0x01000000
9
+
10
+ function serviceDayStart(options) {
11
+ const { serviceDate, utcOffsetHours, windowStart, windowEnd } = options
12
+ const midnight = Date.parse(`${serviceDate}T00:00:00Z`)
13
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(serviceDate) || !Number.isFinite(midnight) || new Date(midnight).toISOString().slice(0, 10) !== serviceDate) {
14
+ throw new Error('A valid serviceDate (YYYY-MM-DD) is required')
15
+ }
16
+ if (!Number.isFinite(utcOffsetHours) || Math.abs(utcOffsetHours) > 14) throw new Error('A valid local UTC offset is required')
17
+ if (!Number.isFinite(windowStart) || !Number.isFinite(windowEnd) || windowStart >= windowEnd) throw new Error('A finite, increasing study window is required')
18
+ if (options.bounds) {
19
+ const [west, south, east, north] = options.bounds
20
+ if (options.bounds.length !== 4 || !options.bounds.every(Number.isFinite) || west < -180 || east > 180 || south < -90 || north > 90 || west >= east || south >= north) {
21
+ throw new Error('bounds must be [west, south, east, north] in geographic degrees')
22
+ }
23
+ }
24
+ return midnight - utcOffsetHours * 3600000
25
+ }
26
+
27
+ /** Decode an uncompressed readsb heatmap slice. Times remain service-local seconds, including after midnight. */
28
+ export function decodeAdsbHeatmap(bytes, options, onSample) {
29
+ const dayStart = serviceDayStart(options)
30
+ const buffer = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes)
31
+ if (buffer.length % 16 !== 0) throw new Error('Truncated heatmap record')
32
+ const [west, south, east, north] = options.bounds ?? [-180, -90, 180, 90]
33
+ const callsigns = new Map()
34
+ let time
35
+ let count = 0
36
+ for (let offset = 0; offset < buffer.length; offset += 16) {
37
+ const address = buffer.readUInt32LE(offset), encodedLatitude = buffer.readInt32LE(offset + 4)
38
+ if (address === MAGIC) {
39
+ const milliseconds = Number((BigInt(encodedLatitude) << 32n) | BigInt(buffer.readUInt32LE(offset + 8)))
40
+ time = Math.round((milliseconds - dayStart) / 1000)
41
+ continue
42
+ }
43
+ const id = address & 0xffffff
44
+ if ((address & NON_ICAO_MARKER) !== 0 || (options.addresses && !options.addresses.has(id))) continue
45
+ // Negative latitude also sets this bit in two's complement; it is still a position.
46
+ if (encodedLatitude >= 0 && (encodedLatitude & CALLSIGN_MARKER) !== 0) {
47
+ callsigns.set(id, buffer.subarray(offset + 8, offset + 16).toString('ascii').replaceAll('\0', '').trim().toUpperCase())
48
+ continue
49
+ }
50
+ if (time === undefined || time < options.windowStart || time > options.windowEnd) continue
51
+ const latitude = encodedLatitude / 1e6, longitude = buffer.readInt32LE(offset + 8) / 1e6
52
+ if (longitude < west || longitude > east || latitude < south || latitude > north) continue
53
+ const altitude = buffer.readInt16LE(offset + 12), speed = buffer.readInt16LE(offset + 14)
54
+ if (altitude < 0 || speed < 0) continue
55
+ onSample(id, [time, longitude, latitude, altitude * 25, speed / 10, callsigns.get(id)])
56
+ count++
57
+ }
58
+ return count
59
+ }
60
+
61
+ function round(value, digits) {
62
+ const scale = 10 ** digits
63
+ return Math.round(value * scale) / scale
64
+ }
65
+
66
+ function transportScaleTrack(record) {
67
+ const samples = [...record.samples]
68
+ .sort((first, second) => first[0] - second[0])
69
+ .filter((sample, index, all) => index === 0 || sample[0] !== all[index - 1][0])
70
+ .map((sample) => sample.slice(0, 5))
71
+ if (samples.length < 4) return undefined
72
+ const maximumSpeed = samples.reduce((maximum, sample) => Math.max(maximum, sample[4]), 0)
73
+ const maximumAltitude = samples.reduce((maximum, sample) => Math.max(maximum, sample[3]), 0)
74
+ const airlineCallsign = /^[A-Z]{2,4}\d[A-Z0-9]*$/.test(record.callsign)
75
+ if (maximumSpeed < 120 || maximumAltitude < 1_500) return undefined
76
+ if (!airlineCallsign && (maximumSpeed < 250 || maximumAltitude < 10_000)) return undefined
77
+ return {
78
+ id: record.id,
79
+ callsign: record.callsign || record.id.toUpperCase(),
80
+ start: samples[0][0],
81
+ end: samples.at(-1)[0],
82
+ samples,
83
+ }
84
+ }
85
+
86
+ function transportScaleTracks(record) {
87
+ const ordered = [...record.samples]
88
+ .sort((first, second) => first[0] - second[0])
89
+ .filter((sample, index, all) => index === 0 || sample[0] !== all[index - 1][0])
90
+ const segments = []
91
+ let current = []
92
+ let currentCallsign = ''
93
+
94
+ for (const sample of ordered) {
95
+ const callsign = sample[5]
96
+ const previous = current.at(-1)
97
+ const callsignChanged = callsign && currentCallsign && callsign !== currentCallsign
98
+ const longGap = previous && sample[0] - previous[0] > 30 * 60
99
+ if (current.length && (callsignChanged || longGap)) {
100
+ segments.push({ callsign: currentCallsign, samples: current })
101
+ current = []
102
+ currentCallsign = ''
103
+ }
104
+ if (callsign) currentCallsign = callsign
105
+ current.push(sample)
106
+ }
107
+ if (current.length) segments.push({ callsign: currentCallsign, samples: current })
108
+
109
+ return segments
110
+ .map((segment) => {
111
+ const base = transportScaleTrack({
112
+ id: record.id,
113
+ callsign: segment.callsign || record.callsign,
114
+ samples: segment.samples,
115
+ })
116
+ if (!base) return undefined
117
+ return {
118
+ ...base,
119
+ id: `${record.id}-${base.start}`,
120
+ icaoAddress: record.id,
121
+ }
122
+ })
123
+ .filter(Boolean)
124
+ }
125
+
126
+
127
+ /** Stable ordering and IDs; split on a known callsign change or an observation gap over 30 minutes. */
128
+ export function transportAirTracks(records, { splitTracks = true } = {}) {
129
+ return [...records].flatMap(record => splitTracks
130
+ ? transportScaleTracks(record)
131
+ : [transportScaleTrack(record)].filter(Boolean))
132
+ .sort((first, second) => first.id.localeCompare(second.id))
133
+ }
134
+
135
+ /** Produce playback chunks with the same 180s trail and 45s interpolation overlap used by the editions. */
136
+ export function chunkAirSnapshot(snapshot, { chunkSeconds, stem, overlapBefore = 180, overlapAfter = 45 }) {
137
+ if (!Number.isInteger(chunkSeconds) || chunkSeconds <= 0) throw new Error('chunkSeconds must be a positive integer')
138
+ if (![overlapBefore, overlapAfter].every(value => Number.isFinite(value) && value >= 0)) throw new Error('Chunk overlap must be non-negative')
139
+ if (!/^[a-zA-Z0-9_-]+$/.test(stem)) throw new Error('A simple chunk filename stem is required')
140
+ const { windowStart: start, windowEnd: end } = snapshot.metadata
141
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start >= end) throw new Error('A finite, increasing study window is required')
142
+ const chunks = []
143
+ for (let windowStart = start; windowStart < end; windowStart += chunkSeconds) {
144
+ const windowEnd = Math.min(end, windowStart + chunkSeconds)
145
+ const id = String(Math.floor(windowStart / chunkSeconds)).padStart(2, '0')
146
+ const tracks = snapshot.tracks.flatMap(track => {
147
+ const samples = track.samples.filter(sample => sample[0] >= windowStart - overlapBefore && sample[0] <= windowEnd + overlapAfter)
148
+ return samples.length < 2 ? [] : [{ ...track, start: samples[0][0], end: samples.at(-1)[0], samples }]
149
+ })
150
+ const payload = { windowStart, windowEnd, tracks }
151
+ const serialized = `${JSON.stringify(payload)}\n`
152
+ chunks.push({ descriptor: {
153
+ id, windowStart, windowEnd, path: `${stem}-${id}.json`, trackCount: tracks.length,
154
+ sampleCount: tracks.reduce((sum, track) => sum + track.samples.length, 0),
155
+ bytes: Buffer.byteLength(serialized), sha256: createHash('sha256').update(serialized).digest('hex'),
156
+ }, payload })
157
+ }
158
+ const aircraft = snapshot.tracks.map(({ samples: _samples, ...track }) => ({
159
+ ...track, icaoAddress: track.icaoAddress ?? track.id,
160
+ chunkIds: chunks.filter(({ descriptor }) => track.end >= descriptor.windowStart && track.start <= descriptor.windowEnd)
161
+ .map(({ descriptor }) => descriptor.id),
162
+ }))
163
+ return { manifest: {
164
+ metadata: snapshot.metadata, bounds: snapshot.bounds, trackCount: snapshot.tracks.length,
165
+ sampleCount: snapshot.tracks.reduce((sum, track) => sum + track.samples.length, 0),
166
+ aircraft, chunks: chunks.map(chunk => chunk.descriptor),
167
+ }, chunks }
168
+ }
169
+
170
+ function sourceUrl(date) {
171
+ return `https://github.com/adsblol/globe_history_${date.slice(0, 4)}/releases/tag/v${date.replaceAll('-', '.')}-planes-readsb-prod-0`
172
+ }
173
+
174
+ /** Compile cached gzip heatmaps. The caller owns source selection, timezone and output location; no network calls. */
175
+ export async function ingestAdsbHeatmaps(options) {
176
+ serviceDayStart(options)
177
+ if (!options.bounds) throw new Error('Study bounds are required')
178
+ if (!options.inputs?.length) throw new Error('At least one cached heatmap input is required')
179
+ if (options.chunkHours !== undefined && (!Number.isInteger(options.chunkHours) || options.chunkHours < 1)) throw new Error('chunkHours must be a positive integer')
180
+ if (!options.output?.endsWith('.json')) throw new Error('An output .json path is required')
181
+ if (options.timezone) new Intl.DateTimeFormat('en', { timeZone: options.timezone })
182
+ const records = new Map(), sourceFiles = []
183
+ // Preserve caller ordering for deterministic first-observation wins when input slices overlap.
184
+ for (const input of options.inputs) {
185
+ const raw = await readFile(input), name = basename(input)
186
+ const date = /^\d{4}-\d{2}-\d{2}/.exec(name)?.[0] ?? options.serviceDate
187
+ sourceFiles.push({ name, sha256: createHash('sha256').update(raw).digest('hex'), sourceUrl: sourceUrl(date) })
188
+ decodeAdsbHeatmap(gunzipSync(raw), options, (address, sample) => {
189
+ const id = address.toString(16).padStart(6, '0')
190
+ const record = records.get(id) ?? { id, callsign: '', samples: [] }
191
+ record.callsign = sample[5] || record.callsign
192
+ record.samples.push([sample[0], round(sample[1], 5), round(sample[2], 5), sample[3], round(sample[4], 1), record.callsign])
193
+ records.set(id, record)
194
+ })
195
+ }
196
+ const tracks = transportAirTracks(records.values(), { splitTracks: options.splitTracks ?? true })
197
+ const [minLongitude, minLatitude, maxLongitude, maxLatitude] = options.bounds
198
+ const snapshot = {
199
+ metadata: {
200
+ publisher: 'ADSB.lol', ...(options.timezone ? { timezone: options.timezone } : {}),
201
+ utcOffsetHours: options.utcOffsetHours, sourceFiles,
202
+ serviceDate: options.serviceDate, windowStart: options.windowStart, windowEnd: options.windowEnd,
203
+ sourceUrl: sourceUrl(options.serviceDate), license: 'ODbL 1.0',
204
+ licenseUrl: 'https://opendatacommons.org/licenses/odbl/1-0/',
205
+ model: 'Observed ADS-B / MLAT heatmap positions replayed at 10-second resolution',
206
+ note: 'Ground, non-ICAO, low-speed and low-altitude signals are excluded; altitude is rendered with explicit vertical compression.',
207
+ sampleIntervalSeconds: 10,
208
+ },
209
+ bounds: { minLongitude, minLatitude, maxLongitude, maxLatitude }, tracks,
210
+ }
211
+ if (options.chunkHours !== undefined) {
212
+ const { manifest, chunks } = chunkAirSnapshot(snapshot, {
213
+ chunkSeconds: options.chunkHours * 3600, stem: basename(options.output, '.json').replace(/-manifest$/, ''),
214
+ })
215
+ for (const { descriptor, payload } of chunks) {
216
+ await writeFile(join(dirname(options.output), descriptor.path), `${JSON.stringify(payload)}\n`)
217
+ }
218
+ await writeFile(options.output, `${JSON.stringify(manifest)}\n`)
219
+ return manifest
220
+ }
221
+ await writeFile(options.output, `${JSON.stringify(snapshot)}\n`)
222
+ return snapshot
223
+ }
package/air-endpoints.mjs CHANGED
@@ -3,6 +3,7 @@ import { dirname, join } from 'node:path'
3
3
  import { gunzipSync } from 'node:zlib'
4
4
  import { createHash } from 'node:crypto'
5
5
  import { parseCsvLine } from './gtfs.mjs'
6
+ import { decodeAdsbHeatmap } from './adsb-heatmap.mjs'
6
7
 
7
8
  export function parseAirports(csv) {
8
9
  const [header, ...lines] = csv.trim().split(/\r?\n/)
@@ -71,36 +72,19 @@ export async function enrichAirEndpoints({ manifestPath, snapshotPaths = [], hea
71
72
  const tracks = [...manifest.aircraft, ...snapshots.flatMap(snapshot => snapshot.tracks)]
72
73
  const wanted = new Set(tracks.map(track => parseInt(track.icaoAddress ?? track.id.split('-')[0], 16)))
73
74
  const records = new Map()
74
- const dayStart = Date.parse(`${date}T00:00:00Z`) - utcOffsetHours * 3600000
75
75
  const files = (await readdir(heatmapDirectory)).filter(file => file.endsWith('.bin.ttf')).sort()
76
76
  if (!files.length) throw new Error('No cached heatmap slices found')
77
77
  const sources = []
78
78
  for (const file of files) {
79
79
  const raw = await readFile(join(heatmapDirectory, file))
80
- const buffer = gunzipSync(raw)
81
- let time
82
- const callsigns = new Map()
83
- let used = false
84
- for (let offset = 0; offset + 16 <= buffer.length; offset += 16) {
85
- const address = buffer.readUInt32LE(offset), latitude = buffer.readInt32LE(offset + 4)
86
- if (address === 0x0e7f7c9d) {
87
- time = Math.round((Number((BigInt(latitude) << 32n) | BigInt(buffer.readUInt32LE(offset + 8))) - dayStart) / 1000)
88
- continue
89
- }
90
- const id = address & 0xffffff
91
- if (!wanted.has(id) || (address & 0x01000000)) continue
92
- if (latitude >= 0 && (latitude & 0x40000000) !== 0) {
93
- callsigns.set(id, buffer.subarray(offset + 8, offset + 16).toString('ascii').replaceAll('\0', '').trim().toUpperCase())
94
- continue
95
- }
96
- if (time === undefined || time < 0 || time > 86400) continue
97
- const altitude = buffer.readInt16LE(offset + 12), speed = buffer.readInt16LE(offset + 14)
98
- if (altitude < 0 || speed < 0) continue
80
+ const used = decodeAdsbHeatmap(gunzipSync(raw), {
81
+ serviceDate: date, utcOffsetHours, windowStart: 0, windowEnd: 86400, addresses: wanted,
82
+ }, (id, sample) => {
99
83
  const samples = records.get(id) ?? []
100
- samples.push([time, buffer.readInt32LE(offset + 8) / 1e6, latitude / 1e6, altitude * 25, speed / 10, callsigns.get(id) ?? samples.at(-1)?.[5] ?? ''])
84
+ sample[5] ??= samples.at(-1)?.[5] ?? ''
85
+ samples.push(sample)
101
86
  records.set(id, samples)
102
- used = true
103
- }
87
+ })
104
88
  if (used) sources.push({ file, sha256: createHash('sha256').update(raw).digest('hex') })
105
89
  }
106
90
  const csv = await readFile(airportCsvPath, 'utf8')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motionstudies/data",
3
- "version": "0.1.0-alpha.5",
3
+ "version": "0.1.0-alpha.7",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Offline data preparation primitives for Motion Studies.",
@@ -23,6 +23,10 @@
23
23
  "node": ">=24.0.0"
24
24
  },
25
25
  "exports": {
26
+ "./adsb-heatmap": {
27
+ "types": "./adsb-heatmap.d.mts",
28
+ "import": "./adsb-heatmap.mjs"
29
+ },
26
30
  "./air-endpoints": {
27
31
  "types": "./air-endpoints.d.mts",
28
32
  "import": "./air-endpoints.mjs"
@@ -55,6 +59,6 @@
55
59
  ],
56
60
  "sideEffects": false,
57
61
  "dependencies": {
58
- "@motionstudies/core": "0.1.0-alpha.5"
62
+ "@motionstudies/core": "0.1.0-alpha.7"
59
63
  }
60
64
  }