UNPKG

simple-elevation-chart

Version:

Very simple SVG-based elevation chart

1,064 lines (737 loc) 26.2 kB
var __defProp = Object.defineProperty var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: !0, configurable: !0, writable: !0, value }) : (obj[key] = value) var __name = (target, value) => __defProp(target, 'name', { value, configurable: !0 }) var __publicField = (obj, key, value) => ( __defNormalProp(obj, typeof key != 'symbol' ? key + '' : key, value), value ) const DEFAULT_OPTIONS = { // delete null fields in output removeEmptyFields: !0, // average speed threshold (in m/ms) used to determine the moving duration avgSpeedThreshold: 215e-6, }, calculateDistance = /* @__PURE__ */ __name((points) => { const cumulativeDistance = [0] for (let i = 0; i < points.length - 1; i++) { const currentTotalDistance = cumulativeDistance[i] + haversineDistance(points[i], points[i + 1]) cumulativeDistance.push(currentTotalDistance) } return { cumulative: cumulativeDistance, total: cumulativeDistance[cumulativeDistance.length - 1], } }, 'calculateDistance'), haversineDistance = /* @__PURE__ */ __name((point1, point2) => { const toRadians = /* @__PURE__ */ __name( (degrees) => (degrees * Math.PI) / 180, 'toRadians', ), lat1Radians = toRadians(point1.latitude), lat2Radians = toRadians(point2.latitude), sinDeltaLatitude = Math.sin(toRadians(point2.latitude - point1.latitude) / 2), sinDeltaLongitude = Math.sin(toRadians(point2.longitude - point1.longitude) / 2), a = sinDeltaLatitude ** 2 + Math.cos(lat1Radians) * Math.cos(lat2Radians) * sinDeltaLongitude ** 2 return 6371e3 * (2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))) }, 'haversineDistance'), calculateDuration = /* @__PURE__ */ __name( (points, distance, calculOptions = DEFAULT_OPTIONS) => { var _a const { avgSpeedThreshold } = calculOptions, allTimedPoints = [], cumulative = [0] let lastTime = 0 for (let i = 0; i < points.length - 1; i++) { const time = points[i].time, dist = distance.cumulative[i], previousPoint = cumulative[i] if (time !== null) { const movingTime = time.getTime() - lastTime if (movingTime > 0) { let sumDistances = 0, sumTime = 0 for (let j = i; j >= 0; j--) { const prevTime = (_a = points[j].time) == null ? void 0 : _a.getTime() if (prevTime !== void 0) { const timeDiff = time.getTime() - prevTime if (timeDiff > 1e4) break ;(sumDistances += distance.cumulative[j + 1] - distance.cumulative[j]), (sumTime += timeDiff) } } const nextCumul = (sumTime > 0 ? sumDistances / sumTime : 0) > avgSpeedThreshold ? previousPoint + movingTime : previousPoint cumulative.push(nextCumul) } else cumulative.push(previousPoint) ;(lastTime = time.getTime()), allTimedPoints.push({ time, distance: dist }) } else cumulative.push(previousPoint) } const totalDuration = allTimedPoints.length === 0 ? 0 : allTimedPoints[allTimedPoints.length - 1].time.getTime() - allTimedPoints[0].time.getTime() return { startTime: allTimedPoints.length ? allTimedPoints[0].time : null, endTime: allTimedPoints.length ? allTimedPoints[allTimedPoints.length - 1].time : null, cumulative, movingDuration: cumulative[cumulative.length - 1] / 1e3, // Convert to seconds totalDuration: totalDuration / 1e3, // Convert to seconds } }, 'calculateDuration', ), calculateElevation = /* @__PURE__ */ __name((points) => { var _a, _b let dp = 0, dn = 0 const elevation = [] let sum = 0 for (let i = 0; i < points.length - 1; i++) { const nextElevation = (_a = points[i + 1]) == null ? void 0 : _a.elevation, currentElevation = (_b = points[i]) == null ? void 0 : _b.elevation if (nextElevation !== null && currentElevation !== null) { const diff = nextElevation - currentElevation diff < 0 ? (dn += diff) : diff > 0 && (dp += diff) } } for (const point of points) point.elevation !== null && (elevation.push(point.elevation), (sum += point.elevation)) let max = elevation[0] ?? null, min = elevation[0] ?? null for (let i = 1; i < elevation.length; i++) elevation[i] > max && (max = elevation[i]), elevation[i] < min && (min = elevation[i]) return { maximum: max, minimum: min, positive: Math.abs(dp) || null, negative: Math.abs(dn) || null, average: elevation.length ? sum / elevation.length : null, } }, 'calculateElevation'), calculateSlopes = /* @__PURE__ */ __name((points, cumulativeDistance) => { var _a, _b const slopes = [] for (let i = 0; i < points.length - 1; i++) { const nextElevation = (_a = points[i + 1]) == null ? void 0 : _a.elevation, currentElevation = (_b = points[i]) == null ? void 0 : _b.elevation if (nextElevation !== null && currentElevation !== null) { const elevationDifference = nextElevation - currentElevation, displacement = cumulativeDistance[i + 1] - cumulativeDistance[i], slope = (elevationDifference * 100) / displacement slopes.push(slope) } } return slopes }, 'calculateSlopes'), parseGPX = /* @__PURE__ */ __name((gpxSource, options = DEFAULT_OPTIONS) => { const parseMethod = /* @__PURE__ */ __name( (gpxSource2) => typeof document > 'u' ? null : typeof window > 'u' ? (console.error( 'window is undefined, try to use the parseGPXWithCustomParser method', ), null) : new window.DOMParser().parseFromString(gpxSource2, 'text/xml'), 'parseMethod', ), allOptions = { ...DEFAULT_OPTIONS, ...options } return parseGPXWithCustomParser(gpxSource, parseMethod, allOptions) }, 'parseGPX'), parseGPXWithCustomParser = /* @__PURE__ */ __name( (gpxSource, parseGPXToXML, options = DEFAULT_OPTIONS) => { const parsedSource = parseGPXToXML(gpxSource) if (parsedSource === null) return [null, new Error('Provided parsing method failed.')] const output = { xml: parsedSource, metadata: { name: '', description: '', time: '', author: null, link: null, }, waypoints: [], tracks: [], routes: [], }, metadata = output.xml.querySelector('metadata') if (metadata !== null) { ;(output.metadata.name = getElementValue(metadata, 'name')), (output.metadata.description = getElementValue(metadata, 'desc')), (output.metadata.time = getElementValue(metadata, 'time')) const authorElement = metadata.querySelector('author') if (authorElement !== null) { const emailElement = authorElement.querySelector('email'), linkElement2 = authorElement.querySelector('link') output.metadata.author = { name: getElementValue(authorElement, 'name'), email: emailElement !== null ? { id: emailElement.getAttribute('id') ?? '', domain: emailElement.getAttribute('domain') ?? '', } : null, link: linkElement2 !== null ? { href: linkElement2.getAttribute('href') ?? '', text: getElementValue(linkElement2, 'text'), type: getElementValue(linkElement2, 'type'), } : null, } } const linkElement = querySelectDirectDescendant(metadata, 'link') linkElement !== null && (output.metadata.link = { href: linkElement.getAttribute('href') ?? '', text: getElementValue(linkElement, 'text'), type: getElementValue(linkElement, 'type'), }) } const waypoints = Array.from(output.xml.querySelectorAll('wpt')) for (const waypoint of waypoints) { const point = { name: getElementValue(waypoint, 'name'), symbol: getElementValue(waypoint, 'sym'), latitude: parseFloat(waypoint.getAttribute('lat') ?? ''), longitude: parseFloat(waypoint.getAttribute('lon') ?? ''), elevation: null, comment: getElementValue(waypoint, 'cmt'), description: getElementValue(waypoint, 'desc'), time: null, }, rawElevation = parseFloat(getElementValue(waypoint, 'ele') ?? '') point.elevation = isNaN(rawElevation) ? null : rawElevation const rawTime = getElementValue(waypoint, 'time') ;(point.time = rawTime == null ? null : new Date(rawTime)), output.waypoints.push(point) } const routes = Array.from(output.xml.querySelectorAll('rte')) for (const routeElement of routes) { const route = { name: getElementValue(routeElement, 'name'), comment: getElementValue(routeElement, 'cmt'), description: getElementValue(routeElement, 'desc'), src: getElementValue(routeElement, 'src'), number: getElementValue(routeElement, 'number'), type: null, link: null, points: [], distance: { cumulative: [], total: 0, }, duration: { cumulative: [], movingDuration: 0, totalDuration: 0, endTime: null, startTime: null, }, elevation: { maximum: null, minimum: null, average: null, positive: null, negative: null, }, slopes: [], }, type = querySelectDirectDescendant(routeElement, 'type') route.type = (type == null ? void 0 : type.innerHTML) ?? (type == null ? void 0 : type.textContent) ?? null const linkElement = routeElement.querySelector('link') linkElement !== null && (route.link = { href: linkElement.getAttribute('href') ?? '', text: getElementValue(linkElement, 'text'), type: getElementValue(linkElement, 'type'), }) const routePoints = Array.from(routeElement.querySelectorAll('rtept')) for (const routePoint of routePoints) { const point = { latitude: parseFloat(routePoint.getAttribute('lat') ?? ''), longitude: parseFloat(routePoint.getAttribute('lon') ?? ''), elevation: null, time: null, extensions: null, }, rawElevation = parseFloat(getElementValue(routePoint, 'ele') ?? '') point.elevation = isNaN(rawElevation) ? null : rawElevation const rawTime = getElementValue(routePoint, 'time') ;(point.time = rawTime == null ? null : new Date(rawTime)), route.points.push(point) } ;(route.distance = calculateDistance(route.points)), (route.duration = calculateDuration( route.points, route.distance, options, )), (route.elevation = calculateElevation(route.points)), (route.slopes = calculateSlopes(route.points, route.distance.cumulative)), output.routes.push(route) } const tracks = Array.from(output.xml.querySelectorAll('trk')) for (const trackElement of tracks) { const track = { name: getElementValue(trackElement, 'name'), comment: getElementValue(trackElement, 'cmt'), description: getElementValue(trackElement, 'desc'), src: getElementValue(trackElement, 'src'), number: getElementValue(trackElement, 'number'), type: null, link: null, points: [], distance: { cumulative: [], total: 0, }, duration: { cumulative: [], movingDuration: 0, totalDuration: 0, startTime: null, endTime: null, }, elevation: { maximum: null, minimum: null, average: null, positive: null, negative: null, }, slopes: [], }, type = querySelectDirectDescendant(trackElement, 'type') track.type = (type == null ? void 0 : type.innerHTML) ?? (type == null ? void 0 : type.textContent) ?? null const linkElement = trackElement.querySelector('link') linkElement !== null && (track.link = { href: linkElement.getAttribute('href') ?? '', text: getElementValue(linkElement, 'text'), type: getElementValue(linkElement, 'type'), }) const trackPoints = Array.from(trackElement.querySelectorAll('trkpt')) for (const trackPoint of trackPoints) { const point = { latitude: parseFloat(trackPoint.getAttribute('lat') ?? ''), longitude: parseFloat(trackPoint.getAttribute('lon') ?? ''), elevation: null, time: null, extensions: null, }, extensionsElement = trackPoint.querySelector('extensions') if (extensionsElement !== null) { let extensions = {} ;(extensions = parseExtensions( extensions, extensionsElement.childNodes, )), (point.extensions = extensions) } const rawElevation = parseFloat(getElementValue(trackPoint, 'ele') ?? '') point.elevation = isNaN(rawElevation) ? null : rawElevation const rawTime = getElementValue(trackPoint, 'time') ;(point.time = rawTime == null ? null : new Date(rawTime)), track.points.push(point) } ;(track.distance = calculateDistance(track.points)), (track.duration = calculateDuration( track.points, track.distance, options, )), (track.elevation = calculateElevation(track.points)), (track.slopes = calculateSlopes(track.points, track.distance.cumulative)), output.tracks.push(track) } return ( options.removeEmptyFields && (deleteNullFields(output.metadata), deleteNullFields(output.waypoints), deleteNullFields(output.tracks), deleteNullFields(output.routes)), [new ParsedGPX(output, options), null] ) }, 'parseGPXWithCustomParser', ), parseExtensions = /* @__PURE__ */ __name( (extensions, extensionChildrenCollection) => ( Array.from(extensionChildrenCollection) .filter((child) => child.nodeType === 1) .forEach((child) => { var _a const tagName = child.nodeName if ( ((_a = child.childNodes) == null ? void 0 : _a.length) === 1 && child.childNodes[0].nodeType === 3 && child.childNodes[0].textContent ) { const textContent = child.childNodes[0].textContent.trim(), value = isNaN(+textContent) ? textContent : parseFloat(textContent) extensions[tagName] = value } else (extensions[tagName] = {}), (extensions[tagName] = parseExtensions( extensions[tagName], child.childNodes, )) }), extensions ), 'parseExtensions', ), getElementValue = /* @__PURE__ */ __name((parent, tag) => { var _a const element = parent.querySelector(tag) return element !== null ? (((_a = element.firstChild) == null ? void 0 : _a.textContent) ?? element.innerHTML ?? null) : null }, 'getElementValue'), querySelectDirectDescendant = /* @__PURE__ */ __name((parent, tag) => { try { return parent.querySelector(`:scope > ${tag}`) } catch { return parent.childNodes ? (Array.from(parent.childNodes).find((element) => element.tagName == tag) ?? null) : null } }, 'querySelectDirectDescendant'), deleteNullFields = /* @__PURE__ */ __name((object) => { if (!(typeof object != 'object' || object === null || object === void 0)) { if (Array.isArray(object)) { object.forEach(deleteNullFields) return } for (const [key, value] of Object.entries(object)) value == null || value == null ? delete object[key] : deleteNullFields(value) } }, 'deleteNullFields'), _ParsedGPX = class _ParsedGPX { constructor({ xml, metadata, waypoints, tracks, routes }, options) { __publicField(this, 'xml') __publicField(this, 'metadata') __publicField(this, 'waypoints') __publicField(this, 'tracks') __publicField(this, 'routes') __publicField(this, 'options') ;(this.xml = xml), (this.metadata = metadata), (this.waypoints = waypoints), (this.tracks = tracks), (this.routes = routes), (this.options = options) } /** * Outputs the GPX data as GeoJSON, returning a JavaScript Object. * * @returns The GPX data converted to the GeoJSON format */ toGeoJSON() { const GeoJSON2 = { type: 'FeatureCollection', features: [], properties: this.metadata, }, addFeature = /* @__PURE__ */ __name((track) => { const { name, comment, description, src, number, link, type, points, } = track, feature = { type: 'Feature', geometry: { type: 'LineString', coordinates: [] }, properties: { name, comment, description, src, number, link, type, }, } for (const point of points) { const { longitude, latitude, elevation } = point feature.geometry.coordinates.push([ longitude, latitude, elevation, ]) } GeoJSON2.features.push(feature) }, 'addFeature') for (const track of [...this.tracks, ...this.routes]) addFeature(track) for (const waypoint of this.waypoints) { const { name, symbol, comment, description, longitude, latitude, elevation, } = waypoint, feature = { type: 'Feature', geometry: { type: 'Point', coordinates: [longitude, latitude, elevation], }, properties: { name, symbol, comment, description }, } GeoJSON2.features.push(feature) } return this.options.removeEmptyFields && deleteNullFields(GeoJSON2), GeoJSON2 } applyToTrack(trackIndex, func, ...args) { if (trackIndex < 0 || trackIndex >= this.tracks.length) { console.error('The track index is out of bounds.') return } try { return func(this.tracks[trackIndex].points, ...args) } catch (error) { throw new Error( `An error occurred in the applyToTrack function. ${error} Check that the track index is valid, and that the function has the correct arguments.`, ) } } applyToRoute(routeIndex, func, ...args) { if (routeIndex < 0 || routeIndex >= this.routes.length) { console.error('The route index is out of bounds.') return } try { return func(this.routes[routeIndex].points, ...args) } catch (error) { throw new Error( `An error occurred in the applyToRoute function. ${error} Check that the route index is valid, and that the function has the correct arguments.`, ) } } } __name(_ParsedGPX, 'ParsedGPX') const ParsedGPX = _ParsedGPX function stringifyGPX(gpx, customXmlSerializer) { const doc = gpx.xml.implementation.createDocument(GPX_NS, 'gpx') return ( doc.documentElement.setAttribute('version', '1.1'), doc.documentElement.setAttribute('creator', 'gpxjs'), new XmlMapper(doc).mapObject(GPX_MAPPING, gpx, doc.documentElement), (customXmlSerializer ?? new XMLSerializer()).serializeToString(doc) ) } __name(stringifyGPX, 'stringifyGPX') const GPX_NS = 'http://www.topografix.com/GPX/1/1', EXPR_PROPERTY = '$expr', FOR_PROPERTY = '$for', FUNC_PROPERTY = '$func', LINK_MAPPING = { '@href': '=', text: '=', type: '=', } function ExtensionsMapping(doc, srcObj, dstElem) { for (const key in srcObj) { const elem = doc.createElementNS(GPX_NS, key) dstElem.append(elem) const value = srcObj[key] if (typeof value == 'object') ExtensionsMapping(doc, value, elem) else { const node = doc.createTextNode(value.toString()) elem.append(node) } } } __name(ExtensionsMapping, 'ExtensionsMapping') const POINT_MAPPING = { '@lat': 'latitude', '@lon': 'longitude', ele: 'elevation', time: '=', extensions: { $func: ExtensionsMapping, }, }, GPX_MAPPING = { metadata: { name: '=', desc: 'description', author: { name: '=', email: { '@id': '=', '@domain': '=', }, link: LINK_MAPPING, }, link: LINK_MAPPING, time: '=', }, wpt: { $expr: 'waypoints', $for: { '@lat': 'latitude', '@lon': 'longitude', name: '=', desc: 'description', ele: 'elevation', time: '=', cmt: 'comment', }, }, trk: { $expr: 'tracks', $for: { name: '=', cmt: 'comment', desc: 'description', src: '=', number: '=', link: LINK_MAPPING, type: '=', trkseg: { $expr: '.', trkpt: { $expr: 'points', $for: POINT_MAPPING, }, }, }, }, rte: { $expr: 'routes', $for: { name: '=', cmt: 'comment', desc: 'description', src: '=', number: '=', link: LINK_MAPPING, type: '=', rtept: { $expr: 'points', $for: POINT_MAPPING, }, }, }, }, _XmlMapper = class _XmlMapper { constructor(doc) { __publicField(this, 'doc') this.doc = doc } /** * Generate XML attributes and elements using the given mapping. */ mapObject(objectMapping, srcObj, dstElem) { for (const field in objectMapping) field !== EXPR_PROPERTY && this.mapField(field, objectMapping[field], srcObj, dstElem) } /** * Generate XML elements and attributes for the specified field. */ mapField(fieldExpr, mapping, srcObj, dstElem) { const isAttribute = fieldExpr.startsWith('@'), fieldName = isAttribute ? fieldExpr.substring(1) : fieldExpr if (typeof mapping == 'object') { const fieldMapping = mapping, fieldValue = this.evalExpr( srcObj, fieldMapping[EXPR_PROPERTY] ?? '=', fieldName, ) if (fieldValue == null) return const forMapping = fieldMapping[FOR_PROPERTY] if (forMapping) for (const value of fieldValue) { const elem = this.doc.createElementNS(GPX_NS, fieldName) dstElem.append(elem), this.mapObject(forMapping, value, elem) } else { const elem = this.doc.createElementNS(GPX_NS, fieldName) dstElem.append(elem) const funcMapping = fieldMapping[FUNC_PROPERTY] funcMapping ? funcMapping(this.doc, fieldValue, elem) : this.mapObject(mapping, fieldValue, elem) } } else if (typeof mapping == 'string') { const value = this.evalExpr(srcObj, mapping, fieldName) if (value == null) return if (isAttribute) dstElem.setAttribute(fieldName, value) else { const valueElem = this.doc.createElementNS(GPX_NS, fieldName) dstElem.append(valueElem) const node = this.doc.createTextNode(value) valueElem.append(node) } } else throw new Error(`Unsupported field mapping: ${mapping}`) } /** * Evalutes a field expression for the specified object. If the expression * equals `=`, then the specified `fieldName` will be used. */ evalExpr(srcObj, expr, fieldName) { let property = expr if (expr === '.') return srcObj expr === '=' && (property = fieldName) const value = srcObj[property] return value != null && typeof value == 'object' && fieldName === 'time' ? value.toISOString() : value } } __name(_XmlMapper, 'XmlMapper') const XmlMapper = _XmlMapper export { ParsedGPX, calculateDistance, calculateDuration, calculateElevation, calculateSlopes, parseGPX, parseGPXWithCustomParser, stringifyGPX, }