leaflet-measure
Version:
Coordinate, linear, and area measure tool for Leaflet maps
360 lines (321 loc) • 13.3 kB
JavaScript
// leaflet-measure.js
var _ = require('underscore');
var L = require('leaflet');
var humanize = require('humanize');
var calc = require('./calc');
var dom = require('./dom');
var $ = dom.$;
var Symbology = require('./mapsymbology');
var fs = require('fs');
var controlTemplate = _.template(fs.readFileSync(__dirname + '/leaflet-measure-template.html', 'utf8'));
var resultsTemplate = _.template(fs.readFileSync(__dirname + '/leaflet-measure-template-results.html', 'utf8'));
var pointPopupTemplate = _.template(fs.readFileSync(__dirname + '/popuptemplates/point-popuptemplate.html', 'utf8'));
var linePopupTemplate = _.template(fs.readFileSync(__dirname + '/popuptemplates/line-popuptemplate.html', 'utf8'));
var areaPopupTemplate = _.template(fs.readFileSync(__dirname + '/popuptemplates/area-popuptemplate.html', 'utf8'));
L.Control.Measure = L.Control.extend({
_className: 'leaflet-control-measure',
options: {
position: 'topright',
activeColor: '#ABE67E', // base color for map features while actively measuring
completedColor: '#C8F2BE', // base color for permenant features generated from completed measure
popupOptions: { // standard leaflet popup options http://leafletjs.com/reference.html#popup-options
className: 'leaflet-measure-resultpopup',
autoPanPadding: [10, 10]
}
},
initialize: function (options) {
L.setOptions(this, options);
this._symbols = new Symbology(_.pick(this.options, 'activeColor', 'completedColor'));
},
onAdd: function (map) {
this._map = map;
this._latlngs = [];
this._initLayout();
map.on('click', this._collapse, this);
this._layer = L.layerGroup().addTo(map);
return this._container;
},
onRemove: function (map) {
map.off('click', this._collapse, this);
map.removeLayer(this._layer);
},
_initLayout: function () {
var className = this._className, container = this._container = L.DomUtil.create('div', className);
var $toggle, $start, $cancel, $finish;
container.innerHTML = controlTemplate({
model: {
className: className
}
});
// copied from leaflet
// https://bitbucket.org/ljagis/js-mapbootstrap/src/4ab1e9e896c08bdbc8164d4053b2f945143f4f3a/app/components/measure/leaflet-measure-control.js?at=master#cl-30
container.setAttribute('aria-haspopup', true);
if (!L.Browser.touch) {
L.DomEvent.disableClickPropagation(container);
L.DomEvent.disableScrollPropagation(container);
} else {
L.DomEvent.on(container, 'click', L.DomEvent.stopPropagation);
}
$toggle = this.$toggle = $('.js-toggle', container); // collapsed content
this.$interaction = $('.js-interaction', container); // expanded content
$start = $('.js-start', container); // start button
$cancel = $('.js-cancel', container); // cancel button
$finish = $('.js-finish', container); // finish button
this.$startPrompt = $('.js-startprompt', container); // full area with button to start measurment
this.$measuringPrompt = $('.js-measuringprompt', container); // full area with all stuff for active measurement
this.$startHelp = $('.js-starthelp', container); // "Start creating a measurement by adding points"
this.$results = $('.js-results', container); // div with coordinate, linear, area results
this.$measureTasks = $('.js-measuretasks', container); // active measure buttons container
this._collapse();
this._updateMeasureNotStarted();
if (!L.Browser.android) {
L.DomEvent.on(container, 'mouseenter', this._expand, this);
L.DomEvent.on(container, 'mouseleave', this._collapse, this);
}
L.DomEvent.on($toggle, 'click', L.DomEvent.stop);
if (L.Browser.touch) {
L.DomEvent.on($toggle, 'click', this._expand, this);
} else {
L.DomEvent.on($toggle, 'focus', this._expand, this);
}
L.DomEvent.on($start, 'click', L.DomEvent.stop);
L.DomEvent.on($start, 'click', this._startMeasure, this);
L.DomEvent.on($cancel, 'click', L.DomEvent.stop);
L.DomEvent.on($cancel, 'click', this._finishMeasure, this);
L.DomEvent.on($finish, 'click', L.DomEvent.stop);
L.DomEvent.on($finish, 'click', this._handleMeasureDoubleClick, this);
},
_expand: function () {
dom.hide(this.$toggle);
dom.show(this.$interaction);
},
_collapse: function () {
if (!this._locked) {
dom.hide(this.$interaction);
dom.show(this.$toggle);
}
},
// move between basic states:
// measure not started, started/in progress but no points added, in progress and with points
_updateMeasureNotStarted: function () {
dom.hide(this.$startHelp);
dom.hide(this.$results);
dom.hide(this.$measureTasks);
dom.hide(this.$measuringPrompt);
dom.show(this.$startPrompt);
},
_updateMeasureStartedNoPoints: function () {
dom.hide(this.$results);
dom.show(this.$startHelp);
dom.show(this.$measureTasks);
dom.hide(this.$startPrompt);
dom.show(this.$measuringPrompt);
},
_updateMeasureStartedWithPoints: function () {
dom.hide(this.$startHelp);
dom.show(this.$results);
dom.show(this.$measureTasks);
dom.hide(this.$startPrompt);
dom.show(this.$measuringPrompt);
},
// get state vars and interface ready for measure
_startMeasure: function () {
this._locked = true;
this._map.doubleClickZoom.disable(); // double click now finishes measure
this._map.on('mouseout', this._handleMapMouseOut, this);
L.DomEvent.on(this._container, 'mouseenter', this._handleMapMouseOut, this);
if (!this._measureCollector) {
// polygon to cover all other layers and collection measure move and click events
this._measureCollector = L.polygon([[90, -180], [90, 180], [-90, 180], [-90, -180]], this._symbols.getSymbol('measureCollector')).addTo(this._layer);
this._measureCollector.on('mousemove', this._handleMeasureMove, this);
this._measureCollector.on('dblclick', this._handleMeasureDoubleClick, this);
this._measureCollector.on('click', this._handleMeasureClick, this);
}
this._measureCollector.bringToFront();
this._measureVertexes = L.featureGroup().addTo(this._layer);
this._updateMeasureStartedNoPoints();
},
// return to state with no measure in progress, undo `this._startMeasure`
_finishMeasure: function () {
this._locked = false;
this._map.doubleClickZoom.enable();
this._map.off('mouseout', this._handleMapMouseOut, this);
L.DomEvent.off(this._container, 'mouseover', this._handleMapMouseOut, this);
this._clearMeasure();
this._measureCollector.off();
this._layer.removeLayer(this._measureCollector);
this._measureCollector = null;
this._layer.removeLayer(this._measureVertexes);
this._measureVertexes = null;
this._updateMeasureNotStarted();
this._collapse();
},
// clear all running measure data
_clearMeasure: function () {
this._latlngs = [];
this._measureVertexes.clearLayers();
if (this._measureDrag) {
this._layer.removeLayer(this._measureDrag);
}
if (this._measureArea) {
this._layer.removeLayer(this._measureArea);
}
if (this._measureBoundary) {
this._layer.removeLayer(this._measureBoundary);
}
this._measureDrag = null;
this._measureArea = null;
this._measureBoundary = null;
},
// update results area of dom with calced measure from `this._latlngs`
_updateResults: function () {
var calced = calc.measure(this._latlngs);
this.$results.innerHTML = resultsTemplate({
model: _.extend({}, calced, {
pointCount: this._latlngs.length
}),
humanize: humanize
});
},
// mouse move handler while measure in progress
// adds floating measure marker under cursor
_handleMeasureMove: function (evt) {
if (!this._measureDrag) {
this._measureDrag = L.circleMarker(evt.latlng, this._symbols.getSymbol('measureDrag')).addTo(this._layer);
} else {
this._measureDrag.setLatLng(evt.latlng);
}
this._measureDrag.bringToFront();
},
// handler for both double click and clicking finish button
// do final calc and finish out current measure, clear dom and internal state, add permanent map features
_handleMeasureDoubleClick: function () {
var latlngs = this._latlngs, calced, resultFeature, popupContainer, popupContent, zoomLink, deleteLink;
this._finishMeasure();
if (!latlngs.length) {
return;
}
if (latlngs.length > 2) {
latlngs.push(_.first(latlngs)); // close path to get full perimeter measurement for areas
}
calced = calc.measure(latlngs);
if (latlngs.length === 1) {
resultFeature = L.circleMarker(latlngs[0], this._symbols.getSymbol('resultPoint'));
popupContent = pointPopupTemplate({
model: calced,
humanize: humanize
});
} else if (latlngs.length === 2) {
resultFeature = L.polyline(latlngs, this._symbols.getSymbol('resultLine')).addTo(this._map);
popupContent = linePopupTemplate({
model: calced,
humanize: humanize
});
} else {
resultFeature = L.polygon(latlngs, this._symbols.getSymbol('resultArea'));
popupContent = areaPopupTemplate({
model: calced,
humanize: humanize
});
}
popupContainer = L.DomUtil.create('div', '');
popupContainer.innerHTML = popupContent;
zoomLink = $('.js-zoomto', popupContainer);
if (zoomLink) {
L.DomEvent.on(zoomLink, 'click', L.DomEvent.stop);
L.DomEvent.on(zoomLink, 'click', function () {
this._map.fitBounds(resultFeature.getBounds(), {
padding: [20, 20],
maxZoom: 17
});
}, this);
}
deleteLink = $('.js-deletemarkup', popupContainer);
if (deleteLink) {
L.DomEvent.on(deleteLink, 'click', L.DomEvent.stop);
L.DomEvent.on(deleteLink, 'click', function () {
// TODO. maybe remove any event handlers on zoom and delete buttons?
this._map.removeLayer(resultFeature);
}, this);
}
resultFeature.addTo(this._map);
resultFeature.bindPopup(popupContainer, this.options.popupOptions);
resultFeature.openPopup(resultFeature.getBounds().getCenter());
},
// handle map click during ongoing measurement
// add new clicked point, update measure layers and results ui
_handleMeasureClick: function (evt) {
var latlng = evt.latlng, lastClick = _.last(this._latlngs), vertexSymbol = this._symbols.getSymbol('measureVertex');
this._map.closePopup(); // open popups aren't closed on click. may be bug. close popup manually just in case.
if (!lastClick || !latlng.equals(lastClick)) { // skip if same point as last click, happens on `dblclick`
this._latlngs.push(latlng);
this._addMeasureArea(this._latlngs);
this._addMeasureBoundary(this._latlngs);
this._measureVertexes.eachLayer(function (layer) {
layer.setStyle(vertexSymbol);
// reset all vertexes to non-active class - only last vertex is active
// `layer.setStyle({ className: 'layer-measurevertex'})` doesn't work. https://github.com/leaflet/leaflet/issues/2662
// set attribute on path directly
layer._path.setAttribute('class', vertexSymbol.className);
});
this._addNewVertex(latlng);
if (this._measureBoundary) {
this._measureBoundary.bringToFront();
}
this._measureVertexes.bringToFront();
}
this._updateResults();
this._updateMeasureStartedWithPoints();
},
// handle map mouse out during ongoing measure
// remove floating cursor vertex from map
_handleMapMouseOut: function () {
if (this._measureDrag) {
this._layer.removeLayer(this._measureDrag);
this._measureDrag = null;
}
},
// add various measure graphics to map - vertex, area, boundary
_addNewVertex: function (latlng) {
L.circleMarker(latlng, this._symbols.getSymbol('measureVertexActive')).addTo(this._measureVertexes);
},
_addMeasureArea: function (latlngs) {
if (latlngs.length < 3) {
if (this._measureArea) {
this._layer.removeLayer(this._measureArea);
this._measureArea = null;
}
return;
}
if (!this._measureArea) {
this._measureArea = L.polygon(latlngs, this._symbols.getSymbol('measureArea')).addTo(this._layer);
} else {
this._measureArea.setLatLngs(latlngs);
}
},
_addMeasureBoundary: function (latlngs) {
if (latlngs.length < 2) {
if (this._measureBoundary) {
this._layer.removeLayer(this._measureBoundary);
this._measureBoundary = null;
}
return;
}
if (!this._measureBoundary) {
this._measureBoundary = L.polyline(latlngs, this._symbols.getSymbol('measureBoundary')).addTo(this._layer);
} else {
this._measureBoundary.setLatLngs(latlngs);
}
}
});
L.Map.mergeOptions({
measureControl: false
});
L.Map.addInitHook(function () {
if (this.options.measureControl) {
this.measureControl = (new L.Control.Measure()).addTo(this);
}
});
L.control.measure = function (options) {
return new L.Control.Measure(options);
};