acmi-parser 1.2.0 → 1.2.1

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 (2) hide show
  1. package/README.md +388 -0
  2. package/package.json +1 -1
package/README.md ADDED
@@ -0,0 +1,388 @@
1
+ # acmi-parser
2
+
3
+ Parse Tacview ACMI 2.1 and 2.2 recordings in Node.js or the browser.
4
+
5
+ [Open the browser demo](https://jfayot.github.io/acmi-parser/)
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install acmi-parser
11
+ ```
12
+
13
+ ## Quick start
14
+
15
+ For most use cases, use the one-shot `parseAcmi` function. It accepts ACMI text,
16
+ binary data, or a browser `Blob`/`File`, and automatically handles single-file
17
+ ZIP archives.
18
+
19
+ ```ts
20
+ import { parseAcmi } from "acmi-parser";
21
+
22
+ const recording = await parseAcmi(file, {
23
+ excludedTypes: ["Weapon", "Projectile"],
24
+ });
25
+
26
+ console.log(recording.entities, recording.frames, recording.timeSpan);
27
+ ```
28
+
29
+ In Node.js, `Buffer` can be passed directly:
30
+
31
+ ```ts
32
+ import { readFile } from "node:fs/promises";
33
+ import { parseAcmi } from "acmi-parser";
34
+
35
+ const recording = await parseAcmi(await readFile("flight.zip.acmi"));
36
+ ```
37
+
38
+ ## Inputs and parser options
39
+
40
+ `parseAcmi()` and `AcmiParser.parse()` accept the following input directly:
41
+
42
+ | Input | Typical source |
43
+ | ----------------- | ----------------------------------------------- |
44
+ | `string` | Uncompressed ACMI text |
45
+ | `ArrayBuffer` | `Response.arrayBuffer()` |
46
+ | `ArrayBufferView` | `Uint8Array`, `DataView`, or a Node.js `Buffer` |
47
+ | `Blob` | A browser `Blob` or `File` |
48
+
49
+ Binary input may contain either plain ACMI text or a ZIP archive. A ZIP archive
50
+ must contain exactly one non-directory entry.
51
+
52
+ ```ts
53
+ interface AcmiParserOptions {
54
+ excludedTypes?: readonly string[];
55
+ signal?: AbortSignal;
56
+ }
57
+ ```
58
+
59
+ `excludedTypes` removes an entity when any of its ACMI `Type` components matches
60
+ the list. Use `"Untyped"` to remove entities without a `Type` property.
61
+
62
+ The legacy `filter` and `controller` options remain available for compatibility,
63
+ but new code should use `excludedTypes` and `signal`.
64
+
65
+ ## Parsed output
66
+
67
+ Both parsing entry points resolve to an `AcmiData` instance:
68
+
69
+ ```ts
70
+ class AcmiData {
71
+ isValid: boolean;
72
+ header: Header;
73
+ globalProperties: GlobalProperties;
74
+ timeSpan: TimeSpan;
75
+ entities: Map<number, Entity>;
76
+ frames: Frame[];
77
+
78
+ getFrame(time: Dayjs): Frame | undefined;
79
+ createSampledTrajectories(options?: TrajectoryOptions): Trajectories;
80
+ }
81
+ ```
82
+
83
+ The maps use numeric entity IDs. ACMI hexadecimal IDs such as `a` are exposed as
84
+ their numeric value, for example `data.entities.get(0x0a)`.
85
+
86
+ ### Validation and header
87
+
88
+ `isValid` reports basic structural validation, including the ACMI header,
89
+ property syntax, and the reference time required by parsed frames. It is not a
90
+ schema validator for every custom property. Unsupported ACMI types or versions
91
+ return an `AcmiData` object with `isValid === false`; they do not throw.
92
+
93
+ | `Header` field | Type | Description |
94
+ | -------------- | -------- | ---------------------------------------------------------- |
95
+ | `fileType` | `string` | Header `FileType`; supported value is `text/acmi/tacview` |
96
+ | `fileVersion` | `string` | Header `FileVersion`; supported values are `2.1` and `2.2` |
97
+
98
+ ### Global properties
99
+
100
+ | `GlobalProperties` field | Type | Description |
101
+ | ------------------------ | ---------------------------------- | ----------------------------------------------- |
102
+ | `referenceTime` | `Dayjs` | Absolute origin used for frame and entity times |
103
+ | `recordingTime` | `Dayjs \| undefined` | Recording timestamp, when provided |
104
+ | `dataSource` | `string \| undefined` | Source application or system |
105
+ | `dataRecorder` | `string \| undefined` | Recorder name |
106
+ | `author` | `string \| undefined` | Recording author |
107
+ | `title` | `string \| undefined` | Recording title |
108
+ | `category` | `string \| undefined` | Recording category |
109
+ | `briefing` | `string \| undefined` | Briefing text |
110
+ | `debriefing` | `string \| undefined` | Debriefing text |
111
+ | `comments` | `string \| undefined` | Recording comments |
112
+ | `referenceLongitude` | `number \| undefined` | Longitude offset in degrees |
113
+ | `referenceLatitude` | `number \| undefined` | Latitude offset in degrees |
114
+ | `additionalProps` | `Map<string, string> \| undefined` | Unrecognized global ACMI properties |
115
+
116
+ Day.js instances provide methods such as `toISOString()`, `add()`, and `diff()`.
117
+ An absent or invalid reference time is represented by an invalid Day.js value;
118
+ check it with `referenceTime.isValid()`.
119
+
120
+ ### Time spans
121
+
122
+ `data.timeSpan` describes the recording range, while every `Entity` has its own
123
+ active range.
124
+
125
+ ```ts
126
+ class TimeSpan {
127
+ start: Dayjs;
128
+ end: Dayjs;
129
+ isValid(): boolean;
130
+ duration(): number;
131
+ }
132
+ ```
133
+
134
+ `duration()` returns seconds, including fractional seconds, or `-1` when either
135
+ endpoint is invalid.
136
+
137
+ ### Entities
138
+
139
+ `data.entities` contains metadata keyed by numeric entity ID:
140
+
141
+ ```ts
142
+ class Entity {
143
+ id: number;
144
+ timeSpan: TimeSpan;
145
+ name?: string;
146
+ types?: string[];
147
+ callsign?: string;
148
+ pilot?: string;
149
+ group?: string;
150
+ country?: string;
151
+ coalition?: string;
152
+ color?: string;
153
+ }
154
+ ```
155
+
156
+ `types` contains the components of the ACMI `Type` value split on `+`. Entity
157
+ metadata is stored once; position and orientation over time are stored in
158
+ frames.
159
+
160
+ ### Frames and transforms
161
+
162
+ Each frame is a scene snapshot:
163
+
164
+ ```ts
165
+ type Scene = Map<number, Transform>;
166
+
167
+ class Frame {
168
+ timeStamp: number;
169
+ scene: Scene;
170
+ }
171
+ ```
172
+
173
+ `timeStamp` is the number of seconds relative to
174
+ `globalProperties.referenceTime`. `scene` maps every active, non-excluded entity
175
+ ID to its transform at that frame.
176
+
177
+ ```ts
178
+ class Transform {
179
+ position: Vector3;
180
+ orientation?: Euler;
181
+ }
182
+ ```
183
+
184
+ Transform components use these units:
185
+
186
+ | Value | Meaning | Unit |
187
+ | ------------------- | --------- | ------- |
188
+ | `position.x` | Longitude | Degrees |
189
+ | `position.y` | Latitude | Degrees |
190
+ | `position.z` | Altitude | Metres |
191
+ | `orientation.roll` | Roll | Radians |
192
+ | `orientation.pitch` | Pitch | Radians |
193
+ | `orientation.yaw` | Yaw | Radians |
194
+
195
+ `orientation` is `undefined` until the source supplies orientation components.
196
+ When a PGM geoid is supplied to the parser constructor, its height is added to
197
+ the ACMI altitude.
198
+
199
+ Use `getFrame()` with an absolute Day.js time to retrieve the most recent frame
200
+ at or before that time. It returns `undefined` outside the recording time span.
201
+
202
+ ```ts
203
+ import dayjs from "dayjs";
204
+
205
+ const frame = recording.getFrame(dayjs("2024-01-02T03:04:10Z"));
206
+ const transform = frame?.scene.get(entityId);
207
+
208
+ if (transform) {
209
+ console.log(transform.position.x, transform.position.y, transform.position.z);
210
+ }
211
+ ```
212
+
213
+ ## Reusing a parser
214
+
215
+ Create an `AcmiParser` when parsing more than one recording or when supplying a
216
+ PGM geoid model:
217
+
218
+ ```ts
219
+ function parseAcmi(
220
+ data: AcmiInput,
221
+ options?: AcmiParserOptions,
222
+ ): Promise<AcmiData>;
223
+
224
+ class AcmiParser {
225
+ constructor(geoidPgm?: AcmiBinaryInput);
226
+ parse(data: AcmiInput, options?: AcmiParserOptions): Promise<AcmiData>;
227
+ }
228
+ ```
229
+
230
+ ```ts
231
+ import { AcmiParser } from "acmi-parser";
232
+
233
+ const parser = new AcmiParser(geoidPgm);
234
+ const first = await parser.parse(firstFile);
235
+ const second = await parser.parse(secondFile);
236
+ ```
237
+
238
+ The geoid argument accepts `ArrayBuffer` or any `ArrayBufferView`, including a
239
+ Node.js `Buffer`. Parser instances are reusable sequentially; each `parse()` call
240
+ replaces the instance's previous parsing state. The class remains available as
241
+ the package's default export for compatibility.
242
+
243
+ ## Cancellation
244
+
245
+ Pass an `AbortSignal` to cancel input processing or ZIP decompression:
246
+
247
+ ```ts
248
+ const controller = new AbortController();
249
+ const result = parseAcmi(file, { signal: controller.signal });
250
+
251
+ controller.abort();
252
+ await result;
253
+ ```
254
+
255
+ Compressed-input failures reject with `AcmiParseError`. Its `code` is suitable
256
+ for programmatic handling:
257
+
258
+ ```ts
259
+ import { AcmiParseError, parseAcmi } from "acmi-parser";
260
+
261
+ try {
262
+ await parseAcmi(file);
263
+ } catch (error) {
264
+ if (error instanceof AcmiParseError && error.code === "INVALID_ARCHIVE") {
265
+ // Show an actionable message to the user.
266
+ }
267
+ }
268
+ ```
269
+
270
+ | Error code | Meaning |
271
+ | ------------------- | ---------------------------------------------------------- |
272
+ | `INVALID_ARCHIVE` | The ZIP is unreadable or does not contain exactly one file |
273
+ | `UNSUPPORTED_INPUT` | A JavaScript caller supplied an unsupported runtime value |
274
+
275
+ Cancellation rejects with the `AbortSignal`'s reason, normally an `AbortError`.
276
+ `AcmiParseError.cause` retains the underlying ZIP error when one is available.
277
+
278
+ ## Trajectories
279
+
280
+ Parsed recordings can produce regularly sampled trajectories:
281
+
282
+ ```ts
283
+ const trajectories = recording.createSampledTrajectories({
284
+ sampleRate: 1,
285
+ emulateOrientation: true,
286
+ });
287
+ ```
288
+
289
+ ```ts
290
+ interface TrajectoryOptions {
291
+ sampleRate?: number;
292
+ emulateOrientation?: boolean;
293
+ }
294
+
295
+ type Trajectories = Map<number, Trajectory>;
296
+
297
+ class Trajectory {
298
+ samples: TrajectorySample[];
299
+ hasOrientations(): boolean;
300
+ emulateOrientations(withRoll?: boolean): void;
301
+ }
302
+
303
+ interface TrajectorySample {
304
+ time: Dayjs;
305
+ stateVector: StateVector;
306
+ }
307
+
308
+ class StateVector {
309
+ cartesian: Vector3;
310
+ quaternion?: Quaternion;
311
+ }
312
+ ```
313
+
314
+ `sampleRate` defaults to `1` second and must be finite and greater than zero.
315
+ Samples whose state is unchanged are omitted, while the recording's final state
316
+ is always retained.
317
+
318
+ `stateVector.cartesian` is a WGS84 Earth-centred, Earth-fixed Cartesian position
319
+ in metres. When present, `stateVector.quaternion` contains the fixed-frame
320
+ orientation as `x`, `y`, `z`, and `w` components.
321
+
322
+ If `emulateOrientation` is `true`, orientations are derived from the
323
+ trajectory's velocity and turning motion and assigned to every sample, replacing
324
+ source orientations. `Trajectory.emulateOrientations()` can also perform this
325
+ operation later and mutates the trajectory's samples. Its optional `withRoll`
326
+ argument enables turn-based bank-angle estimation.
327
+
328
+ ```ts
329
+ for (const [entityId, trajectory] of trajectories) {
330
+ for (const sample of trajectory.samples) {
331
+ const { x, y, z } = sample.stateVector.cartesian;
332
+ const orientation = sample.stateVector.quaternion;
333
+ console.log(entityId, sample.time.toISOString(), x, y, z, orientation?.w);
334
+ }
335
+ }
336
+ ```
337
+
338
+ ## Public exports
339
+
340
+ The package provides both ESM and CommonJS builds. `AcmiParser` is available as
341
+ both a named and default export.
342
+
343
+ | Export | Kind | Purpose |
344
+ | ------------------------------ | -------- | -------------------------------------------------- |
345
+ | `parseAcmi` | Function | Parse one recording with a fresh parser |
346
+ | `AcmiParser` | Class | Reusable parser and optional geoid configuration |
347
+ | `AcmiParseError` | Class | Typed compressed-input error |
348
+ | `AcmiData` | Class | Parsed recording and query methods |
349
+ | `Header` | Class | ACMI header values |
350
+ | `GlobalProperties` | Class | Recording-level metadata |
351
+ | `TimeSpan` | Class | Absolute start/end and duration helper |
352
+ | `Entity` | Class | Entity metadata and active time span |
353
+ | `Frame` | Class | Timestamped scene snapshot |
354
+ | `Transform` | Class | Geodetic position and optional orientation |
355
+ | `Trajectory` | Class | Ordered trajectory samples and orientation helpers |
356
+ | `StateVector` | Class | ECEF Cartesian position and optional quaternion |
357
+ | `AcmiInput`, `AcmiBinaryInput` | Types | Accepted parser inputs |
358
+ | `AcmiParserOptions` | Type | Filtering and cancellation options |
359
+ | `AcmiParseErrorCode` | Type | Stable parser error codes |
360
+ | `Scene` | Type | Entity-to-transform map |
361
+ | `TrajectoryOptions` | Type | Trajectory sampling options |
362
+ | `Trajectories` | Type | Entity-to-trajectory map |
363
+ | `TrajectorySample` | Type | Timestamped state vector |
364
+
365
+ `ITrajectoryOptions` and `ITrajectorySample` are deprecated aliases retained for
366
+ source compatibility.
367
+
368
+ ## Development and releases
369
+
370
+ Pull requests and pushes to `main` run the Vitest suite and build the library and
371
+ both demos. User-facing changes should include a changeset:
372
+
373
+ ```sh
374
+ pnpm changeset
375
+ ```
376
+
377
+ On `main`, the release workflow maintains a Changesets version pull request.
378
+ Merging that pull request publishes `acmi-parser`, creates the corresponding
379
+ GitHub release, and attaches npm provenance.
380
+
381
+ Repository maintainers must allow GitHub Actions to create pull requests. npm
382
+ publishing uses the trusted publisher configured for
383
+ `.github/workflows/release.yml`; no long-lived npm token is required. The
384
+ workflow grants the `id-token: write` permission needed for npm's OIDC exchange.
385
+
386
+ The browser demo is deployed to GitHub Pages after every push to `main`. Its
387
+ Pages build uses `/acmi-parser/` as the Vite base path while local development
388
+ continues to use `/`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acmi-parser",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "ACMI file format parser",
5
5
  "author": "jfayot",
6
6
  "repository": {