├── .github
└── workflows
│ └── deploy-to-npm.yml
├── .gitignore
├── LICENSE
├── README.md
├── index.html
├── lib
├── index.ts
├── math_helpers.ts
├── options.ts
├── parse.ts
├── parsed_gpx.ts
├── stringify.ts
└── types.ts
├── package-lock.json
├── package.json
├── src
├── main.ts
└── test_files
│ └── test.gpx
├── test
├── helpers.spec.js
├── parse.spec.js
├── stringify.spec.js
└── test-gpx-file.js
├── tsconfig.json
└── vite.config.js
/.github/workflows/deploy-to-npm.yml:
--------------------------------------------------------------------------------
1 | name: Build and Deploy to NPM
2 | on:
3 | release:
4 | types: [created]
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: actions/checkout@v2
10 | - uses: actions/setup-node@v2
11 | with:
12 | node-version: "16.x"
13 | registry-url: "https://registry.npmjs.org"
14 | - run: npm ci
15 | - run: npm run build
16 | - run: npm publish --access public
17 | env:
18 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
19 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
26 | # Misc testing files
27 | src/test_files/biking.gpx
28 | src/test_files/walking.gpx
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Weaver Goldman
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 | 
3 | [](https://github.com/We-Gold/gpxjs/issues)
4 | 
5 | [](https://opensource.org/licenses/MIT)
6 | 
7 |
8 | # GPX.JS
9 |
10 | Based on [GPXParser.js](https://github.com/Luuka/GPXParser.js), which has been unmaintained, this updated library is intended to bring modern JavaScript features to GPX parsing, including extensions in tracks and fully featured typescript support.
11 |
12 | _I'm also open to any improvements or suggestions with the library, so feel free to leave an issue ([Contributing](#contribution))._
13 |
14 | ## GPX Schema
15 |
16 | The schema for GPX, a commonly used gps tracking format, can be found here: [GPX 1.1](https://www.topografix.com/gpx.asp).
17 |
18 | See [Documentation](#documentation) for more details on how GPX data is represented by the library.
19 |
20 | ## Usage
21 |
22 | **This library does include support for non-browser execution.**
23 |
24 | Right now, to use this in Node.js without a browser or in something like React Native, use [`xmldom-qsa`](https://www.npmjs.com/package/xmldom-qsa) instead.
25 |
26 | See instructions below on [how to use a custom DOM parser](#using-a-custom-dom-parser).
27 |
28 | ### Install using NPM
29 |
30 | `npm i @we-gold/gpxjs`
31 |
32 | Then, import the `parseGPX` method:
33 |
34 | ```js
35 | import { parseGPX } from "@we-gold/gpxjs"
36 |
37 | const [parsedFile, error] = parseGPX(myXMLGPXString)
38 |
39 | // Or use a try catch to verify
40 | if (error) throw error
41 |
42 | const geojson = parsedFile.toGeoJSON()
43 | ```
44 |
45 | ### Include JavaScript File
46 |
47 | In an HTML document:
48 |
49 | ```html
50 |
51 |
52 |
62 | ```
63 |
64 | ### Fetching a GPX File
65 |
66 | While this feature isn't included, it is fairly simple to fetch a GPX file and format it as a string.
67 |
68 | ```js
69 | import { parseGPX } from "@we-gold/gpxjs"
70 |
71 | fetch("./somefile.gpx")
72 | .then((response) => {
73 | if (!response.ok) {
74 | throw new Error("Failed to fetch the file")
75 | }
76 | return response.text()
77 | })
78 | .then((data) => {
79 | const [parsedFile, error] = parseGPX(data)
80 |
81 | // Or use a try catch to verify
82 | if (error) throw error
83 |
84 | const geojson = parsedFile.toGeoJSON()
85 | })
86 | ```
87 |
88 | _`parseGPX` has an additional optional argument `removeEmptyFields` which removes empty or null values from the output. It is true by default. This argument is also available in `parseGPXWithCustomParser`._
89 |
90 | ### Use the Parsed GPX
91 |
92 | ```js
93 | const totalDistance = gpx.tracks[0].distance.total
94 |
95 | const extensions = gpx.tracks[1].extensions
96 | ```
97 |
98 | ### Use parsedGPX functions
99 |
100 | ```js
101 | /// export to GPX
102 | const geoJSON = parsedGPX.toGeoJSON()
103 |
104 |
105 | /// stringify GPX function
106 | import { stringifyGPX } from "@we-gold/gpxjs"
107 |
108 | const xmlAsString = stringifyGPX(parsedGPX);
109 |
110 | // math functions
111 | import { calculateDistance, calculateDuration, calculateElevation, calculateSlopes } from "@we-gold/gpxjs"
112 |
113 | /// recalculates the distance, duration, elevation, and slopes of the track
114 | const dist = parsedGPX.applyToTrack(0, calculateDistance);
115 | const dur = parsedGPX.applyToTrack(0, calculateDuration);
116 | const elev = parsedGPX.applyToTrack(0, calculateElevation);
117 | const slope = parsedGPX.applyToTrack(0, calculateSlopes, dist.cumulative)
118 | ```
119 |
120 | ### Using a Custom DOM Parser
121 |
122 | If working in an environment where a custom DOM Parser is required, you can include it like so:
123 |
124 | _Note, this is significantly slower than using the browser parser._
125 |
126 | ```js
127 | import { parseGPXWithCustomParser, stringifyGPX} from "@we-gold/gpxjs"
128 | import { DOMParser, XMLSerializer } from "xmldom-qsa"
129 |
130 | const customParseMethod = (txt: string): Document | null => {
131 | return new DOMParser().parseFromString(txt, "text/xml")
132 | }
133 |
134 | const [parsedFile, error] = parseGPXWithCustomParser(
135 | myXMLGPXString,
136 | customParseMethod
137 | )
138 |
139 | const xml = stringifyGPX(parsedFile, new XMLSerializer());
140 | ```
141 |
142 | # Contribution
143 |
144 | If you are having an issue and aren't sure how to resolve it, feel free to leave an issue.
145 |
146 | If you do know how to fix it, please leave a PR, as I cannot guarantee how soon I can address the issue myself.
147 |
148 | I do try to be responsive to PRs though, so if you leave one I'll try to get it merged asap.
149 |
150 | Also, there are some basic tests built in to the library, so please test your code before you try to get it merged (_just to make sure everything is backwards compatible_). Use `npm run test` to do this.
151 |
152 | You will need _playwright_ installed to run the tests. Use `npx playwright install` to install it. Follow any additional instructions given by the installer to ensure it works on your operating system.
153 |
154 | ## Options
155 |
156 | You can also run `parseGPX()` with custom options for more control over the parsing process. See [options.ts](./lib/options.ts) for more details.
157 |
158 | ```js
159 | const [parsedFile, error] = parseGPX(data, {
160 | avgSpeedThreshold: 0.1,
161 | })
162 | // same for parseGPXWithCustomParser()
163 | ```
164 |
165 | | Property | Type | Description |
166 | | ----------------- | ------- | ----------------------------------------------------------------------- |
167 | | removeEmptyFields | boolean | delete null fields in output |
168 | | avgSpeedThreshold | number | average speed threshold (in m/ms) used to determine the moving duration |
169 |
170 | ## Types
171 |
172 | These descriptions are adapted from [GPXParser.js](https://github.com/Luuka/GPXParser.js), with minor modifications.
173 |
174 | For specific type definition, see [types.ts](./lib/types.ts).
175 |
176 | | Property | Type | Description |
177 | | --------- | ------------------ | ----------------------------------- |
178 | | xml | XML Document | XML Document parsed from GPX string |
179 | | metadata | Metadata object | File metadata |
180 | | waypoints | Array of Waypoints | Array of waypoints |
181 | | tracks | Array of Tracks | Array of waypoints of tracks |
182 | | routes | Array of Routes | Array of waypoints of routes |
183 |
184 | ### Metadata
185 |
186 | | Property | Type | Description |
187 | | ----------- | ----------- | ------------- |
188 | | name | String | File name |
189 | | description | String | Description |
190 | | link | Link object | Web address |
191 | | author | Float | Author object |
192 | | time | String | Time |
193 |
194 | ### Waypoint
195 |
196 | | Property | Type | Description |
197 | | ----------- | ------ | ----------------- |
198 | | name | String | Point name |
199 | | comment | String | Comment |
200 | | description | String | Point description |
201 | | latitude | Float | Point latitute |
202 | | longitude | Float | Point longitude |
203 | | elevation | Float | Point elevation |
204 | | time | Date | Point time |
205 |
206 | ### Track
207 |
208 | | Property | Type | Description |
209 | | ----------- | ---------------- | ------------------------------------- |
210 | | name | String | Point name |
211 | | comment | String | Comment |
212 | | description | String | Point description |
213 | | src | String | Source device |
214 | | number | String | Track identifier |
215 | | link | String | Link to a web address |
216 | | type | String | Track type |
217 | | points | Array | Array of Points |
218 | | distance | Distance Object | Distance information about the Track |
219 | | duration | Duration Object | Duration information about the Track |
220 | | elevation | Elevation Object | Elevation information about the Track |
221 | | slopes | Float Array | Slope of each sub-segment |
222 |
223 | ### Route
224 |
225 | | Property | Type | Description |
226 | | ----------- | ---------------- | ------------------------------------- |
227 | | name | String | Point name |
228 | | comment | String | Comment |
229 | | description | String | Point description |
230 | | src | String | Source device |
231 | | number | String | Track identifier |
232 | | link | String | Link to a web address |
233 | | type | String | Route type |
234 | | points | Array | Array of Points |
235 | | distance | Distance Object | Distance information about the Route |
236 | | duration | Duration Object | Duration information about the Route |
237 | | elevation | Elevation Object | Elevation information about the Route |
238 | | slopes | Float Array | Slope of each sub-segment |
239 |
240 | ### Point
241 |
242 | | Property | Type | Description |
243 | | --------- | ----- | --------------- |
244 | | latitude | Float | Point latitute |
245 | | longitude | Float | Point longitude |
246 | | elevation | Float | Point elevation |
247 | | time | Date | Point time |
248 |
249 | ### Distance
250 |
251 | | Property | Type | Description |
252 | | ---------- | ----- | ---------------------------------------------------- |
253 | | total | Float | Total distance of the Route/Track |
254 | | cumulative | Float | Cumulative distance at each point of the Route/Track |
255 |
256 | ### Duration
257 |
258 | | Property | Type | Description |
259 | | -------------- | ------------ | ---------------------------------------------------- |
260 | | cumulative | Float | Cumulative duration at each point of the Route/Track |
261 | | movingDuration | Float | Total moving duration of the Route/Track in seconds |
262 | | totalDuration | Float | Total duration of the Route/Track in seconds |
263 | | startTime | Date or null | Start date, if available |
264 | | endTime | Date or null | End date, if available |
265 |
266 | ### Elevation
267 |
268 | | Property | Type | Description |
269 | | -------- | ----- | ----------------------------- |
270 | | maximum | Float | Maximum elevation |
271 | | minimum | Float | Minimum elevation |
272 | | positive | Float | Positive elevation difference |
273 | | negative | Float | Negative elevation difference |
274 | | average | Float | Average elevation |
275 |
276 | ### Author
277 |
278 | | Property | Type | Description |
279 | | -------- | ------------ | --------------------------- |
280 | | name | String | Author name |
281 | | email | Email object | Email address of the author |
282 | | link | Link object | Web address |
283 |
284 | ### Email
285 |
286 | | Property | Type | Description |
287 | | -------- | ------ | ------------ |
288 | | id | String | Email id |
289 | | domain | String | Email domain |
290 |
291 | ### Link
292 |
293 | | Property | Type | Description |
294 | | -------- | ------ | ----------- |
295 | | href | String | Web address |
296 | | text | String | Link text |
297 | | type | String | Link type |
298 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | GPXJS test
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/lib/index.ts:
--------------------------------------------------------------------------------
1 | export { ParsedGPX } from "./parsed_gpx"
2 | export { parseGPX, parseGPXWithCustomParser } from "./parse"
3 | export { stringifyGPX } from "./stringify"
4 | export {
5 | calculateDuration,
6 | calculateDistance,
7 | calculateElevation,
8 | calculateSlopes,
9 | } from "./math_helpers"
10 | export * from "./types"
11 |
--------------------------------------------------------------------------------
/lib/math_helpers.ts:
--------------------------------------------------------------------------------
1 | import { Point, Distance, Elevation, Duration, Options } from "./types"
2 |
3 | import { DEFAULT_OPTIONS } from "./options"
4 |
5 | export type MathHelperFunction = (points: Point[], ...args: any[]) => any
6 |
7 | /**
8 | * Calculates the distances along a series of points using the haversine formula internally.
9 | *
10 | * @param points An array containing points with latitudes and longitudes
11 | * @returns A distance object containing the total distance and the cumulative distances
12 | */
13 | export const calculateDistance: MathHelperFunction = (
14 | points: Point[]
15 | ): Distance => {
16 | const cumulativeDistance = [0]
17 |
18 | // Incrementally calculate the distance between adjacent points until
19 | // all of the distances have been accumulated
20 | for (let i = 0; i < points.length - 1; i++) {
21 | const currentTotalDistance =
22 | cumulativeDistance[i] + haversineDistance(points[i], points[i + 1])
23 | cumulativeDistance.push(currentTotalDistance)
24 | }
25 |
26 | return {
27 | cumulative: cumulativeDistance,
28 | total: cumulativeDistance[cumulativeDistance.length - 1],
29 | }
30 | }
31 |
32 | /**
33 | * Calculate the distance between two points with latitude and longitude
34 | * using the haversine formula.
35 | *
36 | * @param point1 A point with latitude and longitude
37 | * @param point2 A point with latitude and longitude
38 | * @returns The distance between the two points
39 | */
40 | export const haversineDistance = (point1: Point, point2: Point): number => {
41 | const toRadians = (degrees: number) => (degrees * Math.PI) / 180
42 |
43 | const lat1Radians = toRadians(point1.latitude)
44 | const lat2Radians = toRadians(point2.latitude)
45 | const sinDeltaLatitude = Math.sin(
46 | toRadians(point2.latitude - point1.latitude) / 2
47 | )
48 | const sinDeltaLongitude = Math.sin(
49 | toRadians(point2.longitude - point1.longitude) / 2
50 | )
51 | const a =
52 | sinDeltaLatitude ** 2 +
53 | Math.cos(lat1Radians) * Math.cos(lat2Radians) * sinDeltaLongitude ** 2
54 | const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
55 | return 6371000 * c
56 | }
57 |
58 | /**
59 | * Calculates duration statistics based on distance traveled and the time taken.
60 | *
61 | *
62 | * @param points A list of points with a time
63 | * @param distance A distance object containing the total distance and the cumulative distances
64 | * @returns A duration object
65 | */
66 |
67 | export const calculateDuration: MathHelperFunction = (
68 | points: Point[],
69 | distance: Distance,
70 | calculOptions: Options = DEFAULT_OPTIONS
71 | ): Duration => {
72 | const { avgSpeedThreshold } = calculOptions
73 | const allTimedPoints: { time: Date; distance: number }[] = []
74 | const cumulative: number[] = [0]
75 | let lastTime = 0
76 |
77 | for (let i = 0; i < points.length - 1; i++) {
78 | const currentPoint = points[i]
79 | const time = currentPoint.time
80 | const dist = distance.cumulative[i]
81 | const previousPoint = cumulative[i]
82 |
83 | if (time !== null) {
84 | const movingTime = time.getTime() - lastTime
85 |
86 | if (movingTime > 0) {
87 | // Calculate average speed over the last 10 seconds
88 | let sumDistances = 0
89 | let sumTime = 0
90 |
91 | for (let j = i; j >= 0; j--) {
92 | const prevTime = points[j].time?.getTime()
93 | if (prevTime !== undefined) {
94 | const timeDiff = time.getTime() - prevTime
95 | if (timeDiff > 10000) break // Only include last 10 seconds
96 | sumDistances +=
97 | distance.cumulative[j + 1] - distance.cumulative[j]
98 | sumTime += timeDiff
99 | }
100 | }
101 |
102 | const avgSpeed = sumTime > 0 ? sumDistances / sumTime : 0
103 |
104 | // Determine if average speed indicates resting
105 | const nextCumul =
106 | avgSpeed > avgSpeedThreshold
107 | ? previousPoint + movingTime // Significant movement
108 | : previousPoint // Resting, no time added
109 |
110 | cumulative.push(nextCumul)
111 | } else {
112 | // Handle edge case of no movement
113 | cumulative.push(previousPoint)
114 | }
115 |
116 | lastTime = time.getTime()
117 | allTimedPoints.push({ time, distance: dist })
118 | } else {
119 | // Missing time, do not contribute to cumulative
120 | cumulative.push(previousPoint)
121 | }
122 | }
123 |
124 | const totalDuration =
125 | allTimedPoints.length === 0
126 | ? 0
127 | : allTimedPoints[allTimedPoints.length - 1].time.getTime() -
128 | allTimedPoints[0].time.getTime()
129 |
130 | return {
131 | startTime: allTimedPoints.length ? allTimedPoints[0].time : null,
132 | endTime: allTimedPoints.length
133 | ? allTimedPoints[allTimedPoints.length - 1].time
134 | : null,
135 | cumulative,
136 | movingDuration: cumulative[cumulative.length - 1] / 1000, // Convert to seconds
137 | totalDuration: totalDuration / 1000, // Convert to seconds
138 | }
139 | }
140 |
141 | /**
142 | * Calculates details about the elevation of the given points.
143 | * Points without elevations will be skipped.
144 | *
145 | * @param points A list of points with an elevation
146 | * @returns An elevation object containing details about the elevation of the points
147 | */
148 | export const calculateElevation: MathHelperFunction = (
149 | points: Point[]
150 | ): Elevation => {
151 | let dp = 0
152 | let dn = 0
153 | const elevation = []
154 | let sum = 0
155 |
156 | // Calculate the positive and negative changes over the whole set of points
157 | for (let i = 0; i < points.length - 1; i++) {
158 | const nextElevation = points[i + 1]?.elevation
159 | const currentElevation = points[i]?.elevation
160 |
161 | if (nextElevation !== null && currentElevation !== null) {
162 | const diff = nextElevation - currentElevation
163 | if (diff < 0) dn += diff
164 | else if (diff > 0) dp += diff
165 | }
166 | }
167 |
168 | // Store all elevations and calculate the sum of the elevations
169 | for (const point of points) {
170 | if (point.elevation !== null) {
171 | elevation.push(point.elevation)
172 | sum += point.elevation
173 | }
174 | }
175 |
176 | // Find the maximum and minimum elevation
177 | let max = elevation[0] ?? null
178 | let min = elevation[0] ?? null
179 | for (let i = 1; i < elevation.length; i++) {
180 | if (elevation[i] > max) max = elevation[i]
181 | if (elevation[i] < min) min = elevation[i]
182 | }
183 |
184 | return {
185 | maximum: max,
186 | minimum: min,
187 | positive: Math.abs(dp) || null,
188 | negative: Math.abs(dn) || null,
189 | average: elevation.length ? sum / elevation.length : null,
190 | }
191 | }
192 |
193 | /**
194 | * Calculates the elevation grade as a percent between the adjacent points in the list.
195 | * Points without elevation will be skipped.
196 | *
197 | * @param points A list of points with elevations
198 | * @param cumulativeDistance A list of cumulative distances aquired through the `calculateDistance` method
199 | * @returns A list of slopes between the given points
200 | */
201 | export const calculateSlopes: MathHelperFunction = (
202 | points: Point[],
203 | cumulativeDistance: number[]
204 | ): number[] => {
205 | const slopes = []
206 |
207 | for (let i = 0; i < points.length - 1; i++) {
208 | const nextElevation = points[i + 1]?.elevation
209 | const currentElevation = points[i]?.elevation
210 |
211 | if (nextElevation !== null && currentElevation !== null) {
212 | const elevationDifference = nextElevation - currentElevation
213 | const displacement =
214 | cumulativeDistance[i + 1] - cumulativeDistance[i]
215 |
216 | // Calculate the elevation grade as a percentage
217 | const slope = (elevationDifference * 100) / displacement
218 | slopes.push(slope)
219 | }
220 | }
221 |
222 | return slopes
223 | }
224 |
--------------------------------------------------------------------------------
/lib/options.ts:
--------------------------------------------------------------------------------
1 | export const DEFAULT_THRESHOLD = 0.000215 // m/ms - 0.215 m/s - 0.774000 km/h
2 |
3 | export const DEFAULT_OPTIONS = {
4 | // delete null fields in output
5 | removeEmptyFields: true,
6 | // average speed threshold (in m/ms) used to determine the moving duration
7 | avgSpeedThreshold: DEFAULT_THRESHOLD,
8 | }
9 |
--------------------------------------------------------------------------------
/lib/parse.ts:
--------------------------------------------------------------------------------
1 | import {
2 | calculateDistance,
3 | calculateDuration,
4 | calculateElevation,
5 | calculateSlopes,
6 | } from "./math_helpers"
7 | import { DEFAULT_OPTIONS } from "./options"
8 | import { ParsedGPX } from "./parsed_gpx"
9 | import {
10 | ParsedGPXInputs,
11 | Point,
12 | Route,
13 | Track,
14 | Waypoint,
15 | Extensions,
16 | Options,
17 | } from "./types"
18 |
19 | /**
20 | * Converts the given GPX XML to a JavaScript Object with the ability to convert to GeoJSON.
21 | *
22 | * @param gpxSource A string containing the source GPX XML
23 | * @param removeEmptyFields Whether or not to remove null or undefined fields from the output
24 | * @returns A ParsedGPX with all of the parsed data and a method to convert to GeoJSON
25 | */
26 | export const parseGPX = (
27 | gpxSource: string,
28 | options: Options = DEFAULT_OPTIONS
29 | ) => {
30 | const parseMethod = (gpxSource: string): Document | null => {
31 | // Verify that we are in a browser
32 | if (typeof document === "undefined") return null
33 | if (typeof window === "undefined") {
34 | console.error(
35 | "window is undefined, try to use the parseGPXWithCustomParser method"
36 | )
37 | return null
38 | }
39 | const domParser = new window.DOMParser()
40 | return domParser.parseFromString(gpxSource, "text/xml")
41 | }
42 | const allOptions = { ...DEFAULT_OPTIONS, ...options }
43 | return parseGPXWithCustomParser(gpxSource, parseMethod, allOptions)
44 | }
45 |
46 | /**
47 | * Converts the given GPX XML to a JavaScript Object with the ability to convert to GeoJSON.
48 | * This uses a **custom** method supplied by the user. This is most applicable to non-browser environments.
49 | *
50 | * @param gpxSource A string containing the source GPX XML
51 | * @param parseGPXToXML An optional method that parses gpx to a usable XML format
52 | * @param removeEmptyFields Whether or not to remove null or undefined fields from the output
53 | * @returns A ParsedGPX with all of the parsed data and a method to convert to GeoJSON
54 | */
55 | export const parseGPXWithCustomParser = (
56 | gpxSource: string,
57 | parseGPXToXML: (gpxSource: string) => Document | null,
58 | options: Options = DEFAULT_OPTIONS
59 | ): [null, Error] | [ParsedGPX, null] => {
60 | // Parse the GPX string using the given parse method
61 | const parsedSource = parseGPXToXML(gpxSource)
62 |
63 | // Verify that the parsed data is present
64 | if (parsedSource === null)
65 | return [null, new Error("Provided parsing method failed.")]
66 |
67 | const output: ParsedGPXInputs = {
68 | xml: parsedSource,
69 | metadata: {
70 | name: "",
71 | description: "",
72 | time: "",
73 | author: null,
74 | link: null,
75 | },
76 | waypoints: [],
77 | tracks: [],
78 | routes: [],
79 | }
80 |
81 | const metadata = output.xml.querySelector("metadata")
82 | if (metadata !== null) {
83 | // Store the top level elements of the metadata
84 | output.metadata.name = getElementValue(metadata, "name")
85 | output.metadata.description = getElementValue(metadata, "desc")
86 | output.metadata.time = getElementValue(metadata, "time")
87 |
88 | // Parse and store the tree of data associated with the author
89 | const authorElement = metadata.querySelector("author")
90 | if (authorElement !== null) {
91 | const emailElement = authorElement.querySelector("email")
92 | const linkElement = authorElement.querySelector("link")
93 |
94 | output.metadata.author = {
95 | name: getElementValue(authorElement, "name"),
96 | email:
97 | emailElement !== null
98 | ? {
99 | id: emailElement.getAttribute("id") ?? "",
100 | domain:
101 | emailElement.getAttribute("domain") ?? "",
102 | }
103 | : null,
104 | link:
105 | linkElement !== null
106 | ? {
107 | href: linkElement.getAttribute("href") ?? "",
108 | text: getElementValue(linkElement, "text"),
109 | type: getElementValue(linkElement, "type"),
110 | }
111 | : null,
112 | }
113 | }
114 |
115 | // Parse and store the link element and its associated data
116 | const linkElement = querySelectDirectDescendant(metadata, "link")
117 | if (linkElement !== null) {
118 | output.metadata.link = {
119 | href: linkElement.getAttribute("href") ?? "",
120 | text: getElementValue(linkElement, "text"),
121 | type: getElementValue(linkElement, "type"),
122 | }
123 | }
124 | }
125 |
126 | // Parse and store all waypoints
127 | const waypoints = Array.from(output.xml.querySelectorAll("wpt"))
128 | for (const waypoint of waypoints) {
129 | const point: Waypoint = {
130 | name: getElementValue(waypoint, "name"),
131 | symbol: getElementValue(waypoint, "sym"),
132 | latitude: parseFloat(waypoint.getAttribute("lat") ?? ""),
133 | longitude: parseFloat(waypoint.getAttribute("lon") ?? ""),
134 | elevation: null,
135 | comment: getElementValue(waypoint, "cmt"),
136 | description: getElementValue(waypoint, "desc"),
137 | time: null,
138 | }
139 |
140 | const rawElevation = parseFloat(getElementValue(waypoint, "ele") ?? "")
141 | point.elevation = isNaN(rawElevation) ? null : rawElevation
142 |
143 | const rawTime = getElementValue(waypoint, "time")
144 | point.time = rawTime == null ? null : new Date(rawTime)
145 |
146 | output.waypoints.push(point)
147 | }
148 |
149 | const routes = Array.from(output.xml.querySelectorAll("rte"))
150 | for (const routeElement of routes) {
151 | const route: Route = {
152 | name: getElementValue(routeElement, "name"),
153 | comment: getElementValue(routeElement, "cmt"),
154 | description: getElementValue(routeElement, "desc"),
155 | src: getElementValue(routeElement, "src"),
156 | number: getElementValue(routeElement, "number"),
157 | type: null,
158 | link: null,
159 | points: [],
160 | distance: {
161 | cumulative: [],
162 | total: 0,
163 | },
164 | duration: {
165 | cumulative: [],
166 | movingDuration: 0,
167 | totalDuration: 0,
168 | endTime: null,
169 | startTime: null,
170 | },
171 | elevation: {
172 | maximum: null,
173 | minimum: null,
174 | average: null,
175 | positive: null,
176 | negative: null,
177 | },
178 | slopes: [],
179 | }
180 |
181 | const type = querySelectDirectDescendant(routeElement, "type")
182 | route.type = type?.innerHTML ?? type?.textContent ?? null
183 |
184 | // Parse and store the link and its associated data
185 | const linkElement = routeElement.querySelector("link")
186 | if (linkElement !== null) {
187 | route.link = {
188 | href: linkElement.getAttribute("href") ?? "",
189 | text: getElementValue(linkElement, "text"),
190 | type: getElementValue(linkElement, "type"),
191 | }
192 | }
193 |
194 | // Parse and store all points in the route
195 | const routePoints = Array.from(routeElement.querySelectorAll("rtept"))
196 | for (const routePoint of routePoints) {
197 | const point: Point = {
198 | latitude: parseFloat(routePoint.getAttribute("lat") ?? ""),
199 | longitude: parseFloat(routePoint.getAttribute("lon") ?? ""),
200 | elevation: null,
201 | time: null,
202 | extensions: null,
203 | }
204 |
205 | const rawElevation = parseFloat(
206 | getElementValue(routePoint, "ele") ?? ""
207 | )
208 | point.elevation = isNaN(rawElevation) ? null : rawElevation
209 |
210 | const rawTime = getElementValue(routePoint, "time")
211 | point.time = rawTime == null ? null : new Date(rawTime)
212 |
213 | route.points.push(point)
214 | }
215 |
216 | route.distance = calculateDistance(route.points)
217 | route.duration = calculateDuration(
218 | route.points,
219 | route.distance,
220 | options
221 | )
222 | route.elevation = calculateElevation(route.points)
223 | route.slopes = calculateSlopes(route.points, route.distance.cumulative)
224 |
225 | output.routes.push(route)
226 | }
227 |
228 | const tracks = Array.from(output.xml.querySelectorAll("trk"))
229 | for (const trackElement of tracks) {
230 | const track: Track = {
231 | name: getElementValue(trackElement, "name"),
232 | comment: getElementValue(trackElement, "cmt"),
233 | description: getElementValue(trackElement, "desc"),
234 | src: getElementValue(trackElement, "src"),
235 | number: getElementValue(trackElement, "number"),
236 | type: null,
237 | link: null,
238 | points: [],
239 | distance: {
240 | cumulative: [],
241 | total: 0,
242 | },
243 | duration: {
244 | cumulative: [],
245 | movingDuration: 0,
246 | totalDuration: 0,
247 | startTime: null,
248 | endTime: null,
249 | },
250 | elevation: {
251 | maximum: null,
252 | minimum: null,
253 | average: null,
254 | positive: null,
255 | negative: null,
256 | },
257 | slopes: [],
258 | }
259 |
260 | const type = querySelectDirectDescendant(trackElement, "type")
261 | track.type = type?.innerHTML ?? type?.textContent ?? null
262 |
263 | // Parse and store the link and its associated data
264 | const linkElement = trackElement.querySelector("link")
265 | if (linkElement !== null) {
266 | track.link = {
267 | href: linkElement.getAttribute("href") ?? "",
268 | text: getElementValue(linkElement, "text"),
269 | type: getElementValue(linkElement, "type"),
270 | }
271 | }
272 |
273 | // Parse and store all track points
274 | const trackPoints = Array.from(trackElement.querySelectorAll("trkpt"))
275 | for (const trackPoint of trackPoints) {
276 | const point: Point = {
277 | latitude: parseFloat(trackPoint.getAttribute("lat") ?? ""),
278 | longitude: parseFloat(trackPoint.getAttribute("lon") ?? ""),
279 | elevation: null,
280 | time: null,
281 | extensions: null,
282 | }
283 |
284 | // Parse any extensions and store them in an object
285 | const extensionsElement = trackPoint.querySelector("extensions")
286 | if (extensionsElement !== null) {
287 | let extensions: Extensions = {}
288 | extensions = parseExtensions(
289 | extensions,
290 | extensionsElement.childNodes
291 | )
292 | // Store all available extensions as numbers
293 | point.extensions = extensions
294 | }
295 |
296 | const rawElevation = parseFloat(
297 | getElementValue(trackPoint, "ele") ?? ""
298 | )
299 | point.elevation = isNaN(rawElevation) ? null : rawElevation
300 |
301 | const rawTime = getElementValue(trackPoint, "time")
302 | point.time = rawTime == null ? null : new Date(rawTime)
303 |
304 | track.points.push(point)
305 | }
306 |
307 | track.distance = calculateDistance(track.points)
308 | track.duration = calculateDuration(
309 | track.points,
310 | track.distance,
311 | options
312 | )
313 | track.elevation = calculateElevation(track.points)
314 | track.slopes = calculateSlopes(track.points, track.distance.cumulative)
315 |
316 | output.tracks.push(track)
317 | }
318 |
319 | if (options.removeEmptyFields) {
320 | deleteNullFields(output.metadata)
321 | deleteNullFields(output.waypoints)
322 | deleteNullFields(output.tracks)
323 | deleteNullFields(output.routes)
324 | }
325 |
326 | return [new ParsedGPX(output, options), null]
327 | }
328 |
329 | const parseExtensions = (
330 | extensions: Extensions,
331 | extensionChildrenCollection: NodeListOf
332 | ) => {
333 | Array.from(extensionChildrenCollection)
334 | .filter((child: ChildNode) => child.nodeType === 1)
335 | .forEach((child: ChildNode) => {
336 | const tagName = child.nodeName
337 | if (
338 | child.childNodes?.length === 1 &&
339 | child.childNodes[0].nodeType === 3 &&
340 | child.childNodes[0].textContent
341 | ) {
342 | const textContent = child.childNodes[0].textContent.trim()
343 | const value = isNaN(+textContent)
344 | ? textContent
345 | : parseFloat(textContent)
346 | extensions[tagName] = value
347 | } else {
348 | extensions[tagName] = {}
349 | extensions[tagName] = parseExtensions(
350 | extensions[tagName] as Extensions,
351 | child.childNodes
352 | )
353 | }
354 | })
355 |
356 | return extensions
357 | }
358 |
359 | /**
360 | * Extracts a value from a node within a parent element.
361 | * This is useful when there are nested tags within the DOM.
362 | *
363 | * @param parent An element to extract a value from
364 | * @param tag The tag of the child element that contains the desired data (e.g. "time" or "name")
365 | * @returns A string containing the desired value
366 | */
367 | const getElementValue = (parent: Element, tag: string): string | null => {
368 | const element = parent.querySelector(tag)
369 |
370 | // Extract and return the value within the parent element
371 | if (element !== null) {
372 | return element.firstChild?.textContent ?? element.innerHTML ?? null
373 | } else return null
374 | }
375 |
376 | /**
377 | * Find a direct descendent of the given element. If it is nested more than one layer down,
378 | * it will not be found.
379 | *
380 | * @param parent A parent element to search within
381 | * @param tag The tag of the child element to search for
382 | * @returns The desired inner element
383 | */
384 | const querySelectDirectDescendant = (
385 | parent: Element,
386 | tag: string
387 | ): Element | null => {
388 | try {
389 | // Find the first direct matching direct descendent
390 | const result = parent.querySelector(`:scope > ${tag}`)
391 | return result
392 | } catch (e) {
393 | // Handle non-browser or older environments that don't support :scope
394 | if (parent.childNodes) {
395 | return (
396 | (Array.from(parent.childNodes) as Element[]).find(
397 | (element) => element.tagName == tag
398 | ) ?? null
399 | )
400 | } else return null
401 | }
402 | }
403 |
404 | export const deleteNullFields = (object: T) => {
405 | // Return non-object values as-is
406 | if (typeof object !== "object" || object === null || object === undefined) {
407 | return
408 | }
409 |
410 | // Remove null fields from arrays
411 | if (Array.isArray(object)) {
412 | object.forEach(deleteNullFields)
413 | return
414 | }
415 |
416 | // Recursively remove null fields from object
417 | for (const [key, value] of Object.entries(object)) {
418 | if (value == null || value == undefined) {
419 | delete (object as { [key: string]: any })[key]
420 | } else {
421 | deleteNullFields(value)
422 | }
423 | }
424 | }
425 |
--------------------------------------------------------------------------------
/lib/parsed_gpx.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Feature,
3 | GeoJSON,
4 | MetaData,
5 | Options,
6 | ParsedGPXInputs,
7 | Route,
8 | Track,
9 | Waypoint,
10 | WaypointFeature,
11 | } from "./types"
12 |
13 | import { deleteNullFields } from "./parse"
14 | import { MathHelperFunction } from "./math_helpers"
15 |
16 | /**
17 | * Represents a parsed GPX object.
18 | * All internal data is accessible, and can be converted to GeoJSON.
19 | */
20 | export class ParsedGPX {
21 | public xml: Document
22 | public metadata: MetaData
23 | public waypoints: Waypoint[]
24 | public tracks: Track[]
25 | public routes: Route[]
26 | private options: Options
27 |
28 | constructor(
29 | { xml, metadata, waypoints, tracks, routes }: ParsedGPXInputs,
30 | options: Options
31 | ) {
32 | this.xml = xml
33 | this.metadata = metadata
34 | this.waypoints = waypoints
35 | this.tracks = tracks
36 | this.routes = routes
37 | this.options = options
38 | }
39 |
40 | /**
41 | * Outputs the GPX data as GeoJSON, returning a JavaScript Object.
42 | *
43 | * @returns The GPX data converted to the GeoJSON format
44 | */
45 | toGeoJSON() {
46 | const GeoJSON: GeoJSON = {
47 | type: "FeatureCollection",
48 | features: [],
49 | properties: this.metadata,
50 | }
51 |
52 | // Converts a track or route to a feature and adds it to the output object
53 | const addFeature = (track: Track | Route) => {
54 | const {
55 | name,
56 | comment,
57 | description,
58 | src,
59 | number,
60 | link,
61 | type,
62 | points,
63 | } = track
64 |
65 | const feature: Feature = {
66 | type: "Feature",
67 | geometry: { type: "LineString", coordinates: [] },
68 | properties: {
69 | name,
70 | comment,
71 | description,
72 | src,
73 | number,
74 | link,
75 | type,
76 | },
77 | }
78 |
79 | for (const point of points) {
80 | const { longitude, latitude, elevation } = point
81 | feature.geometry.coordinates.push([
82 | longitude,
83 | latitude,
84 | elevation,
85 | ])
86 | }
87 |
88 | GeoJSON.features.push(feature)
89 | }
90 |
91 | for (const track of [...this.tracks, ...this.routes]) {
92 | addFeature(track)
93 | }
94 |
95 | // Convert waypoints into features and add them to the output object
96 | for (const waypoint of this.waypoints) {
97 | const {
98 | name,
99 | symbol,
100 | comment,
101 | description,
102 | longitude,
103 | latitude,
104 | elevation,
105 | } = waypoint
106 |
107 | const feature: WaypointFeature = {
108 | type: "Feature",
109 | geometry: {
110 | type: "Point",
111 | coordinates: [longitude, latitude, elevation],
112 | },
113 | properties: { name, symbol, comment, description },
114 | }
115 |
116 | GeoJSON.features.push(feature)
117 | }
118 |
119 | if (this.options.removeEmptyFields) deleteNullFields(GeoJSON)
120 |
121 | return GeoJSON
122 | }
123 |
124 | applyToTrack(
125 | trackIndex: number,
126 | func: MathHelperFunction,
127 | ...args: any[]
128 | ): ReturnType {
129 | // Ensure that the track index is valid
130 | if (trackIndex < 0 || trackIndex >= this.tracks.length) {
131 | console.error("The track index is out of bounds.")
132 | return
133 | }
134 |
135 | // @ts-ignore: A spread argument must either have a tuple type or be passed to a rest parameter.
136 | try {
137 | return func(this.tracks[trackIndex].points, ...args)
138 | } catch (error) {
139 | throw new Error(
140 | `An error occurred in the applyToTrack function.\n${error}\n
141 | Check that the track index is valid, and that the function has the correct arguments.`
142 | )
143 | }
144 | }
145 |
146 | applyToRoute(
147 | routeIndex: number,
148 | func: MathHelperFunction,
149 | ...args: any[]
150 | ): ReturnType {
151 | // Ensure that the route index is valid
152 | if (routeIndex < 0 || routeIndex >= this.routes.length) {
153 | console.error("The route index is out of bounds.")
154 | return
155 | }
156 |
157 | // @ts-ignore: A spread argument must either have a tuple type or be passed to a rest parameter.
158 | try {
159 | return func(this.routes[routeIndex].points, ...args)
160 | } catch (error) {
161 | throw new Error(
162 | `An error occurred in the applyToRoute function.\n${error}\n
163 | Check that the route index is valid, and that the function has the correct arguments.`
164 | )
165 | }
166 | }
167 | }
168 |
--------------------------------------------------------------------------------
/lib/stringify.ts:
--------------------------------------------------------------------------------
1 | import { ParsedGPX } from "./parsed_gpx";
2 | import { Extensions } from "./types";
3 |
4 | interface XMLSerializationStrategy {
5 | serializeToString(doc: Document): string;
6 | }
7 |
8 | /**
9 | * Converts a ParsedGPX object back into a GPX XML string.
10 | * @param gpx the parsed GPX object to serialize
11 | * @param customXmlSerializer an optional custom XMLSerializer implementation.
12 | * If not specified, a default XMLSerializer instance will be created.
13 | * @returns a serialized XML string in the GPX format
14 | */
15 | export function stringifyGPX(gpx: ParsedGPX, customXmlSerializer?: XMLSerializationStrategy): string {
16 | const doc = gpx.xml.implementation.createDocument(GPX_NS, "gpx");
17 | doc.documentElement.setAttribute('version', '1.1');
18 | doc.documentElement.setAttribute('creator', 'gpxjs');
19 | const mapper = new XmlMapper(doc);
20 | mapper.mapObject(GPX_MAPPING, gpx, doc.documentElement);
21 | const serializer = customXmlSerializer ?? new XMLSerializer();
22 | return serializer.serializeToString(doc);
23 | }
24 |
25 | /****
26 | * Implementation Details
27 | *
28 | * We provide a system for specifying the mapping from a ParsedGPX data
29 | * structure to a corresponding XML structure, without having to write code for
30 | * each field.
31 | ****/
32 |
33 | // GPX XML Namespace
34 | const GPX_NS = 'http://www.topografix.com/GPX/1/1';
35 |
36 | // Special properties used in field mapping. See documentation in FieldMapping.
37 | const EXPR_PROPERTY = '$expr';
38 | const FOR_PROPERTY = '$for';
39 | const FUNC_PROPERTY = '$func';
40 |
41 | /**
42 | * Specifies the XML mapping for an object.
43 | */
44 | type ObjectMapping = {
45 | /**
46 | * Each key represents an XML element or attribute in the output value,
47 | * while the associated value specifies how that content of the element is
48 | * produced from the source data.
49 | *
50 | * To specify an attribute, use a key like '@attribute_name'. Otherwise,
51 | * the key specifies an element name.
52 | *
53 | * If the key-value is a string, it specifies the corresponding field in
54 | * the source object that will be read to fill the element value. If the
55 | * key-value is '=', then the element name will be used as the field name.
56 | *
57 | * If the key-value specifies a nested ObjectMapping, then that mapping will
58 | * be used to recursively generate additional sub-elements in the mapping.
59 | * That mapping can be tweaked with a few specific fields. See FieldMapping
60 | * for more details.
61 | */
62 | [key: string]: string | ObjectMapping | FieldMapping
63 | }
64 |
65 | /**
66 | * Specifies special behavior when mapping a sub-object.
67 | */
68 | type FieldMapping = {
69 | /**
70 | * Normally, the element name is used to determine the corresponding object
71 | * field name to use when looking up the object value used in a mapping.
72 | * The $expr property can be used to specify a different field name to use.
73 | */
74 | $expr?: string
75 | /**
76 | * For repeated elements generated from an array, specify this field with
77 | * the corresponding
78 | */
79 | $for?: ObjectMapping
80 | /**
81 | * For complex mappings, specify a function with the following signature:
82 | * (doc: Document, srcObj: any, dstElem: Element)
83 | * This function will be called, allowing one to specify arbitrary mapping
84 | * behavior.
85 | */
86 | $func?: Function
87 | }
88 |
89 | /****
90 | * GPX Schema Mapping
91 | ****/
92 |
93 | const LINK_MAPPING: ObjectMapping = {
94 | "@href": '=',
95 | text: '=',
96 | type: '='
97 | }
98 |
99 | // We need a special mapping function to handle the arbitrary data in an
100 | // Extensions object.
101 | function ExtensionsMapping(doc: Document, srcObj: Extensions, dstElem: Element) {
102 | for (const key in srcObj) {
103 | const elem = doc.createElementNS(GPX_NS, key);
104 | dstElem.append(elem);
105 | const value = srcObj[key];
106 | if (typeof value === 'object') {
107 | ExtensionsMapping(doc, value, elem);
108 | } else {
109 | const node = doc.createTextNode(value.toString());
110 | elem.append(node);
111 | }
112 | }
113 | }
114 |
115 | const POINT_MAPPING: ObjectMapping = {
116 | '@lat': 'latitude',
117 | '@lon': 'longitude',
118 | ele: 'elevation',
119 | time: '=',
120 | extensions: {
121 | $func: ExtensionsMapping
122 | }
123 | }
124 |
125 | const GPX_MAPPING: ObjectMapping = {
126 | metadata: {
127 | name: '=',
128 | desc: 'description',
129 | author: {
130 | name: '=',
131 | email: {
132 | '@id': '=',
133 | '@domain': '='
134 | },
135 | link: LINK_MAPPING
136 | },
137 | link: LINK_MAPPING,
138 | time: '='
139 | },
140 | wpt: {
141 | $expr: 'waypoints',
142 | $for: {
143 | '@lat': 'latitude',
144 | '@lon': 'longitude',
145 | name: '=',
146 | desc: 'description',
147 | ele: 'elevation',
148 | time: '=',
149 | cmt: 'comment'
150 | }
151 | },
152 | trk: {
153 | $expr: 'tracks',
154 | $for: {
155 | name: '=',
156 | cmt: 'comment',
157 | desc: 'description',
158 | src: '=',
159 | number: '=',
160 | link: LINK_MAPPING,
161 | type: '=',
162 | trkseg: {
163 | $expr: '.',
164 | trkpt: {
165 | $expr: 'points',
166 | $for: POINT_MAPPING
167 | },
168 | },
169 | },
170 | },
171 | rte: {
172 | $expr: 'routes',
173 | $for: {
174 | name: '=',
175 | cmt: 'comment',
176 | desc: 'description',
177 | src: '=',
178 | number: '=',
179 | link: LINK_MAPPING,
180 | type: '=',
181 | rtept: {
182 | $expr: 'points',
183 | $for: POINT_MAPPING
184 | }
185 | }
186 | }
187 | };
188 |
189 | class XmlMapper {
190 | private doc: Document;
191 | constructor(doc: Document) {
192 | this.doc = doc;
193 | }
194 |
195 | /**
196 | * Generate XML attributes and elements using the given mapping.
197 | */
198 | mapObject(objectMapping: ObjectMapping, srcObj: any, dstElem: Element) {
199 | for (const field in objectMapping) {
200 | if (field === EXPR_PROPERTY) {
201 | continue;
202 | }
203 | this.mapField(field, objectMapping[field], srcObj, dstElem);
204 | }
205 | }
206 |
207 | /**
208 | * Generate XML elements and attributes for the specified field.
209 | */
210 | mapField(
211 | fieldExpr: string,
212 | mapping: string | ObjectMapping | FieldMapping,
213 | srcObj: any,
214 | dstElem: Element
215 | ) {
216 | const isAttribute = fieldExpr.startsWith('@');
217 | const fieldName = isAttribute ? fieldExpr.substring(1) : fieldExpr;
218 |
219 | if (typeof mapping === "object") {
220 | const fieldMapping = mapping as FieldMapping;
221 | const fieldValue = this.evalExpr(srcObj, fieldMapping[EXPR_PROPERTY] ?? '=', fieldName);
222 | if (fieldValue == null) {
223 | return;
224 | }
225 | const forMapping = fieldMapping[FOR_PROPERTY];
226 | if (forMapping) {
227 | for (const value of fieldValue) {
228 | const elem = this.doc.createElementNS(GPX_NS, fieldName);
229 | dstElem.append(elem);
230 | this.mapObject(forMapping, value, elem);
231 | }
232 | } else {
233 | const elem = this.doc.createElementNS(GPX_NS, fieldName);
234 | dstElem.append(elem);
235 |
236 | const funcMapping = fieldMapping[FUNC_PROPERTY];
237 | if (funcMapping) {
238 | funcMapping(this.doc, fieldValue, elem);
239 | } else {
240 | this.mapObject(mapping as ObjectMapping, fieldValue, elem);
241 | }
242 | }
243 | } else if (typeof mapping === "string") {
244 | const value = this.evalExpr(srcObj, mapping, fieldName);
245 | if (value == null) {
246 | return;
247 | }
248 |
249 | if (isAttribute) {
250 | dstElem.setAttribute(fieldName, value);
251 | } else {
252 | const valueElem = this.doc.createElementNS(GPX_NS, fieldName);
253 | dstElem.append(valueElem);
254 | const node = this.doc.createTextNode(value);
255 | valueElem.append(node);
256 | }
257 | } else {
258 | throw new Error(`Unsupported field mapping: ${mapping}`)
259 | }
260 | }
261 |
262 | /**
263 | * Evalutes a field expression for the specified object. If the expression
264 | * equals `=`, then the specified `fieldName` will be used.
265 | */
266 | evalExpr(srcObj: any, expr: string, fieldName: string) {
267 | let property = expr;
268 | if (expr === '.') {
269 | return srcObj;
270 | } else if (expr === '=') {
271 | property = fieldName;
272 | }
273 | const value = srcObj[property];
274 |
275 | // Special handling for Date objects.
276 | if (value != null && typeof value === 'object' && fieldName === 'time') {
277 | return value.toISOString();
278 | }
279 |
280 | return value;
281 | }
282 | }
283 |
--------------------------------------------------------------------------------
/lib/types.ts:
--------------------------------------------------------------------------------
1 | export type MetaData = {
2 | name: string | null
3 | description: string | null
4 | link: Link | null
5 | author: Author | null
6 | time: string | null
7 | }
8 |
9 | export type Waypoint = {
10 | name: string | null
11 | symbol: string | null
12 | comment: string | null
13 | description: string | null
14 | latitude: number
15 | longitude: number
16 | elevation: number | null
17 | time: Date | null
18 | }
19 |
20 | export type Track = {
21 | name: string | null
22 | comment: string | null
23 | description: string | null
24 | src: string | null
25 | number: string | null
26 | link: Link | null
27 | type: string | null
28 | points: Point[]
29 | distance: Distance
30 | duration: Duration
31 | elevation: Elevation
32 | slopes: number[]
33 | }
34 |
35 | export type Route = {
36 | name: string | null
37 | comment: string | null
38 | description: string | null
39 | src: string | null
40 | number: string | null
41 | link: Link | null
42 | type: string | null
43 | points: Point[]
44 | distance: Distance
45 | elevation: Elevation
46 | duration: Duration
47 | slopes: number[]
48 | }
49 |
50 | export type Point = {
51 | latitude: number
52 | longitude: number
53 | elevation: number | null
54 | time: Date | null
55 | extensions: Extensions | null
56 | }
57 |
58 | export type Distance = {
59 | total: number
60 | cumulative: number[]
61 | }
62 |
63 | export type Duration = {
64 | cumulative: number[]
65 | movingDuration: number
66 | totalDuration: number
67 | startTime: Date | null
68 | endTime: Date | null
69 | }
70 |
71 | export type Elevation = {
72 | maximum: number | null
73 | minimum: number | null
74 | positive: number | null
75 | negative: number | null
76 | average: number | null
77 | }
78 |
79 | export type Author = {
80 | name: string | null
81 | email: Email | null
82 | link: Link | null
83 | }
84 |
85 | export type Email = {
86 | id: string | null
87 | domain: string | null
88 | }
89 |
90 | export type Link = {
91 | href: string | null
92 | text: string | null
93 | type: string | null
94 | }
95 |
96 | export type ParsedGPXInputs = {
97 | xml: Document
98 | metadata: MetaData
99 | waypoints: Waypoint[]
100 | tracks: Track[]
101 | routes: Route[]
102 | }
103 |
104 | export type Feature = {
105 | type: string
106 | geometry: {
107 | type: string
108 | coordinates: (number | null)[][]
109 | }
110 | properties: {
111 | [key: string]: string | number | Link | null
112 | }
113 | }
114 |
115 | export type WaypointFeature = {
116 | type: string
117 | geometry: {
118 | type: string
119 | coordinates: (number | null)[]
120 | }
121 | properties: {
122 | [key: string]: string | number | Link | null
123 | }
124 | }
125 |
126 | export type GeoJSON = {
127 | type: string
128 | features: (Feature | WaypointFeature)[]
129 | properties: MetaData
130 | }
131 |
132 | export type Extensions = {
133 | [key: string]: string | number | Extensions
134 | }
135 |
136 | export type Options = {
137 | removeEmptyFields: boolean
138 | avgSpeedThreshold: number
139 | }
140 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@we-gold/gpxjs",
3 | "author": "Weaver Goldman ",
4 | "description": "GPX.js is a modern library for parsing GPX files and converting them to GeoJSON.",
5 | "version": "1.1.0",
6 | "type": "module",
7 | "license": "MIT",
8 | "repository": {
9 | "type": "git",
10 | "url": "https://github.com/We-Gold/gpxjs"
11 | },
12 | "bugs": {
13 | "url": "https://github.com/We-Gold/gpxjs/issues"
14 | },
15 | "keywords": [
16 | "gpx",
17 | "geojson",
18 | "typescript",
19 | "geolocation",
20 | "apple-watch",
21 | "gps"
22 | ],
23 | "files": [
24 | "dist"
25 | ],
26 | "main": "./dist/gpxjs.umd.cjs",
27 | "module": "./dist/gpxjs.js",
28 | "types": "./dist/index.d.ts",
29 | "exports": {
30 | ".": {
31 | "import": "./dist/gpxjs.js",
32 | "require": "./dist/gpxjs.umd.cjs",
33 | "types": "./dist/index.d.ts"
34 | }
35 | },
36 | "scripts": {
37 | "dev": "vite",
38 | "build": "tsc && vite build",
39 | "watch": "vite build --watch",
40 | "pack-test": "npm pack --dry-run",
41 | "preview": "vite preview",
42 | "test": "vitest"
43 | },
44 | "devDependencies": {
45 | "@types/node": "^20.3.1",
46 | "@vitest/browser": "^2.1.8",
47 | "@wdio/cli": "^8.36.1",
48 | "typescript": "^5.0.2",
49 | "vite": "^4.3.9",
50 | "vite-plugin-dts": "^2.3.0",
51 | "vitest": "^2.1.8",
52 | "xmldom-qsa": "^1.1.3"
53 | },
54 | "prettier": {
55 | "trailingComma": "es5",
56 | "tabWidth": 4,
57 | "semi": false,
58 | "useTabs": true
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | import { parseGPX, parseGPXWithCustomParser } from "../lib"
2 | import { DOMParser } from "xmldom-qsa"
3 |
4 | fetch("./src/test_files/test.gpx")
5 | .then((response) => {
6 | if (!response.ok) {
7 | throw new Error("Failed to fetch the file")
8 | }
9 | return response.text()
10 | })
11 | .then((textData) => {
12 | testBrowserParser(textData)
13 | testNonBrowserParser(textData)
14 | })
15 | .catch((error) => {
16 | console.error("Error:", error)
17 | })
18 |
19 | const testBrowserParser = (textData: string) => {
20 | console.log("\nBROWSER MODE")
21 | let startTime = performance.now()
22 |
23 | const [parsedGPX, error] = parseGPX(textData)
24 |
25 | // Verify that the parsing was successful
26 | if (error) throw error
27 |
28 | let endTime = performance.now()
29 | console.log("Execution time:", endTime - startTime, "ms")
30 | console.log(parsedGPX)
31 |
32 | startTime = performance.now()
33 |
34 | const GeoJSON = parsedGPX.toGeoJSON()
35 |
36 | endTime = performance.now()
37 | console.log("Execution time:", endTime - startTime, "ms")
38 | console.log(GeoJSON)
39 | }
40 |
41 | const testNonBrowserParser = (textData: string) => {
42 | console.log("\nNONBROWSER MODE")
43 | let startTime = performance.now()
44 |
45 | const [parsedGPX, error] = parseGPXWithCustomParser(
46 | textData,
47 | (txt: string): Document | null =>
48 | new DOMParser().parseFromString(txt, "text/xml")
49 | )
50 |
51 | // Verify that the parsing was successful
52 | if (error) throw error
53 |
54 | let endTime = performance.now()
55 | console.log("Execution time:", endTime - startTime, "ms")
56 | console.log(parsedGPX)
57 |
58 | startTime = performance.now()
59 |
60 | const GeoJSON = parsedGPX.toGeoJSON()
61 |
62 | endTime = performance.now()
63 | console.log("Execution time:", endTime - startTime, "ms")
64 | console.log(GeoJSON)
65 | }
66 |
--------------------------------------------------------------------------------
/test/helpers.spec.js:
--------------------------------------------------------------------------------
1 | import { test } from "vitest"
2 |
3 | import { parseGPX, calculateDistance, calculateDuration, calculateElevation, calculateSlopes } from "../lib/index"
4 |
5 | import { testGPXFile } from "./test-gpx-file"
6 |
7 | test("All applied functions produce outputs without errors.", () => {
8 | const [parsedGPX, error] = parseGPX(testGPXFile)
9 |
10 | // Verify that the parsing was successful
11 | if (error) throw error
12 |
13 | // Apply all functions to the first track
14 | const tdist = parsedGPX.applyToTrack(0, calculateDistance)
15 | parsedGPX.applyToTrack(0, calculateDuration)
16 | parsedGPX.applyToTrack(0, calculateElevation)
17 | parsedGPX.applyToTrack(0, calculateSlopes, tdist.cumulative)
18 |
19 | // Apply all functions to the first route
20 | const rdist = parsedGPX.applyToRoute(0, calculateDistance)
21 | parsedGPX.applyToRoute(0, calculateDuration)
22 | parsedGPX.applyToRoute(0, calculateElevation)
23 | parsedGPX.applyToRoute(0, calculateSlopes, rdist.cumulative)
24 | })
--------------------------------------------------------------------------------
/test/parse.spec.js:
--------------------------------------------------------------------------------
1 | import { expect, test, assertType } from "vitest"
2 |
3 | import { DOMParser } from "xmldom-qsa"
4 | import { parseGPX, parseGPXWithCustomParser } from "../lib/index"
5 |
6 | import { testGPXFile, expectedMetadata, expectedWaypoint, expectedRoute, expectedTrack } from "./test-gpx-file"
7 |
8 | // TODO test GeoJSON, benchmarks
9 |
10 | // TODO: Noted inconsistencies while testing
11 | // Missing some metadata information
12 | // Parse some times as dates and some as strings
13 | // Some items match abbreviations, some don't
14 |
15 | test("Default parsing returns expected result", () => {
16 | const [parsedGPX, error] = parseGPX(testGPXFile)
17 |
18 | // Verify that the parsing was successful
19 | if (error) throw error
20 |
21 | assertType(parsedGPX.xml)
22 |
23 | // Test metadata from test file
24 | expect(expectedMetadata).toStrictEqual(parsedGPX.metadata)
25 |
26 | // Test waypoint data
27 | const waypoint = parsedGPX.waypoints[0]
28 |
29 | expect(waypoint).not.toBeNull()
30 | expect(waypoint).toStrictEqual(expectedWaypoint)
31 |
32 | // Test track information
33 | const track = parsedGPX.tracks[0]
34 |
35 | expect(track).not.toBeNull()
36 | expect(track).toStrictEqual(expectedTrack)
37 |
38 | // Test route information
39 | const route = parsedGPX.routes[0]
40 |
41 | expect(route).not.toBeNull()
42 | expect(route).toStrictEqual(expectedRoute)
43 | })
44 |
45 | test("Non-browser parsing returns expected result", () => {
46 | const customParseMethod = (txt)=> {
47 | return new DOMParser().parseFromString(txt, "text/xml")
48 | }
49 |
50 | const [parsedGPX, error] = parseGPXWithCustomParser(
51 | testGPXFile,
52 | customParseMethod
53 | )
54 |
55 | // Verify that the parsing was successful
56 | if (error) throw error
57 |
58 | assertType(parsedGPX.xml)
59 |
60 | // Test metadata from test file
61 | expect(expectedMetadata).toStrictEqual(parsedGPX.metadata)
62 |
63 | // Test waypoint data
64 | const waypoint = parsedGPX.waypoints[0]
65 |
66 | expect(waypoint).not.toBeNull()
67 | expect(waypoint).toStrictEqual(expectedWaypoint)
68 |
69 | // Test track information
70 | const track = parsedGPX.tracks[0]
71 |
72 | expect(track).not.toBeNull()
73 | expect(track).toStrictEqual(expectedTrack)
74 |
75 | // Test route information
76 | const route = parsedGPX.routes[0]
77 |
78 | expect(route).not.toBeNull()
79 | expect(route).toStrictEqual(expectedRoute)
80 | })
--------------------------------------------------------------------------------
/test/stringify.spec.js:
--------------------------------------------------------------------------------
1 | import { expect, test, assertType, describe } from "vitest"
2 |
3 | import { XMLSerializer as QsaXMLSerializer } from "xmldom-qsa"
4 | import { parseGPX } from "../lib/index";
5 | import { stringifyGPX } from "../lib/stringify";
6 |
7 | import { testGPXFile } from "./test-gpx-file"
8 |
9 | describe("stringfy", () => {
10 | test("converts ParsedGPX to string", () => {
11 | const [gpx, error] = parseGPX(testGPXFile);
12 | const xml = stringifyGPX(gpx);
13 | expect(prettyPrintXml(xml)).toEqual(prettyPrintXml(EXPECTED_XML));
14 | })
15 |
16 | test("converts ParsedGPX to string with custom XMLSerializer", () => {
17 | const [gpx, error] = parseGPX(testGPXFile);
18 | const xml = stringifyGPX(gpx, new QsaXMLSerializer());
19 | expect(prettyPrintXml(xml)).toEqual(prettyPrintXml(EXPECTED_XML));
20 | })
21 | });
22 |
23 | const EXPECTED_XML =
24 | `
25 |
26 | GPX Test
27 | Test Description
28 |
29 | Test Author
30 |
31 |
32 | Author Website
33 | Web
34 |
35 |
36 |
37 | General Website
38 | Web
39 |
40 |
41 |
42 |
43 | Porte de Carquefou
44 | Route
45 | 35
46 |
47 | Warning
48 |
49 |
50 | Pont de la Tortière
51 | Route
52 | 20
53 |
54 | Bridge
55 |
56 |
57 | Track
58 | Bridge
59 | Test track
60 | GPX Test Device
61 | 1
62 |
63 | Track Website
64 | Web
65 |
66 | MTB
67 |
68 |
69 | 12.36
70 |
71 |
72 | testString
73 | 3
74 | 1.75
75 |
76 | 33
77 |
78 |
79 |
80 |
81 |
82 |
83 | Track
84 | Bridge
85 | Test route
86 | GPX Test Device
87 | 1
88 |
89 | Route Website
90 | Web
91 |
92 | MTB
93 |
94 | 12.36
95 |
96 |
97 |
98 | `
99 |
100 | /****
101 | * Test Support Methods
102 | ****/
103 |
104 | const XSLT_PRETTY_PRINT = [
105 | '',
106 | ' ',
107 | ' ',
108 | ' ',
109 | ' ',
110 | '',
111 | ].join('\n');
112 |
113 | function prettyPrintXml(xml) {
114 | const parser = new DOMParser();
115 |
116 | const xsltDoc = parser.parseFromString(XSLT_PRETTY_PRINT, 'text/xml');
117 | const xsltProcessor = new XSLTProcessor();
118 | xsltProcessor.importStylesheet(xsltDoc);
119 |
120 | const doc = parser.parseFromString(xml, 'text/xml');
121 | const prettyDoc = xsltProcessor.transformToDocument(doc);
122 | return new XMLSerializer().serializeToString(prettyDoc);
123 | }
124 |
--------------------------------------------------------------------------------
/test/test-gpx-file.js:
--------------------------------------------------------------------------------
1 | export const testGPXFile =
2 | `
3 |
4 |
5 | GPX Test
6 | Test Description
7 |
8 | Test Author
9 |
10 |
11 | Author Website
12 | Web
13 |
14 |
15 |
16 | 2024
17 | MIT
18 |
19 |
20 | General Website
21 | Web
22 |
23 |
24 | Test, gpx, file
25 |
27 |
28 |
29 | Porte de Carquefou
30 | Route
31 | 35
32 |
33 | Warning
34 |
35 |
36 | Pont de la Tortière
37 | Route
38 | 20
39 |
40 | Bridge
41 |
42 |
43 | Track
44 | Bridge
45 | Test track
46 | GPX Test Device
47 | 1
48 |
49 | Track Website
50 | Web
51 |
52 | MTB
53 |
54 |
55 | 12.36
56 |
57 |
58 | testString
59 | 3
60 | 1.75
61 |
62 | 33.0
63 |
64 |
65 |
66 |
67 |
68 |
69 | Track
70 | Bridge
71 | Test route
72 | GPX Test Device
73 | 1
74 |
75 | Route Website
76 | Web
77 |
78 | MTB
79 |
80 | 12.36
81 |
82 |
83 |
84 | `
85 |
86 | export const expectedMetadata = {
87 | name: "GPX Test",
88 | description: "Test Description",
89 | time: "2020-01-12T21:32:52",
90 | link: {
91 | href: "https://test2.com",
92 | text: "General Website",
93 | type: "Web"
94 | },
95 | author: {
96 | name: "Test Author",
97 | email: {
98 | id: "test",
99 | domain: "test.com",
100 | },
101 | link: {
102 | href: "https://test.com",
103 | text: "Author Website",
104 | type: "Web"
105 | }
106 | },
107 | }
108 |
109 | export const expectedWaypoint = {
110 | name: "Porte de Carquefou",
111 | latitude: 47.253146555709,
112 | longitude: -1.5153741828293,
113 | elevation: 35,
114 | comment: "Warning",
115 | description: "Route",
116 | time: new Date("2020-02-02T07:54:30.000Z")
117 | }
118 |
119 | export const expectedTrack = {
120 | name: "Track",
121 | comment: "Bridge",
122 | description: "Test track",
123 | src: "GPX Test Device",
124 | number: "1",
125 | type: "MTB",
126 | link: {
127 | href: "https://test.com",
128 | text: "Track Website",
129 | type: "Web"
130 | },
131 | distance: {
132 | cumulative: [0],
133 | total: 0,
134 | },
135 | duration: {
136 | cumulative: [0],
137 | movingDuration: 0,
138 | totalDuration: 0,
139 | },
140 | elevation: {
141 | average: 12.36,
142 | maximum: 12.36,
143 | minimum: 12.36,
144 | },
145 | points: [
146 | {
147 | elevation: 12.36,
148 | extensions: {
149 | floatext: 1.75,
150 | intext: 3,
151 | strext: "testString",
152 | subext: {
153 | subval: 33
154 | },
155 | },
156 | latitude: 47.2278526991611,
157 | longitude: -1.5521714646550901,
158 | time: new Date("2020-02-02T07:54:30.000Z"),
159 | }
160 | ],
161 | slopes: [],
162 | }
163 |
164 | export const expectedRoute = {
165 | name: "Track",
166 | comment: "Bridge",
167 | description: "Test route",
168 | src: "GPX Test Device",
169 | number: "1",
170 | type: "MTB",
171 | link: {
172 | href: "https://test.com",
173 | text: "Route Website",
174 | type: "Web"
175 | },
176 | distance: {
177 | cumulative: [0],
178 | total: 0,
179 | },
180 | duration: {
181 | cumulative: [0],
182 | movingDuration: 0,
183 | totalDuration: 0,
184 | },
185 | elevation: {
186 | average: 12.36,
187 | maximum: 12.36,
188 | minimum: 12.36,
189 | },
190 | points: [
191 | {
192 | latitude: 47.2278526991611,
193 | longitude: -1.5521714646550901,
194 | elevation: 12.36,
195 | time: new Date("2020-02-02T07:54:30.000Z"),
196 | },
197 | ],
198 | slopes: [],
199 | }
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "module": "ESNext",
6 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "noEmit": true,
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noFallthroughCasesInSwitch": true
21 | },
22 | "include": ["src", "lib"]
23 | }
24 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import { resolve } from 'path'
2 | import { defineConfig } from 'vite'
3 | import dts from 'vite-plugin-dts'
4 |
5 | export default defineConfig({
6 | esbuild: {
7 | keepNames: true,
8 | minifyIdentifiers: false,
9 | },
10 | build: {
11 | minify: 'esbuild',
12 | lib: {
13 | entry: resolve(__dirname, 'lib/index.ts'),
14 | name: 'gpxjs',
15 | fileName: 'gpxjs',
16 | }
17 | },
18 | test: {
19 | browser: {
20 | provider: 'playwright',
21 | enabled: true,
22 | name: 'chromium',
23 | headless: true,
24 | },
25 | },
26 | plugins: [dts()],
27 | define: {
28 | // Shim required for using the custom parser
29 | global: {},
30 | },
31 | })
32 |
--------------------------------------------------------------------------------