From e23c1aee905b507b55ce9c4079a5bc685ac98c58 Mon Sep 17 00:00:00 2001 From: Chris Myers Date: Thu, 26 Mar 2026 00:57:20 +0000 Subject: [PATCH 1/4] GPX 1.1 Full implementation --- README.md | 182 ++++++++-- package.json | 2 +- src/GpxExport.ts | 437 ++++++++++++++++++++---- src/index.ts | 3 +- src/types/GpxAuthor.ts | 6 + src/types/GpxBounds.ts | 6 + src/types/GpxCopyright.ts | 5 + src/types/GpxDocument.ts | 12 + src/types/GpxLink.ts | 5 + src/types/GpxMetadata.ts | 21 ++ src/types/GpxOptions.ts | 16 + src/types/GpxPoint.ts | 12 + src/types/GpxPointExtensions.ts | 10 + src/types/GpxRoute.ts | 11 + src/types/GpxRoutePoint.ts | 10 + src/types/GpxTrack.ts | 15 + src/types/GpxTrackSegment.ts | 6 + src/types/GpxWaypoint.ts | 13 + src/types/index.ts | 15 + tests/generateGpx.test.ts | 570 +++++++++++++++++++++++++++++++- 20 files changed, 1237 insertions(+), 120 deletions(-) create mode 100644 src/types/GpxAuthor.ts create mode 100644 src/types/GpxBounds.ts create mode 100644 src/types/GpxCopyright.ts create mode 100644 src/types/GpxDocument.ts create mode 100644 src/types/GpxLink.ts create mode 100644 src/types/GpxMetadata.ts create mode 100644 src/types/GpxOptions.ts create mode 100644 src/types/GpxPoint.ts create mode 100644 src/types/GpxPointExtensions.ts create mode 100644 src/types/GpxRoute.ts create mode 100644 src/types/GpxRoutePoint.ts create mode 100644 src/types/GpxTrack.ts create mode 100644 src/types/GpxTrackSegment.ts create mode 100644 src/types/GpxWaypoint.ts create mode 100644 src/types/index.ts diff --git a/README.md b/README.md index 1f477f2..d99ba1f 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,12 @@ -# **gpx-export** +# gpx-export -Zero‑dependency GPX 1.1 generator with optional Garmin TrackPointExtension v2 support. -Works in Node.js, browsers, Capacitor, and any JS runtime. +Zero-dependency GPX 1.1 generator for Node.js, browsers, Capacitor, and other JS runtimes. ---- +It supports: +- Single-method generation via `generateGpx` with `GpxDocument` input +- Backward-compatible `generateGpx(track, options)` support via tracks-only wrapper +- Waypoints, routes, tracks, and metadata +- Garmin TrackPointExtension v2 (`gpxtpx`) for speed, heart rate, and cadence ## Install @@ -11,71 +14,182 @@ Works in Node.js, browsers, Capacitor, and any JS runtime. npm install gpx-export ``` ---- - -## Usage +## Quick Start ```ts import { generateGpx } from 'gpx-export'; -const gpx = generateGpx({ - name: 'Morning Ride', - points: [ - { - lat: 54.5741, - lon: -1.3180, - time: new Date(), - elevation: 32.4, - speed: 5.2, // m/s +const now = new Date(); + +const gpx = generateGpx( + { + metadata: { + time: now, }, - ], -}); + tracks: [ + { + name: 'Morning Ride', + points: [ + { + lat: 54.5741, + lon: -1.318, + time: now, + elevation: 32.4, + speed: 5.2, + }, + ], + }, + ], + }, + { + creator: 'my-app', + }, +); console.log(gpx); ``` ---- +## Full Document Example + +```ts +import { generateGpx } from 'gpx-export'; + +const now = new Date('2026-01-01T12:00:00.000Z'); + +const gpx = generateGpx( + { + metadata: { + name: 'My Export', + desc: 'Generated by gpx-export', + author: { name: 'Chris' }, + link: { href: 'https://github.com/cmyers/gpx-export' }, + time: now, + keywords: 'gps,gpx,export', + }, + waypoints: [ + { + lat: 54.5, + lon: -1.3, + name: 'Start', + time: now, + }, + ], + routes: [ + { + name: 'Route 1', + points: [ + { lat: 54.5, lon: -1.3, time: now }, + { lat: 54.6, lon: -1.2, time: now }, + ], + }, + ], + tracks: [ + { + name: 'Track 1', + segments: [ + { + points: [ + { + lat: 54.5, + lon: -1.3, + time: now, + elevation: 20.12, + extensions: { + speed: 3.45, + heartRate: 152, + cadence: 86, + }, + }, + ], + }, + ], + }, + ], + }, + { + creator: 'my-app', + bounds: { + minLat: 54.4, + minLon: -1.4, + maxLat: 54.7, + maxLon: -1.1, + }, + }, +); +``` ## API -### `generateGpx(track: GpxTrack, options?: GpxOptions): string` +### generateGpx(document: GpxDocument, options?: GpxOptions): string + +Generates a GPX 1.1 document from a full GPX document object. + +Backward compatibility: +- Passing a `GpxTrack` is still supported. +- Track input is wrapped as `{ tracks: [track] }` and then processed as a document. +- Optional metadata can be supplied with `options.metadata`. + +### generateGpxDocument(document: GpxDocument, options?: GpxOptions): string -Returns a GPX 1.1 XML document as a string. +Compatibility alias for `generateGpx(document, options)`. ---- +Merge behavior: +- `options.metadata` shallow-merges into `document.metadata`. +- `options.waypoints` and `options.routes` are appended. +- `options.bounds` writes `metadata.bounds`. -## Types +## Supported Types (Summary) ```ts +interface GpxPointExtensions { + speed?: number; // gpxtpx:speed (m/s) + heartRate?: number; // gpxtpx:hr (bpm) + cadence?: number; // gpxtpx:cad (rpm) + rawXml?: string; // trusted XML inside +} + interface GpxPoint { lat: number; lon: number; time: Date; - speed?: number; // m/s — gpxtpx:speed - elevation?: number; // metres — + speed?: number; // legacy alias for extensions.speed + elevation?: number; // + extensions?: GpxPointExtensions; } interface GpxTrack { name: string; - createdAt?: Date; // defaults to first point time - points: GpxPoint[]; + createdAt?: Date; + points?: GpxPoint[]; + segments?: { points: GpxPoint[] }[]; + cmt?: string; + desc?: string; + extensions?: GpxPointExtensions; } interface GpxOptions { - creator?: string; // defaults to "gpx-export" + creator?: string; // default: "gpx-export" + metadata?: GpxMetadata; + waypoints?: GpxWaypoint[]; + routes?: GpxRoute[]; + bounds?: GpxBounds; + extensionsXml?: string; // trusted XML in root } ``` ---- +All exported type definitions are available from the package root. ## Notes -- Adds Garmin `gpxtpx` namespace only when speed values are present. -- If `createdAt` is omitted, metadata time uses the first point time; if there are no points, it falls back to the current time. -- Elevation is formatted to 2 decimal places; speed is formatted to 4 decimal places. -- Saving/downloading the GPX is left to the caller. +- Elevation is formatted to 2 decimal places. +- Speed is formatted to 4 decimal places. +- The Garmin `gpxtpx` namespace is included when Garmin point extensions are present. +- `extensionsXml` and `rawXml` values are inserted as trusted XML and are not escaped. +- Element output order is deterministic for identical inputs. + +## Scope ---- +This library implements a practical subset of GPX 1.1 and Garmin TrackPointExtension v2 used by this package types. ## License diff --git a/package.json b/package.json index 16036c8..d113cf4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gpx-export", - "version": "0.0.1", + "version": "0.1.0", "author": "Chris Myers (https://www.npmjs.com/~cmyers-dev)", "description": "Export to GPX format from GPS data", "type": "module", diff --git a/src/GpxExport.ts b/src/GpxExport.ts index 8103b2d..46e927b 100644 --- a/src/GpxExport.ts +++ b/src/GpxExport.ts @@ -1,26 +1,19 @@ // GpxExport — zero-dependency GPX 1.1 generator with Garmin TrackPointExtension v2 support -export interface GpxPoint { - lat: number; - lon: number; - time: Date; - /** Speed in m/s — written as gpxtpx:speed (Garmin TrackPointExtension v2) */ - speed?: number; - /** Elevation in metres — written as standard GPX */ - elevation?: number; -} - -export interface GpxTrack { - name: string; - /** Defaults to the time of the first point when omitted */ - createdAt?: Date; - points: GpxPoint[]; -} - -export interface GpxOptions { - /** Value written to the GPX creator attribute. Defaults to 'gpx-export' */ - creator?: string; -} +import type { + GpxBounds, + GpxDocument, + GpxLink, + GpxMetadata, + GpxOptions, + GpxPoint, + GpxPointExtensions, + GpxRoute, + GpxRoutePoint, + GpxTrack, + GpxTrackSegment, + GpxWaypoint, +} from './types'; function escapeXml(value: string): string { return value @@ -30,61 +23,363 @@ function escapeXml(value: string): string { .replace(/"/g, '"'); } -export function generateGpx(track: GpxTrack, options: GpxOptions = {}): string { - const creator = escapeXml(options.creator ?? 'gpx-export'); - const name = escapeXml(track.name); - const createdAt = (track.createdAt ?? track.points[0]?.time ?? new Date()).toISOString(); - - const useGarminExtension = track.points.some((p) => p.speed !== undefined); - - const trkpts = track.points - .map((p) => { - const lines = [ - ` `, - ...(p.elevation !== undefined ? [` ${p.elevation.toFixed(2)}`] : []), - ` `, - ]; - if (p.speed !== undefined) { - lines.push( - ` `, - ` `, - ` ${p.speed.toFixed(4)}`, - ` `, - ` `, - ); +function indentLines(lines: string[], level: number): string[] { + const indent = ' '.repeat(level); + return lines.map((line) => `${indent}${line}`); +} + +function xmlTag(name: string, value: string): string { + return `<${name}>${escapeXml(value)}`; +} + +function xmlTagNumber(name: string, value: number, precision?: number): string { + const rendered = precision !== undefined ? value.toFixed(precision) : String(value); + return `<${name}>${rendered}`; +} + +function toIso(value?: Date): string | undefined { + return value ? value.toISOString() : undefined; +} + +function hasValue(value: string | undefined): value is string { + return Boolean(value && value.length > 0); +} + +function resolveTrackSegments(track: GpxTrack): GpxTrackSegment[] { + if (track.segments && track.segments.length > 0) { + return track.segments; + } + return [{ points: track.points ?? [] }]; +} + +function normalizePointExtensions(legacySpeed: number | undefined, extensions: GpxPointExtensions | undefined): GpxPointExtensions | undefined { + const normalized: GpxPointExtensions = { + speed: extensions?.speed ?? legacySpeed, + heartRate: extensions?.heartRate, + cadence: extensions?.cadence, + rawXml: extensions?.rawXml, + }; + + if ( + normalized.speed === undefined + && normalized.heartRate === undefined + && normalized.cadence === undefined + && !hasValue(normalized.rawXml) + ) { + return undefined; + } + + return normalized; +} + +function hasGarminPointExtensions(ext: GpxPointExtensions | undefined): boolean { + return Boolean(ext?.speed !== undefined || ext?.heartRate !== undefined || ext?.cadence !== undefined); +} + +function renderBounds(bounds: GpxBounds): string { + return ``; +} + +function renderLink(link: GpxLink): string[] { + const lines: string[] = [``]; + if (link.text) { + lines.push(` ${xmlTag('text', link.text)}`); + } + if (link.type) { + lines.push(` ${xmlTag('type', link.type)}`); + } + lines.push(``); + return lines; +} + +function renderMetadata(metadata: GpxMetadata | undefined): string[] { + if (!metadata) { + return []; + } + + const lines: string[] = ['']; + if (metadata.name) { + lines.push(` ${xmlTag('name', metadata.name)}`); + } + if (metadata.desc) { + lines.push(` ${xmlTag('desc', metadata.desc)}`); + } + if (metadata.author) { + lines.push(` `); + lines.push(` ${xmlTag('name', metadata.author.name)}`); + if (metadata.author.link) { + lines.push(...indentLines(renderLink(metadata.author.link), 2)); + } + lines.push(` `); + } + if (metadata.copyright) { + if (typeof metadata.copyright === 'string') { + lines.push(` `); + } else { + lines.push(` `); + if (metadata.copyright.year !== undefined) { + lines.push(` ${xmlTagNumber('year', metadata.copyright.year)}`); + } + if (metadata.copyright.license) { + lines.push(` ${xmlTag('license', metadata.copyright.license)}`); } - lines.push(` `); - return lines.join('\n'); - }) - .join('\n'); + lines.push(` `); + } + } + if (metadata.link) { + lines.push(...indentLines(renderLink(metadata.link), 1)); + } + if (metadata.time) { + lines.push(` ${xmlTag('time', metadata.time.toISOString())}`); + } + if (metadata.keywords) { + lines.push(` ${xmlTag('keywords', metadata.keywords)}`); + } + if (metadata.bounds) { + lines.push(` ${renderBounds(metadata.bounds)}`); + } + lines.push(''); + return lines; +} - const garminAttrs = useGarminExtension - ? [ - ` xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v2"`, - ` xsi:schemaLocation="http://www.topografix.com/GPX/1/1 https://www.topografix.com/GPX/1/1/gpx.xsd`, - ` http://www.garmin.com/xmlschemas/TrackPointExtension/v2 https://www8.garmin.com/xmlschemas/TrackPointExtensionv2.xsd"`, - ] - : [ - ` xsi:schemaLocation="http://www.topografix.com/GPX/1/1 https://www.topografix.com/GPX/1/1/gpx.xsd"`, - ]; +function renderPointExtensions(extensions: GpxPointExtensions | undefined, level: number): string[] { + if (!extensions) { + return []; + } + + const lines: string[] = ['']; + if (hasGarminPointExtensions(extensions)) { + lines.push(` `); + if (extensions.speed !== undefined) { + lines.push(` ${xmlTagNumber('gpxtpx:speed', extensions.speed, 4)}`); + } + if (extensions.heartRate !== undefined) { + lines.push(` ${xmlTagNumber('gpxtpx:hr', extensions.heartRate)}`); + } + if (extensions.cadence !== undefined) { + lines.push(` ${xmlTagNumber('gpxtpx:cad', extensions.cadence)}`); + } + lines.push(` `); + } + + if (hasValue(extensions.rawXml)) { + lines.push(extensions.rawXml); + } + lines.push(''); + return indentLines(lines, level); +} + +function renderTrackPoint(point: GpxPoint, level: number): string[] { + const lines: string[] = [``]; + if (point.elevation !== undefined) { + lines.push(` ${xmlTagNumber('ele', point.elevation, 2)}`); + } + lines.push(` ${xmlTag('time', point.time.toISOString())}`); + + const extensions = normalizePointExtensions(point.speed, point.extensions); + lines.push(...renderPointExtensions(extensions, 1)); + lines.push(''); + return indentLines(lines, level); +} + +function renderRoutePoint(point: GpxRoutePoint, level: number): string[] { + const lines: string[] = [``]; + if (point.elevation !== undefined) { + lines.push(` ${xmlTagNumber('ele', point.elevation, 2)}`); + } + const iso = toIso(point.time); + if (iso) { + lines.push(` ${xmlTag('time', iso)}`); + } + lines.push(...renderPointExtensions(point.extensions, 1)); + lines.push(''); + return indentLines(lines, level); +} + +function renderWaypoint(waypoint: GpxWaypoint, level: number): string[] { + const lines: string[] = [``]; + if (waypoint.elevation !== undefined) { + lines.push(` ${xmlTagNumber('ele', waypoint.elevation, 2)}`); + } + const iso = toIso(waypoint.time); + if (iso) { + lines.push(` ${xmlTag('time', iso)}`); + } + if (waypoint.name) { + lines.push(` ${xmlTag('name', waypoint.name)}`); + } + if (waypoint.cmt) { + lines.push(` ${xmlTag('cmt', waypoint.cmt)}`); + } + if (waypoint.desc) { + lines.push(` ${xmlTag('desc', waypoint.desc)}`); + } + lines.push(...renderPointExtensions(waypoint.extensions, 1)); + lines.push(''); + return indentLines(lines, level); +} + +function renderRoute(route: GpxRoute, level: number): string[] { + const lines: string[] = ['']; + if (route.name) { + lines.push(` ${xmlTag('name', route.name)}`); + } + if (route.cmt) { + lines.push(` ${xmlTag('cmt', route.cmt)}`); + } + if (route.desc) { + lines.push(` ${xmlTag('desc', route.desc)}`); + } + lines.push(...renderPointExtensions(route.extensions, 1)); + for (const point of route.points) { + lines.push(...renderRoutePoint(point, 1)); + } + lines.push(''); + return indentLines(lines, level); +} + +function renderTrack(track: GpxTrack, level: number): string[] { + const lines: string[] = ['']; + lines.push(` ${xmlTag('name', track.name)}`); + if (track.cmt) { + lines.push(` ${xmlTag('cmt', track.cmt)}`); + } + if (track.desc) { + lines.push(` ${xmlTag('desc', track.desc)}`); + } + lines.push(...renderPointExtensions(track.extensions, 1)); + + for (const segment of resolveTrackSegments(track)) { + lines.push(' '); + for (const point of segment.points) { + lines.push(...renderTrackPoint(point, 2)); + } + lines.push(' '); + } + lines.push(''); + return indentLines(lines, level); +} - return [ +function shouldUseGarminExtension(document: GpxDocument, rootExtensionsXml?: string): boolean { + if (hasValue(rootExtensionsXml)) { + return true; + } + + const pointHasGarmin = (extensions: GpxPointExtensions | undefined): boolean => hasGarminPointExtensions(extensions); + + for (const waypoint of document.waypoints ?? []) { + if (pointHasGarmin(waypoint.extensions)) { + return true; + } + } + + for (const route of document.routes ?? []) { + if (pointHasGarmin(route.extensions)) { + return true; + } + for (const point of route.points) { + if (pointHasGarmin(point.extensions)) { + return true; + } + } + } + + for (const track of document.tracks ?? []) { + if (pointHasGarmin(track.extensions)) { + return true; + } + for (const segment of resolveTrackSegments(track)) { + for (const point of segment.points) { + const normalized = normalizePointExtensions(point.speed, point.extensions); + if (pointHasGarmin(normalized)) { + return true; + } + } + } + } + + return false; +} + +function mergeMetadata(base: GpxMetadata | undefined, incoming: GpxMetadata | undefined): GpxMetadata | undefined { + if (!base && !incoming) { + return undefined; + } + return { + ...(base ?? {}), + ...(incoming ?? {}), + }; +} + +function normalizeDocument(document: GpxDocument, options: GpxOptions): GpxDocument { + return { + metadata: mergeMetadata(document.metadata, options.metadata), + waypoints: [...(document.waypoints ?? []), ...(options.waypoints ?? [])], + routes: [...(document.routes ?? []), ...(options.routes ?? [])], + tracks: document.tracks ?? [], + }; +} + +export function generateGpxDocument(document: GpxDocument, options: GpxOptions = {}): string { + const normalized = normalizeDocument(document, options); + if (options.bounds) { + normalized.metadata = { + ...(normalized.metadata ?? {}), + bounds: options.bounds, + }; + } + + const creator = escapeXml(options.creator ?? 'gpx-export'); + const useGarminExtension = shouldUseGarminExtension(normalized, options.extensionsXml); + + const lines: string[] = [ ``, ``, - ` `, - ` ${name}`, - ` `, - ` `, - ` `, - ` ${name}`, - ` `, - trkpts, - ` `, - ` `, - ``, - ].join('\n'); + ]; + + if (useGarminExtension) { + lines.push( + ` xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v2"`, + ` xsi:schemaLocation="http://www.topografix.com/GPX/1/1 https://www.topografix.com/GPX/1/1/gpx.xsd`, + ` http://www.garmin.com/xmlschemas/TrackPointExtension/v2 https://www8.garmin.com/xmlschemas/TrackPointExtensionv2.xsd"`, + ); + } else { + lines.push(` xsi:schemaLocation="http://www.topografix.com/GPX/1/1 https://www.topografix.com/GPX/1/1/gpx.xsd"`); + } + lines.push('>'); + + lines.push(...indentLines(renderMetadata(normalized.metadata), 1)); + for (const waypoint of normalized.waypoints ?? []) { + lines.push(...renderWaypoint(waypoint, 1)); + } + for (const route of normalized.routes ?? []) { + lines.push(...renderRoute(route, 1)); + } + for (const track of normalized.tracks ?? []) { + lines.push(...renderTrack(track, 1)); + } + + if (hasValue(options.extensionsXml)) { + lines.push(' '); + lines.push(options.extensionsXml); + lines.push(' '); + } + + lines.push(``); + return lines.join('\n'); +} + +function isTrackInput(input: GpxTrack | GpxDocument): input is GpxTrack { + return 'name' in input; } + +export function generateGpx(input: GpxTrack | GpxDocument, options: GpxOptions = {}): string { + if (isTrackInput(input)) { + return generateGpxDocument({ tracks: [input] }, options); + } + + return generateGpxDocument(input, options); +} + diff --git a/src/index.ts b/src/index.ts index eee55da..a692d7a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,2 @@ -export * from "./GpxExport.js"; \ No newline at end of file +export * from "./GpxExport.js"; +export type * from "./types/index"; diff --git a/src/types/GpxAuthor.ts b/src/types/GpxAuthor.ts new file mode 100644 index 0000000..ae922d8 --- /dev/null +++ b/src/types/GpxAuthor.ts @@ -0,0 +1,6 @@ +import type { GpxLink } from './GpxLink'; + +export interface GpxAuthor { + name: string; + link?: GpxLink; +} diff --git a/src/types/GpxBounds.ts b/src/types/GpxBounds.ts new file mode 100644 index 0000000..2094824 --- /dev/null +++ b/src/types/GpxBounds.ts @@ -0,0 +1,6 @@ +export interface GpxBounds { + minLat: number; + minLon: number; + maxLat: number; + maxLon: number; +} diff --git a/src/types/GpxCopyright.ts b/src/types/GpxCopyright.ts new file mode 100644 index 0000000..119aa10 --- /dev/null +++ b/src/types/GpxCopyright.ts @@ -0,0 +1,5 @@ +export interface GpxCopyright { + author: string; + year?: number; + license?: string; +} diff --git a/src/types/GpxDocument.ts b/src/types/GpxDocument.ts new file mode 100644 index 0000000..7836278 --- /dev/null +++ b/src/types/GpxDocument.ts @@ -0,0 +1,12 @@ +import type { GpxMetadata } from './GpxMetadata'; +import type { GpxRoute } from './GpxRoute'; +import type { GpxTrack } from './GpxTrack'; +import type { GpxWaypoint } from './GpxWaypoint'; + +export interface GpxDocument { + metadata?: GpxMetadata; + waypoints?: GpxWaypoint[]; + routes?: GpxRoute[]; + tracks?: GpxTrack[]; +} + diff --git a/src/types/GpxLink.ts b/src/types/GpxLink.ts new file mode 100644 index 0000000..e1e8823 --- /dev/null +++ b/src/types/GpxLink.ts @@ -0,0 +1,5 @@ +export interface GpxLink { + href: string; + text?: string; + type?: string; +} diff --git a/src/types/GpxMetadata.ts b/src/types/GpxMetadata.ts new file mode 100644 index 0000000..86ec3ec --- /dev/null +++ b/src/types/GpxMetadata.ts @@ -0,0 +1,21 @@ +import type { GpxAuthor } from './GpxAuthor'; +import type { GpxBounds } from './GpxBounds'; +import type { GpxCopyright } from './GpxCopyright'; +import type { GpxLink } from './GpxLink'; + +export interface GpxMetadata { + name?: string; + desc?: string; + author?: GpxAuthor; + link?: GpxLink; + time?: Date; + keywords?: string; + /** + * Backward compatible shorthand: + * - string => rendered as + * - object => full GPX 1.1 copyright structure + */ + copyright?: string | GpxCopyright; + bounds?: GpxBounds; +} + diff --git a/src/types/GpxOptions.ts b/src/types/GpxOptions.ts new file mode 100644 index 0000000..84ca197 --- /dev/null +++ b/src/types/GpxOptions.ts @@ -0,0 +1,16 @@ +import type { GpxBounds } from './GpxBounds'; +import type { GpxMetadata } from './GpxMetadata'; +import type { GpxRoute } from './GpxRoute'; +import type { GpxWaypoint } from './GpxWaypoint'; + +export interface GpxOptions { + /** Value written to the GPX creator attribute. Defaults to 'gpx-export' */ + creator?: string; + metadata?: GpxMetadata; + waypoints?: GpxWaypoint[]; + routes?: GpxRoute[]; + bounds?: GpxBounds; + /** Trusted raw XML inserted into root without escaping */ + extensionsXml?: string; +} + diff --git a/src/types/GpxPoint.ts b/src/types/GpxPoint.ts new file mode 100644 index 0000000..ffb09f2 --- /dev/null +++ b/src/types/GpxPoint.ts @@ -0,0 +1,12 @@ +import type { GpxPointExtensions } from './GpxPointExtensions'; + +export interface GpxPoint { + lat: number; + lon: number; + time: Date; + /** Speed in m/s — written as gpxtpx:speed (Garmin TrackPointExtension v2) */ + speed?: number; + /** Elevation in metres — written as standard GPX */ + elevation?: number; + extensions?: GpxPointExtensions; +} diff --git a/src/types/GpxPointExtensions.ts b/src/types/GpxPointExtensions.ts new file mode 100644 index 0000000..d72edac --- /dev/null +++ b/src/types/GpxPointExtensions.ts @@ -0,0 +1,10 @@ +export interface GpxPointExtensions { + /** Speed in m/s — written as gpxtpx:speed */ + speed?: number; + /** Heart rate in bpm — written as gpxtpx:hr */ + heartRate?: number; + /** Cadence in rpm — written as gpxtpx:cad */ + cadence?: number; + /** Trusted raw XML inserted into without escaping */ + rawXml?: string; +} diff --git a/src/types/GpxRoute.ts b/src/types/GpxRoute.ts new file mode 100644 index 0000000..6dceb21 --- /dev/null +++ b/src/types/GpxRoute.ts @@ -0,0 +1,11 @@ +import type { GpxPointExtensions } from './GpxPointExtensions'; +import type { GpxRoutePoint } from './GpxRoutePoint'; + +export interface GpxRoute { + name?: string; + cmt?: string; + desc?: string; + points: GpxRoutePoint[]; + extensions?: GpxPointExtensions; +} + diff --git a/src/types/GpxRoutePoint.ts b/src/types/GpxRoutePoint.ts new file mode 100644 index 0000000..11356db --- /dev/null +++ b/src/types/GpxRoutePoint.ts @@ -0,0 +1,10 @@ +import type { GpxPointExtensions } from './GpxPointExtensions'; + +export interface GpxRoutePoint { + lat: number; + lon: number; + elevation?: number; + time?: Date; + extensions?: GpxPointExtensions; +} + diff --git a/src/types/GpxTrack.ts b/src/types/GpxTrack.ts new file mode 100644 index 0000000..e1097cf --- /dev/null +++ b/src/types/GpxTrack.ts @@ -0,0 +1,15 @@ +import type { GpxPoint } from './GpxPoint'; +import type { GpxPointExtensions } from './GpxPointExtensions'; +import type { GpxTrackSegment } from './GpxTrackSegment'; + +export interface GpxTrack { + name: string; + /** Defaults to the time of the first point when omitted */ + createdAt?: Date; + points?: GpxPoint[]; + segments?: GpxTrackSegment[]; + cmt?: string; + desc?: string; + extensions?: GpxPointExtensions; +} + diff --git a/src/types/GpxTrackSegment.ts b/src/types/GpxTrackSegment.ts new file mode 100644 index 0000000..badf329 --- /dev/null +++ b/src/types/GpxTrackSegment.ts @@ -0,0 +1,6 @@ +import type { GpxPoint } from './GpxPoint'; + +export interface GpxTrackSegment { + points: GpxPoint[]; +} + diff --git a/src/types/GpxWaypoint.ts b/src/types/GpxWaypoint.ts new file mode 100644 index 0000000..3273ff6 --- /dev/null +++ b/src/types/GpxWaypoint.ts @@ -0,0 +1,13 @@ +import type { GpxPointExtensions } from './GpxPointExtensions'; + +export interface GpxWaypoint { + lat: number; + lon: number; + name?: string; + cmt?: string; + desc?: string; + elevation?: number; + time?: Date; + extensions?: GpxPointExtensions; +} + diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..39f28bf --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,15 @@ +export type { GpxAuthor } from './GpxAuthor'; +export type { GpxBounds } from './GpxBounds'; +export type { GpxCopyright } from './GpxCopyright'; +export type { GpxDocument } from './GpxDocument'; +export type { GpxLink } from './GpxLink'; +export type { GpxMetadata } from './GpxMetadata'; +export type { GpxOptions } from './GpxOptions'; +export type { GpxPoint } from './GpxPoint'; +export type { GpxPointExtensions } from './GpxPointExtensions'; +export type { GpxRoute } from './GpxRoute'; +export type { GpxRoutePoint } from './GpxRoutePoint'; +export type { GpxTrack } from './GpxTrack'; +export type { GpxTrackSegment } from './GpxTrackSegment'; +export type { GpxWaypoint } from './GpxWaypoint'; + diff --git a/tests/generateGpx.test.ts b/tests/generateGpx.test.ts index 45c301a..eced81e 100644 --- a/tests/generateGpx.test.ts +++ b/tests/generateGpx.test.ts @@ -1,17 +1,24 @@ import { describe, it, expect } from 'vitest'; -import { generateGpx } from '../src/GpxExport'; +import { generateGpx, generateGpxDocument } from '../src/GpxExport'; describe('generateGpx', () => { it('generates a basic GPX document', () => { - const now = new Date('2024-01-01T12:00:00Z'); + const now = new Date('2026-01-01T12:00:00.000Z'); const gpx = generateGpx({ - name: 'Test Track', - points: [ + metadata: { + time: now, + }, + tracks: [ { - lat: 54.0, - lon: -1.0, - time: now, + name: 'Test Track', + points: [ + { + lat: 54.0, + lon: -1.0, + time: now, + }, + ], }, ], }); @@ -20,25 +27,562 @@ describe('generateGpx', () => { expect(gpx).toContain(''); expect(gpx).toContain('Test Track'); expect(gpx).toContain(''); - expect(gpx).toContain(''); + expect(gpx).toContain(''); }); it('includes Garmin speed extension when speed is present', () => { - const now = new Date('2024-01-01T12:00:00Z'); + const now = new Date('2026-01-01T12:00:00.000Z'); + + const gpx = generateGpx({ + tracks: [ + { + name: 'Speed Test', + points: [ + { + lat: 1, + lon: 2, + time: now, + speed: 3.5, + }, + ], + }, + ], + }); + + expect(gpx).toContain('gpxtpx:TrackPointExtension'); + expect(gpx).toContain('3.5000'); + }); + + it('escapes xml in legacy name and creator fields', () => { + const now = new Date('2026-01-01T12:00:00.000Z'); + + const gpx = generateGpx( + { + tracks: [ + { + name: 'Track ', + points: [ + { + lat: 10, + lon: 20, + time: now, + }, + ], + }, + ], + }, + { creator: 'tool "x" & y' }, + ); + + expect(gpx).toContain('creator="tool "x" & y"'); + expect(gpx).toContain('Track <A&B>'); + }); + + it('accepts full document input with a single API', () => { + const now = new Date('2026-01-01T12:00:00.000Z'); const gpx = generateGpx({ - name: 'Speed Test', + metadata: { + name: 'Document Input', + time: now, + }, + waypoints: [ + { + lat: 1, + lon: 2, + name: 'W', + }, + ], + routes: [ + { + name: 'R', + points: [ + { + lat: 1, + lon: 2, + }, + ], + }, + ], + tracks: [ + { + name: 'T', + points: [ + { + lat: 1, + lon: 2, + time: now, + }, + ], + }, + ], + }); + + expect(gpx).toContain(''); + expect(gpx).toContain('Document Input'); + expect(gpx).toContain(''); + expect(gpx).toContain(''); + expect(gpx).toContain(''); + }); + + it('accepts legacy track input by wrapping as a tracks-only document', () => { + const now = new Date('2026-01-01T12:00:00.000Z'); + + const gpx = generateGpx({ + name: 'Legacy Track', points: [ + { + lat: 12.34, + lon: 56.78, + time: now, + }, + ], + }); + + expect(gpx).toContain(''); + expect(gpx).toContain('Legacy Track'); + expect(gpx).toContain(''); + expect(gpx).not.toContain(''); + }); + + it('allows optional metadata for legacy track input via options', () => { + const now = new Date('2026-01-01T12:00:00.000Z'); + + const gpx = generateGpx( + { + name: 'Legacy Track', + points: [ + { + lat: 1, + lon: 2, + time: now, + }, + ], + }, + { + metadata: { + name: 'Metadata Name', + desc: 'Metadata Description', + time: now, + }, + }, + ); + + expect(gpx).toContain(''); + expect(gpx).toContain('Metadata Name'); + expect(gpx).toContain('Metadata Description'); + expect(gpx).toContain(''); + expect(gpx).toContain('Legacy Track'); + }); +}); + +describe('generateGpxDocument', () => { + it('generates waypoints, routes, and tracks with deterministic order', () => { + const now = new Date('2026-01-01T12:00:00.000Z'); + + const gpx = generateGpxDocument({ + metadata: { + name: 'Document Name', + desc: 'Document Description', + keywords: 'test,document', + time: now, + }, + waypoints: [ + { + lat: 40, + lon: -74, + name: 'Start', + time: now, + }, + ], + routes: [ + { + name: 'Route One', + points: [ + { + lat: 40, + lon: -74, + time: now, + }, + { + lat: 41, + lon: -73, + time: now, + }, + ], + }, + ], + tracks: [ + { + name: 'Track One', + segments: [ + { + points: [ + { + lat: 40, + lon: -74, + time: now, + }, + ], + }, + { + points: [ + { + lat: 41, + lon: -73, + time: now, + }, + ], + }, + ], + }, + ], + }); + + const metadataIndex = gpx.indexOf(''); + const waypointIndex = gpx.indexOf(''); + const trackIndex = gpx.indexOf(''); + + expect(metadataIndex).toBeGreaterThan(-1); + expect(waypointIndex).toBeGreaterThan(metadataIndex); + expect(routeIndex).toBeGreaterThan(waypointIndex); + expect(trackIndex).toBeGreaterThan(routeIndex); + expect(gpx.match(//g)?.length ?? 0).toBe(2); + }); + + it('adds metadata bounds when provided', () => { + const gpx = generateGpxDocument( + { + tracks: [ + { + name: 'Track', + points: [], + }, + ], + }, + { + bounds: { + minLat: 1, + minLon: 2, + maxLat: 3, + maxLon: 4, + }, + }, + ); + + expect(gpx).toContain(''); + }); + + it('supports heart rate and cadence extensions', () => { + const now = new Date('2026-01-01T12:00:00.000Z'); + + const gpx = generateGpxDocument({ + tracks: [ + { + name: 'Fitness', + points: [ + { + lat: 1, + lon: 2, + time: now, + extensions: { + heartRate: 150, + cadence: 85, + }, + }, + ], + }, + ], + }); + + expect(gpx).toContain('xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v2"'); + expect(gpx).toContain('150'); + expect(gpx).toContain('85'); + }); + + it('is deterministic for identical input', () => { + const now = new Date('2026-01-01T12:00:00.000Z'); + const input = { + metadata: { + name: 'Deterministic', + time: now, + }, + tracks: [ + { + name: 'Track', + points: [ + { + lat: 1, + lon: 2, + time: now, + }, + ], + }, + ], + }; + + const a = generateGpxDocument(input); + const b = generateGpxDocument(input); + expect(a).toBe(b); + }); + + it('renders metadata in GPX 1.1 field order with structured copyright', () => { + const now = new Date('2026-01-01T12:00:00.000Z'); + + const gpx = generateGpxDocument({ + metadata: { + name: 'N', + desc: 'D', + author: { + name: 'Chris', + }, + copyright: { + author: 'Chris', + year: 2024, + license: 'https://github.com/cmyers/gpx-export/blob/main/LICENSE', + }, + link: { + href: 'https://github.com/cmyers/gpx-export', + }, + time: now, + keywords: 'k', + }, + }); + + const authorIdx = gpx.indexOf(''); + const copyrightIdx = gpx.indexOf(''); + const linkIdx = gpx.indexOf(''); + const timeIdx = gpx.indexOf(''); + const keywordsIdx = gpx.indexOf('k'); + + expect(copyrightIdx).toBeGreaterThan(authorIdx); + expect(linkIdx).toBeGreaterThan(copyrightIdx); + expect(timeIdx).toBeGreaterThan(linkIdx); + expect(keywordsIdx).toBeGreaterThan(timeIdx); + expect(gpx).toContain('2024'); + expect(gpx).toContain('https://github.com/cmyers/gpx-export/blob/main/LICENSE'); + }); + + it('renders waypoint ele and time before name/cmt/desc', () => { + const now = new Date('2026-01-01T12:00:00.000Z'); + + const gpx = generateGpxDocument({ + waypoints: [ { lat: 1, lon: 2, + elevation: 99.1, time: now, - speed: 3.5, + name: 'Name', + cmt: 'Comment', + desc: 'Description', }, ], }); - expect(gpx).toContain('gpxtpx:TrackPointExtension'); - expect(gpx).toContain('3.5000'); + const eleIdx = gpx.indexOf('99.10'); + const timeIdx = gpx.indexOf(''); + const nameIdx = gpx.indexOf('Name'); + const cmtIdx = gpx.indexOf('Comment'); + const descIdx = gpx.indexOf('Description'); + + expect(eleIdx).toBeGreaterThan(-1); + expect(timeIdx).toBeGreaterThan(eleIdx); + expect(nameIdx).toBeGreaterThan(timeIdx); + expect(cmtIdx).toBeGreaterThan(nameIdx); + expect(descIdx).toBeGreaterThan(cmtIdx); + }); + + it('renders backward-compatible string copyright as author attribute', () => { + const gpx = generateGpxDocument({ + metadata: { + copyright: 'Legacy Owner', + }, + }); + + expect(gpx).toContain(''); + }); + + it('renders a maximal supported document with merged options and extensions', () => { + const now = new Date('2026-01-01T12:00:00.000Z'); + + const gpx = generateGpxDocument( + { + metadata: { + name: 'Doc Name', + desc: 'Doc Desc', + author: { + name: 'Chris', + link: { + href: 'https://github.com/cmyers/gpx-export', + text: 'Project', + type: 'text/html', + }, + }, + copyright: { + author: 'Chris', + year: 2024, + license: 'https://github.com/cmyers/gpx-export/blob/main/LICENSE', + }, + link: { + href: 'https://github.com/cmyers/gpx-export', + text: 'Project', + type: 'text/html', + }, + time: now, + keywords: 'a,b,c', + }, + waypoints: [ + { + lat: 50.1, + lon: -1.2, + elevation: 12.34, + time: now, + name: 'Waypoint A', + cmt: 'Waypoint Comment', + desc: 'Waypoint Description', + extensions: { + speed: 1.2345, + heartRate: 140, + cadence: 88, + rawXml: 'w', + }, + }, + ], + routes: [ + { + name: 'Route A', + cmt: 'Route Comment', + desc: 'Route Description', + extensions: { + rawXml: 'r', + }, + points: [ + { + lat: 50.2, + lon: -1.3, + elevation: 13.45, + time: now, + extensions: { + speed: 2.3456, + heartRate: 150, + cadence: 90, + rawXml: 'rp', + }, + }, + ], + }, + ], + tracks: [ + { + name: 'Track A', + cmt: 'Track Comment', + desc: 'Track Description', + extensions: { + rawXml: 't', + }, + segments: [ + { + points: [ + { + lat: 50.3, + lon: -1.4, + elevation: 14.56, + time: now, + speed: 3.4567, + extensions: { + heartRate: 160, + cadence: 95, + rawXml: 'tp', + }, + }, + ], + }, + ], + }, + ], + }, + { + creator: 'custom-tool', + metadata: { + keywords: 'merged,keywords', + }, + waypoints: [ + { + lat: 60.1, + lon: -2.2, + name: 'Waypoint B', + }, + ], + routes: [ + { + name: 'Route B', + points: [ + { + lat: 60.2, + lon: -2.3, + }, + ], + }, + ], + bounds: { + minLat: 1, + minLon: 2, + maxLat: 3, + maxLon: 4, + }, + extensionsXml: 'root', + }, + ); + + expect(gpx).toContain(''); + expect(gpx).toContain('Doc Name'); + expect(gpx).toContain('Doc Desc'); + expect(gpx).toContain(''); + expect(gpx).toContain(''); + expect(gpx).toContain('Project'); + expect(gpx).toContain('text/html'); + expect(gpx).toContain(''); + expect(gpx).toContain('2024'); + expect(gpx).toContain('https://github.com/cmyers/gpx-export/blob/main/LICENSE'); + expect(gpx).toContain(''); + expect(gpx).toContain(''); + expect(gpx).toContain('merged,keywords'); + expect(gpx).toContain(''); + + expect(gpx).toContain(''); + expect(gpx).toContain('Waypoint A'); + expect(gpx).toContain('w'); + expect(gpx).toContain(''); + expect(gpx).toContain('Waypoint B'); + + expect(gpx).toContain(''); + expect(gpx).toContain('Route A'); + expect(gpx).toContain('r'); + expect(gpx).toContain('rp'); + expect(gpx).toContain('Route B'); + + expect(gpx).toContain(''); + expect(gpx).toContain('Track A'); + expect(gpx).toContain('t'); + expect(gpx).toContain(''); + expect(gpx).toContain('tp'); + + expect(gpx).toContain('1.2345'); + expect(gpx).toContain('2.3456'); + expect(gpx).toContain('3.4567'); + expect(gpx).toContain('140'); + expect(gpx).toContain('150'); + expect(gpx).toContain('160'); + expect(gpx).toContain('88'); + expect(gpx).toContain('90'); + expect(gpx).toContain('95'); + + expect(gpx).toContain(''); + expect(gpx).toContain('root'); }); }); From bcea5f81af59b447f4ad1cba6fda7c7391a655ca Mon Sep 17 00:00:00 2001 From: Chris Myers Date: Fri, 27 Mar 2026 00:23:22 +0000 Subject: [PATCH 2/4] Align with Garmin v2 extension and update docs and tests --- README.md | 75 ++---- docs/api.html | 120 ++++++++-- docs/data-rules.html | 40 +++- docs/examples.html | 124 +++++++--- docs/formats.html | 147 ++++++------ docs/index.html | 44 ++-- docs/install.html | 2 +- src/GpxExport.ts | 77 ++---- src/types/GpxOptions.ts | 16 -- src/types/index.ts | 1 - tests/generateGpx.test.ts | 476 ++++++++++++++++++++------------------ 11 files changed, 615 insertions(+), 507 deletions(-) delete mode 100644 src/types/GpxOptions.ts diff --git a/README.md b/README.md index d99ba1f..0ac9b46 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ Zero-dependency GPX 1.1 generator for Node.js, browsers, Capacitor, and other JS runtimes. It supports: -- Single-method generation via `generateGpx` with `GpxDocument` input -- Backward-compatible `generateGpx(track, options)` support via tracks-only wrapper +- `generateGpx(track, metadata?)` for tracks-only input +- `generateGpx(document, metadata?)` for full document input - Waypoints, routes, tracks, and metadata - Garmin TrackPointExtension v2 (`gpxtpx`) for speed, heart rate, and cadence @@ -23,26 +23,19 @@ const now = new Date(); const gpx = generateGpx( { - metadata: { - time: now, - }, - tracks: [ + name: 'Morning Ride', + points: [ { - name: 'Morning Ride', - points: [ - { - lat: 54.5741, - lon: -1.318, - time: now, - elevation: 32.4, - speed: 5.2, - }, - ], + lat: 54.5741, + lon: -1.318, + time: now, + elevation: 32.4, + speed: 5.2, }, ], }, { - creator: 'my-app', + time: now, }, ); @@ -105,38 +98,16 @@ const gpx = generateGpx( ], }, ], - }, - { - creator: 'my-app', - bounds: { - minLat: 54.4, - minLon: -1.4, - maxLat: 54.7, - maxLon: -1.1, - }, - }, + } ); ``` ## API -### generateGpx(document: GpxDocument, options?: GpxOptions): string - -Generates a GPX 1.1 document from a full GPX document object. - -Backward compatibility: -- Passing a `GpxTrack` is still supported. -- Track input is wrapped as `{ tracks: [track] }` and then processed as a document. -- Optional metadata can be supplied with `options.metadata`. +### generateGpx(track: GpxTrack, metadata?: GpxMetadata): string +### generateGpx(document: GpxDocument, metadata?: GpxMetadata): string -### generateGpxDocument(document: GpxDocument, options?: GpxOptions): string - -Compatibility alias for `generateGpx(document, options)`. - -Merge behavior: -- `options.metadata` shallow-merges into `document.metadata`. -- `options.waypoints` and `options.routes` are appended. -- `options.bounds` writes `metadata.bounds`. +Generates a GPX 1.1 document from either a `GpxTrack` or `GpxDocument`, with optional metadata that shallow-merges into `document.metadata`. ## Supported Types (Summary) @@ -167,24 +138,22 @@ interface GpxTrack { extensions?: GpxPointExtensions; } -interface GpxOptions { - creator?: string; // default: "gpx-export" - metadata?: GpxMetadata; - waypoints?: GpxWaypoint[]; - routes?: GpxRoute[]; - bounds?: GpxBounds; - extensionsXml?: string; // trusted XML in root -} ``` +Why speed appears in two places: +- `GpxPoint.speed` exists for backward compatibility with older callers. +- `GpxPoint.extensions.speed` is the canonical field for new code. +- When both are provided, `extensions.speed` wins and only one GPX speed tag is emitted. + All exported type definitions are available from the package root. ## Notes - Elevation is formatted to 2 decimal places. - Speed is formatted to 4 decimal places. -- The Garmin `gpxtpx` namespace is included when Garmin point extensions are present. -- `extensionsXml` and `rawXml` values are inserted as trusted XML and are not escaped. +- The Garmin `gpxtpx` namespace is included when Garmin metrics are present on track points. +- Garmin `gpxtpx` metric tags are emitted on track points (`trkpt`) only. +- `rawXml` values are inserted as trusted XML and are not escaped. - Element output order is deterministic for identical inputs. ## Scope diff --git a/docs/api.html b/docs/api.html index 76a6053..3c1140a 100644 --- a/docs/api.html +++ b/docs/api.html @@ -14,26 +14,29 @@

