diff --git a/css/style.css b/css/style.css index 8ed470e..83e193e 100644 --- a/css/style.css +++ b/css/style.css @@ -249,6 +249,38 @@ input#trackname:focus:invalid { margin: 0; } +.track-analysis-header-distance { + text-align: right; +} + +.track-analysis-table td { + font-size: small; +} + +table.dataTable.track-analysis-table tfoot td { + font-weight: bold; + text-align: right; + padding-right: 0; +} + +table.track-analysis-table tr:hover { + background-color: rgba(255, 255, 0, 0.3); +} + +.track-analysis-title { + text-transform: capitalize; +} + +.track-analysis-distance { + text-align: right; +} + +.track-analysis-heading { + margin-top: 15px; + font-weight: bold; + font-size: 1.2em; +} + /* dashed line animation, derived from Chris Coyier and others https://css-tricks.com/svg-line-animation-works/ */ diff --git a/gulpfile.js b/gulpfile.js index 9e20e52..451da95 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -55,6 +55,7 @@ var paths = { 'js/Map.js', 'js/LayersConfig.js', 'js/router/BRouter.js', + 'js/util/*.js', 'js/plugin/*.js', 'js/control/*.js', 'js/index.js' diff --git a/index.html b/index.html index 53c3e8f..196c833 100644 --- a/index.html +++ b/index.html @@ -573,6 +573,11 @@ > +
  • + +
  • @@ -764,6 +769,14 @@
    + +
    +

    + Analysis + +

    +
    +
    diff --git a/js/control/TrackAnalysis.js b/js/control/TrackAnalysis.js new file mode 100644 index 0000000..2bce5d9 --- /dev/null +++ b/js/control/TrackAnalysis.js @@ -0,0 +1,482 @@ +/** + * 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 + } + }, + + /** + * The total distance of the whole track, recalculate on each `update()` call. + * + * @type {float} + */ + totalRouteDistance: 0.0, + + /** + * @param {Map} map + * @param {object} options + */ + initialize: function(map, options) { + this.map = map; + L.setOptions(this, options); + }, + + /** + * @type {?BR.TrackEdges} + */ + trackEdges: null, + + /** + * @type {?L.Polyline} + */ + trackPolyline: null, + + /** + * 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: function(polyline, segments) { + if (segments.length === 0) { + $('#track_statistics').html(''); + 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: function(polyline, segments) { + var analysis = { + highway: {}, + surface: {}, + smoothness: {} + }; + + this.totalRouteDistance = 0.0; + + for (var segmentIndex = 0; segments && segmentIndex < segments.length; segmentIndex++) { + for ( + var messageIndex = 1; + messageIndex < segments[segmentIndex].feature.properties.messages.length; + messageIndex++ + ) { + this.totalRouteDistance += parseFloat( + segments[segmentIndex].feature.properties.messages[messageIndex][3] + ); + var wayTags = segments[segmentIndex].feature.properties.messages[messageIndex][9].split(' '); + for (var wayTagIndex = 0; wayTagIndex < wayTags.length; wayTagIndex++) { + var wayTagParts = wayTags[wayTagIndex].split('='); + switch (wayTagParts[0]) { + case 'highway': + var highwayType = wayTagParts[1]; + var 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[wayTagParts[0]][wayTagParts[1]] === 'undefined') { + analysis[wayTagParts[0]][wayTagParts[1]] = { + formatted_name: i18next.t( + 'sidebar.analysis.data.' + wayTagParts[0] + '.' + wayTagParts[1], + wayTagParts[1] + ), + name: wayTagParts[1], + subtype: '', + distance: 0.0 + }; + } + analysis[wayTagParts[0]][wayTagParts[1]].distance += parseFloat( + segments[segmentIndex].feature.properties.messages[messageIndex][3] + ); + break; + } + } + } + } + + return this.sortAnalysisData(analysis); + }, + + /** + * 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: function(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 a.distance < b.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: function(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: function(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: function(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: function(meters) { + return (meters / 1000).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + }, + + handleHover: function(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: function() { + this.map.removeLayer(this.highlightedSegments); + }, + + toggleSelected: function(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: function(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: function(wayTags, dataType, dataName, trackType) { + var parsed = this.parseWayTags(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; + } + + return parsed.highway === dataName; + case 'surface': + if (dataName === 'internal-unknown' && typeof parsed.surface !== 'string') { + return true; + } + + return typeof parsed.surface === 'string' && parsed.surface === dataName; + case 'smoothness': + if (dataName === 'internal-unknown' && typeof parsed.smoothness !== 'string') { + return true; + } + + return typeof parsed.smoothness === 'string' && parsed.smoothness === dataName; + } + + return false; + }, + + /** + * 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} + */ + parseWayTags: function(wayTags) { + var result = {}; + var wayTagPairs = wayTags.feature.wayTags.split(' '); + + for (var j = 0; j < wayTagPairs.length; j++) { + var wayTagParts = wayTagPairs[j].split('='); + result[wayTagParts[0]] = wayTagParts[1]; + } + + return result; + } +}); diff --git a/js/control/TrackMessages.js b/js/control/TrackMessages.js index 0834675..d082d1a 100644 --- a/js/control/TrackMessages.js +++ b/js/control/TrackMessages.js @@ -26,6 +26,16 @@ BR.TrackMessages = L.Class.extend({ InitialCost: { title: 'initial$', className: 'dt-body-right' } }, + /** + * @type {?BR.TrackEdges} + */ + trackEdges: null, + + /** + * @type {?L.Polyline} + */ + trackPolyline: null, + initialize: function(map, options) { L.setOptions(this, options); this._map = map; @@ -49,6 +59,9 @@ BR.TrackMessages = L.Class.extend({ return; } + this.trackPolyline = polyline; + this.trackEdges = new BR.TrackEdges(segments); + for (i = 0; segments && i < segments.length; i++) { messages = segments[i].feature.properties.messages; if (messages) { @@ -80,7 +93,7 @@ BR.TrackMessages = L.Class.extend({ }); // highlight track segment (graph edge) on row hover - this._setEdges(polyline, segments); + $('#datatable tbody tr').hover(L.bind(this._handleHover, this), L.bind(this._handleHoverOut, this)); $('#datatable tbody').on('click', 'tr', L.bind(this._toggleSelected, this)); }, @@ -147,59 +160,11 @@ BR.TrackMessages = L.Class.extend({ return empty; }, - _getMessageLatLng: function(message) { - var lon = message[0] / 1000000, - lat = message[1] / 1000000; - - return L.latLng(lat, lon); - }, - - _setEdges: function(polyline, segments) { - var messages, - segLatLngs, - length, - si, - mi, - latLng, - i, - segIndex, - baseIndex = 0; - - // track latLngs index for end node of edge - this._edges = []; - this._track = polyline; - - for (si = 0; si < segments.length; si++) { - messages = segments[si].feature.properties.messages; - segLatLngs = segments[si].getLatLngs(); - length = segLatLngs.length; - segIndex = 0; - - for (mi = 1; mi < messages.length; mi++) { - latLng = this._getMessageLatLng(messages[mi]); - - for (i = segIndex; i < length; i++) { - if (latLng.equals(segLatLngs[i])) { - break; - } - } - if (i === length) { - i = length - 1; - if (mi !== messages.length - 1) debugger; - } - - segIndex = i + 1; - this._edges.push(baseIndex + i); - } - baseIndex += length; - } - }, - _getRowEdge: function(tr) { var row = this._table.row($(tr)), - trackLatLngs = this._track.getLatLngs(), - startIndex = row.index() > 0 ? this._edges[row.index() - 1] : 0, - endIndex = this._edges[row.index()], + trackLatLngs = this.trackPolyline.getLatLngs(), + startIndex = row.index() > 0 ? this.trackEdges.edges[row.index() - 1] : 0, + endIndex = this.trackEdges.edges[row.index()], edgeLatLngs = trackLatLngs.slice(startIndex, endIndex + 1); return L.polyline(edgeLatLngs, this.options.edgeStyle); diff --git a/js/index.js b/js/index.js index ed4f200..59fcc22 100644 --- a/js/index.js +++ b/js/index.js @@ -29,6 +29,7 @@ exportRoute, profile, trackMessages, + trackAnalysis, sidebar, drawButton, deleteRouteButton, @@ -198,6 +199,9 @@ trackMessages = new BR.TrackMessages(map, { requestUpdate: requestUpdate }); + trackAnalysis = new BR.TrackAnalysis(map, { + requestUpdate: requestUpdate + }); routingPathQuality = new BR.RoutingPathQuality(map, layersControl); @@ -248,6 +252,7 @@ stats.update(track, segments); } trackMessages.update(track, segments); + trackAnalysis.update(track, segments); exportRoute.update(latLngs); } @@ -260,7 +265,8 @@ defaultTabId: BR.conf.transit ? 'tab_itinerary' : 'tab_profile', listeningTabs: { tab_profile: profile, - tab_data: trackMessages + tab_data: trackMessages, + tab_analysis: trackAnalysis } }).addTo(map); if (BR.conf.transit) { diff --git a/js/util/TrackEdges.js b/js/util/TrackEdges.js new file mode 100644 index 0000000..7815064 --- /dev/null +++ b/js/util/TrackEdges.js @@ -0,0 +1,79 @@ +/** + * The track messages and track analysis panels share some functionality + * which is defined in this class to prevent code duplication. + * + * @type {L.Class} + */ +BR.TrackEdges = L.Class.extend({ + /** + * List of indexes for the track array where + * a segment with different features ends + * + * @type {number[]} + * @see BR.TrackMessages + */ + edges: [], + + /** + * @param {Array} segments + */ + initialize: function(segments) { + this.edges = this.getTrackEdges(segments); + }, + + /** + * Find the indexes where a track segment ends, i.e. where the waytags change. + * + * Used in TrackMessages and TrackAnalysis for highlighting track segments. + * + * @param {Array} segments + * + * @return {number[]} + */ + getTrackEdges: function(segments) { + var messages, + segLatLngs, + length, + si, + mi, + latLng, + i, + segIndex, + baseIndex = 0, + edges = []; + + // track latLngs index for end node of edge + for (si = 0; si < segments.length; si++) { + messages = segments[si].feature.properties.messages; + segLatLngs = segments[si].getLatLngs(); + length = segLatLngs.length; + segIndex = 0; + + for (mi = 1; mi < messages.length; mi++) { + latLng = this.getMessageLatLng(messages[mi]); + + for (i = segIndex; i < length; i++) { + if (latLng.equals(segLatLngs[i])) { + break; + } + } + if (i === length) { + i = length - 1; + } + + segIndex = i + 1; + edges.push(baseIndex + i); + } + baseIndex += length; + } + + return edges; + }, + + getMessageLatLng: function(message) { + var lon = message[0] / 1000000, + lat = message[1] / 1000000; + + return L.latLng(lat, lon); + } +}); diff --git a/locales/de.json b/locales/de.json index 8f4059d..a8aaabd 100644 --- a/locales/de.json +++ b/locales/de.json @@ -180,6 +180,31 @@ "itinerary": { "title": "Reiseroute" }, + "analysis": { + "title": "Analyse", + "header": { + "highway": "Weg", + "surface": "Material", + "smoothness": "Beschaffenheit" + }, + "table": { + "category": "Art", + "length": "Länge", + "total_known": "Gesamt bekannt:", + "unknown": "Unbekannt" + }, + "data": { + "highway": { + "living_street": "living street" + }, + "surface": { + "paving_stones": "paving stones" + }, + "smoothness": { + "very_bad": "very bad" + } + } + }, "layers": { "category": { "base-layers": "Grundkarten", diff --git a/locales/en.json b/locales/en.json index d5c103b..85068c0 100644 --- a/locales/en.json +++ b/locales/en.json @@ -180,6 +180,32 @@ "itinerary": { "title": "Itinerary" }, + "analysis": { + "title": "Analysis", + "header": { + "highway": "Highway", + "surface": "Surface", + "smoothness": "Smoothness" + }, + "table": { + "category": "Category", + "length": "Length", + "total_known": "Total Known:", + "unknown": "Unknown" + }, + "data": { + "highway": { + "living_street": "Living Street" + }, + "surface": { + "fine_gravel": "Fine Gravel", + "paving_stones": "Paving Stones" + }, + "smoothness": { + "very_bad": "Very Bad" + } + } + }, "layers": { "category": { "base-layers": "Base layers",