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 @@
+
+
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(
+ ''
+ )
+ .append(
+ $(
+ ''
+ )
+ )
+ );
+ $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",