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.
- package/README.md +388 -0
- 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 `/`.
|