├── .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 | ![GitHub package.json version (branch)](https://img.shields.io/github/package-json/v/We-Gold/gpxjs/main?label=npm%20version&color=green&link=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F@we-gold/gpxjs) 2 | ![npm bundle size](https://img.shields.io/bundlephobia/min/@we-gold/gpxjs?color=green) 3 | [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](https://github.com/We-Gold/gpxjs/issues) 4 | ![ViewCount](https://views.whatilearened.today/views/github/We-Gold/gpxjs.svg) 5 | [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) 6 | ![NPM Downloads](https://img.shields.io/npm/dw/@we-gold/gpxjs) 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 | --------------------------------------------------------------------------------