/** * Provides track analysis functionality. * * Takes the detailed way tags from brouter-server's response * and creates tables with distributions of way types, surfaces, * and smoothness values. * * On hovering/click a table row the corresponding track segments * are highlighted on the map. * * @type {L.Class} */ BR.TrackAnalysis = L.Class.extend({ /** * @type {Object} */ options: { overlayStyle: { color: 'yellow', opacity: 0.8, weight: 8, // show above quality coding (pane defined in RoutingPathQuality.js) pane: 'routingQualityPane', }, }, /** * The total distance of the whole track, recalculate on each `update()` call. * * @type {float} */ totalRouteDistance: 0.0, /** * @param {Map} map * @param {object} options */ initialize(map, options) { this.map = map; L.setOptions(this, options); }, /** * @type {?BR.TrackEdges} */ trackEdges: null, /** * @type {?L.Polyline} */ trackPolyline: null, /** * true when tab is shown, false when hidden * * @type {boolean} */ active: false, /** * Called by BR.Sidebar when tab is activated */ show() { this.active = true; this.options.requestUpdate(this); }, /** * Called by BR.Sidebar when tab is deactivated */ hide() { this.active = false; }, /** * Everytime the track changes this method is called: * * - calculate statistics (way type, surface, smoothness) * for the whole track * - renders statistics tables * - create event listeners which allow to hover/click a * table row for highlighting matching track segments * * @param {Polyline} polyline * @param {Array} segments */ update(polyline, segments) { if (!this.active) { return; } if (segments.length === 0) { $('#track_statistics').html(''); if (this.highlightedSegments) { this.map.removeLayer(this.highlightedSegments); this.highlightedSegments = null; } if (this.highlightedSegment) { this.map.removeLayer(this.highlightedSegment); this.highlightedSegment = null; } return; } this.trackPolyline = polyline; this.trackEdges = new BR.TrackEdges(segments); var analysis = this.calcStats(polyline, segments); this.render(analysis); $('.track-analysis-table tr').hover(L.bind(this.handleHover, this), L.bind(this.handleHoverOut, this)); $('.track-analysis-table tbody').on('click', 'tr', L.bind(this.toggleSelected, this)); }, /** * This method does the heavy-lifting of statistics calculation. * * What happens here? * * - loop over all route segments * - for each segment loop over all contained points * - parse and analyze the `waytags` field between two consecutive points * - group the values for each examined category (highway, surface, smoothness) and sum up the distances * - special handling for tracks: create an entry for each tracktype (and one if the tracktype is unknown) * - sort the result by distance descending * * @param polyline * @param segments * @returns {Object} */ calcStats(polyline, segments) { const analysis = { highway: {}, surface: {}, smoothness: {}, }; this.totalRouteDistance = 0.0; for (let segmentIndex = 0; segments && segmentIndex < segments.length; segmentIndex++) { for ( let messageIndex = 1; messageIndex < segments[segmentIndex].feature.properties.messages.length; messageIndex++ ) { this.totalRouteDistance += parseFloat( segments[segmentIndex].feature.properties.messages[messageIndex][3] ); let wayTags = segments[segmentIndex].feature.properties.messages[messageIndex][9].split(' '); wayTags = this.normalizeWayTags(wayTags, 'cycling'); for (let wayTagIndex = 0; wayTagIndex < wayTags.length; wayTagIndex++) { let wayTagParts = wayTags[wayTagIndex].split('='); let tagName = wayTagParts[0]; switch (tagName) { case 'highway': let highwayType = wayTagParts[1]; let trackType = ''; if (highwayType === 'track') { trackType = this.getTrackType(wayTags); highwayType = 'Track ' + trackType; } if (typeof analysis.highway[highwayType] === 'undefined') { analysis.highway[highwayType] = { formatted_name: i18next.t( 'sidebar.analysis.data.highway.' + highwayType, highwayType ), name: wayTagParts[1], subtype: trackType, distance: 0.0, }; } analysis.highway[highwayType].distance += parseFloat( segments[segmentIndex].feature.properties.messages[messageIndex][3] ); break; case 'surface': case 'smoothness': if (typeof analysis[tagName][wayTagParts[1]] === 'undefined') { analysis[tagName][wayTagParts[1]] = { formatted_name: i18next.t( 'sidebar.analysis.data.' + tagName + '.' + wayTagParts[1], wayTagParts[1] ), name: wayTagParts[1], subtype: '', distance: 0.0, }; } analysis[tagName][wayTagParts[1]].distance += parseFloat( segments[segmentIndex].feature.properties.messages[messageIndex][3] ); break; } } } } return this.sortAnalysisData(analysis); }, /** * Normalize the tag name. * * Motivation: The `surface` and `smoothness` tags come in different variations, * e.g. `surface`, `cycleway:surface` etc. We're only interested * in the tag which matches the given routing type. All other variations * are dropped. If no specialized surface/smoothness tag is found, the default value * is returned, i.e. `smoothness` or `surface`. * * @param wayTags tags + values for a way segment * @param routingType currently only 'cycling' is supported, can be extended in the future (walking, driving, etc.) * @returns {*[]} */ normalizeWayTags(wayTags, routingType) { let normalizedWayTags = {}; let surfaceTags = {}; let smoothnessTags = {}; for (let wayTagIndex = 0; wayTagIndex < wayTags.length; wayTagIndex++) { let wayTagParts = wayTags[wayTagIndex].split('='); const tagName = wayTagParts[0]; const tagValue = wayTagParts[1]; if (tagName === 'surface') { surfaceTags.default = tagValue; continue; } if (tagName.indexOf(':surface') !== -1) { let tagNameParts = tagName.split(':'); surfaceTags[tagNameParts[0]] = tagValue; continue; } if (tagName === 'smoothness') { smoothnessTags.default = tagValue; continue; } if (tagName.indexOf(':smoothness') !== -1) { let tagNameParts = tagName.split(':'); smoothnessTags[tagNameParts[0]] = tagValue; continue; } normalizedWayTags[tagName] = tagValue; } switch (routingType) { case 'cycling': if (typeof surfaceTags.cycleway === 'string') { normalizedWayTags.surface = surfaceTags.cycleway; } else if (typeof surfaceTags.default === 'string') { normalizedWayTags.surface = surfaceTags.default; } if (typeof smoothnessTags.cycleway === 'string') { normalizedWayTags.smoothness = smoothnessTags.cycleway; } else if (typeof smoothnessTags.default === 'string') { normalizedWayTags.smoothness = smoothnessTags.default; } break; default: if (typeof surfaceTags.default === 'string') { normalizedWayTags.surface = surfaceTags.default; } if (typeof smoothnessTags.default === 'string') { normalizedWayTags.smoothness = smoothnessTags.default; } } return this.wayTagsToArray(normalizedWayTags); }, /** * Transform analysis data for each type into an array, sort it * by distance descending and convert it back to an object. * * @param {Object} analysis * * @returns {Object} */ sortAnalysisData(analysis) { var analysisSortable = {}; var result = {}; for (var type in analysis) { if (!analysis.hasOwnProperty(type)) { continue; } result[type] = {}; analysisSortable[type] = []; for (var name in analysis[type]) { if (!analysis[type].hasOwnProperty(name)) { continue; } analysisSortable[type].push(analysis[type][name]); } analysisSortable[type].sort(function (a, b) { return b.distance - a.distance; }); for (var j = 0; j < analysisSortable[type].length; j++) { result[type][analysisSortable[type][j].formatted_name] = analysisSortable[type][j]; } } return result; }, /** * Extract the tracktype from a waytags string. * If no tracktype is found 'unknown' is returned. * * @param {string[]} wayTags * @returns {string} */ getTrackType(wayTags) { for (var i = 0; i < wayTags.length; i++) { var wayTagParts = wayTags[i].split('='); if (wayTagParts[0] === 'tracktype') { return wayTagParts[1]; } } return 'unknown'; }, /** * @param {Object} analysis */ render(analysis) { var $content = $('#track_statistics'); $content.html(''); $content.append( $('

' + i18next.t('sidebar.analysis.header.highway') + '

') ); $content.append(this.renderTable('highway', analysis.highway)); $content.append( $('

' + i18next.t('sidebar.analysis.header.surface') + '

') ); $content.append(this.renderTable('surface', analysis.surface)); $content.append( $('

' + i18next.t('sidebar.analysis.header.smoothness') + '

') ); $content.append(this.renderTable('smoothness', analysis.smoothness)); }, /** * Renders an analysis table. * * @param {string} type * @param {Array} data * @returns {jQuery} */ renderTable(type, data) { var index; var $table = $( '
' ); var $thead = $(''); $thead.append( $('') .append( '' + i18next.t('sidebar.analysis.table.category') + '' ) .append( $( '' + i18next.t('sidebar.analysis.table.length') + '' ) ) ); $table.append($thead); var $tbody = $(''); var totalDistance = 0.0; for (index in data) { if (!data.hasOwnProperty(index)) { continue; } var $row = $( '' ); $row.append('' + data[index].formatted_name + ''); $row.append( '' + this.formatDistance(data[index].distance) + ' km' ); $tbody.append($row); totalDistance += data[index].distance; } if (totalDistance < this.totalRouteDistance) { $tbody.append( $( '' ) .append( $('' + i18next.t('sidebar.analysis.table.unknown') + '') ) .append( $( '' + this.formatDistance(this.totalRouteDistance - totalDistance) + ' km' ) ) ); } $table.append($tbody); $table.append( $('') .append('') .append($('' + i18next.t('sidebar.analysis.table.total_known') + '')) .append( $( '' + this.formatDistance(totalDistance) + ' km' ) ) ); return $table; }, /** * Format a distance with two decimal places. * * @param {number} meters * @returns {string} */ formatDistance(meters) { return (meters / 1000).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }, handleHover(event) { var $tableRow = $(event.currentTarget); var $table = $tableRow.parents('table').first(); var dataType = $table.data('type'); var dataName = $tableRow.data('name'); var trackType = $tableRow.data('subtype'); var polylinesForDataType = this.getPolylinesForDataType(dataType, dataName, trackType); this.highlightedSegments = L.layerGroup(polylinesForDataType).addTo(this.map); }, handleHoverOut() { this.map.removeLayer(this.highlightedSegments); }, toggleSelected(event) { var tableRow = event.currentTarget; var $table = $(tableRow).parents('table').first(); var dataType = $table.data('type'); var dataName = $(tableRow).data('name'); var trackType = $(tableRow).data('subtype'); if (tableRow.classList.toggle('selected')) { if (this.highlightedSegment) { this.map.removeLayer(this.highlightedSegment); this.selectedTableRow.classList.remove('selected'); } this.highlightedSegment = L.layerGroup(this.getPolylinesForDataType(dataType, dataName, trackType)).addTo( this.map ); this.selectedTableRow = tableRow; return; } this.map.removeLayer(this.highlightedSegment); this.selectedTableRow = null; this.highlightedSegment = null; }, /** * Searching each track edge if it matches the requested * arguments (type, name, subtype if type == track). If the * track edge matches the search, create a Leaflet polyline * and add it to the result array. * * @param {string} dataType `highway`, `surface`, `smoothness` * @param {string} dataName `primary`, `track, `asphalt`, etc. * @param {string} trackType the tracktype is passed here (e.g. * `grade3`), but only in the case that `dataName` is `track` * * @returns {Polyline[]} */ getPolylinesForDataType(dataType, dataName, trackType) { var polylines = []; var trackLatLngs = this.trackPolyline.getLatLngs(); for (var i = 0; i < this.trackEdges.edges.length; i++) { if (this.wayTagsMatchesData(trackLatLngs[this.trackEdges.edges[i]], dataType, dataName, trackType)) { var matchedEdgeIndexStart = i > 0 ? this.trackEdges.edges[i - 1] : 0; var matchedEdgeIndexEnd = this.trackEdges.edges[i] + 1; polylines.push( L.polyline( trackLatLngs.slice(matchedEdgeIndexStart, matchedEdgeIndexEnd), this.options.overlayStyle ) ); } } return polylines; }, /** * Examine the way tags string if it matches the data arguments. * Special handling for implicit defined dataName 'internal-unknown' * which matches if a tag-pair is missing. Special handling for * tracktypes again. * * @param {string} wayTags The way tags as provided by brouter, e.g. * `highway=secondary surface=asphalt smoothness=good` * @param {string} dataType `highway`, `surface`, `smoothness` * @param {string} dataName `primary`, `track, `asphalt`, etc. * @param {string} trackType the tracktype is passed here (e.g. * `grade3`), but only in the case that `dataName` is `track` * * @returns {boolean} */ wayTagsMatchesData(wayTags, dataType, dataName, trackType) { const parsed = this.wayTagsToObject(wayTags); switch (dataType) { case 'highway': if (dataName === 'track') { if (trackType === 'unknown' && parsed.highway === 'track' && !parsed.tracktype) { return true; } return typeof parsed.tracktype === 'string' && parsed.tracktype === trackType; } else if (dataName === 'internal-unknown' && typeof parsed.highway !== 'string') { return true; } return typeof parsed.highway === 'string' && parsed.highway === dataName; case 'surface': return this.singleWayTagMatchesData('surface', parsed, dataName); case 'smoothness': return this.singleWayTagMatchesData('smoothness', parsed, dataName); } return false; }, singleWayTagMatchesData(category, parsedData, lookupValue) { var foundValue = null; for (var iterationKey in parsedData) { if (iterationKey.indexOf(category) !== -1) { foundValue = parsedData[iterationKey]; break; } } if (lookupValue === 'internal-unknown' && foundValue === null) { return true; } return foundValue === lookupValue; }, /** * Transform a way tags string into an object, for example: * * 'highway=primary surface=asphalt' => { highway: 'primary', surface: 'asphalt' } * * @param wayTags The way tags as provided by brouter, e.g. * `highway=secondary surface=asphalt smoothness=good` * * @returns {object} */ wayTagsToObject(wayTags) { let result = {}; const wayTagPairs = wayTags.feature.wayTags.split(' '); for (let j = 0; j < wayTagPairs.length; j++) { const wayTagParts = wayTagPairs[j].split('='); result[wayTagParts[0]] = wayTagParts[1]; } return result; }, /** * Transform a way tags object into an array representation, for example: * * { 'highway' : 'path', 'surface' : 'sand' } => ['highway=path', 'surface=sand'] * * @param wayTags The way tags in object representation * * @returns {object} */ wayTagsToArray(wayTags) { let wayTagsArray = []; for (let wayTagKey in wayTags) { wayTagsArray.push(wayTagKey + '=' + wayTags[wayTagKey]); } return wayTagsArray; }, });