API Reference

gpx-export exposes a focused, typed API for building GPX 1.1 documents. - The main export is generateGpx(track, options?). + The main export is generateGpx(...).

-

generateGpx(track, options?)

+

generateGpx(...)

- Creates GPX 1.1 XML with metadata, track name, segment, and points. - Garmin TrackPointExtension output is automatically added when speed - values are present. + Generates GPX 1.1 XML from either: + generateGpx(track: GpxTrack, metadata?: GpxMetadata) or + generateGpx(document: GpxDocument, metadata?: GpxMetadata).

import { generateGpx } from "gpx-export";
 
+const now = new Date("2026-01-01T12:00:00.000Z");
+
 const xml = generateGpx(
   {
-    name: "Morning Ride",
-    points: [
-      { lat: 54.57, lon: -1.31, time: new Date(), speed: 5.2 }
-    ]
+    name: "Session",
+    points: [{ lat: 54.57, lon: -1.31, time: now }]
   },
-  { creator: "my-app" }
+  {
+    name: "Morning Ride",
+    time: now
+  }
 );
 
@@ -42,45 +45,114 @@

Returns

  • string - GPX 1.1 XML document
  • -

    GpxPoint

    -

    - Defines each point in the generated track. -

    +

    GpxDocument

    +
    interface GpxDocument {
    +  metadata?: GpxMetadata;
    +  waypoints?: GpxWaypoint[];
    +  routes?: GpxRoute[];
    +  tracks?: GpxTrack[];
    +}
    +
    + +

    GpxMetadata

    +
    interface GpxMetadata {
    +  name?: string;
    +  desc?: string;
    +  author?: GpxAuthor;
    +  link?: GpxLink;
    +  time?: Date;
    +  keywords?: string;
    +  copyright?: string | GpxCopyright;
    +  bounds?: GpxBounds;
    +}
     
    +interface GpxAuthor {
    +  name: string;
    +  link?: GpxLink;
    +}
    +
    +interface GpxCopyright {
    +  author: string;
    +  year?: number;
    +  license?: string;
    +}
    +
    +interface GpxLink {
    +  href: string;
    +  text?: string;
    +  type?: string;
    +}
    +
    + +

    GpxPointExtensions

    +
    interface GpxPointExtensions {
    +  speed?: number;      // gpxtpx:speed (m/s)
    +  heartRate?: number;  // gpxtpx:hr (bpm)
    +  cadence?: number;    // gpxtpx:cad (rpm)
    +  rawXml?: string;     // trusted XML in <extensions>
    +}
    +
    + +

    GpxPoint

    interface GpxPoint {
       lat: number;
       lon: number;
       time: Date;
    -  speed?: number;     // m/s
    -  elevation?: number; // metres
    +  speed?: number;      // legacy alias for extensions.speed
    +  elevation?: number;  // metres
    +  extensions?: GpxPointExtensions;
     }
     
    -

    GpxTrack

    - Defines metadata and ordered points. + Why speed appears in both places: GpxPoint.speed is a legacy compatibility alias, + while GpxPoint.extensions.speed is the canonical field for new code. + If both are provided, extension speed takes precedence and only one GPX speed tag is emitted.

    +

    GpxTrack

    interface GpxTrack {
       name: string;
       createdAt?: Date;
    -  points: GpxPoint[];
    +  points?: GpxPoint[];
    +  segments?: { points: GpxPoint[] }[];
    +  cmt?: string;
    +  desc?: string;
    +  extensions?: GpxPointExtensions;
     }
     
    -

    GpxOptions

    -
    interface GpxOptions {
    -  creator?: string;
    +    

    GpxRoute and GpxWaypoint

    +
    interface GpxRoute {
    +  name?: string;
    +  cmt?: string;
    +  desc?: string;
    +  points: GpxRoutePoint[];
    +  extensions?: GpxPointExtensions;
    +}
    +
    +interface GpxWaypoint {
    +  lat: number;
    +  lon: number;
    +  name?: string;
    +  cmt?: string;
    +  desc?: string;
    +  elevation?: number;
    +  time?: Date;
    +  extensions?: GpxPointExtensions;
     }
     

    Behavior notes

    • Creator defaults to gpx-export.
    • -
    • Track name and creator are XML-escaped before output.
    • -
    • Metadata time defaults to first point time, then current time.
    • +
    • generateGpx(track, metadata?) wraps track input into a one-track document.
    • +
    • metadata shallow-merges onto document.metadata.
    • Elevation is formatted to 2 decimal places.
    • Speed is formatted to 4 decimal places as gpxtpx:speed.
    • +
    • Garmin gpxtpx namespace is emitted when Garmin metrics are present on track points.
    • +
    • Garmin metric tags are emitted under track-point <extensions> only.
    • +
    • rawXml extension values are inserted as trusted XML and are not escaped.