BR.NogoAreas = L.Control.extend({ statics: { MSG_BUTTON: 'Draw no-go area (circle)', MSG_BUTTON_CANCEL: 'Cancel drawing no-go area', MSG_CREATE: 'Click and drag to draw circle', MSG_DISABLED: 'Click to edit', MSG_ENABLED: '□ = move / resize, = delete,
click nogo to quit editing', STATE_CREATE: 'no-go-create', STATE_CANCEL: 'cancel-no-go-create' }, style: { color: '#f06eaa', weight: 4, opacity: 0.5, fillColor: null, //same as color by default fillOpacity: 0.2, dashArray: null }, editStyle: { color: '#fe57a1', opacity: 0.6, dashArray: '10, 10', fillOpacity: 0.1 }, initialize: function() { this._wasRouteDrawing = false; }, onAdd: function(map) { var self = this; $('#submitNogos').on('click', L.bind(this.uploadNogos, this)); this.drawnItems = new L.FeatureGroup().addTo(map); this.drawnItems.on('click', function(e) { L.DomEvent.stop(e); e.layer.toggleEdit(); }); var editTools = (this.editTools = map.editTools = new BR.Editable(map, { circleEditorClass: BR.DeletableCircleEditor, // FeatureGroup instead of LayerGroup to propagate events to members editLayer: new L.FeatureGroup().addTo(map), featuresLayer: this.drawnItems })); this.button = L.easyButton({ states: [ { stateName: BR.NogoAreas.STATE_CREATE, icon: 'fa-ban', title: BR.NogoAreas.MSG_BUTTON, onClick: function(control) { // initial radius of 0 to detect click, see DeletableCircleEditor.onDrawingMouseUp var opts = L.extend({ radius: 0 }, self.style); editTools.startCircle(null, opts); control.state('cancel-no-go-create'); } }, { stateName: BR.NogoAreas.STATE_CANCEL, icon: 'fa-ban active', title: BR.NogoAreas.MSG_BUTTON_CANCEL, onClick: function(control) { editTools.stopDrawing(); control.state('no-go-create'); } } ] }); // prevent instant re-activate when turning off button by both Pointer and Click // events firing in Chrome mobile while L.Map.Tap enabled for circle drawing L.DomEvent.addListener(this.button.button, 'pointerdown', L.DomEvent.stop); this.editTools.on( 'editable:drawing:end', function(e) { self.button.state(BR.NogoAreas.STATE_CREATE); setTimeout( L.bind(function() { // turn editing off after create; async to still fire 'editable:vertex:dragend' e.layer.disableEdit(); }, this), 0 ); }, this ); this.editTools.on( 'editable:vertex:dragend editable:deleted', function(e) { this._fireUpdate(); }, this ); this.editTools.on( 'editable:enable', function(e) { e.layer.setStyle(this.editStyle); }, this ); this.editTools.on( 'editable:disable', function(e) { e.layer.setStyle(this.style); }, this ); this.tooltip = new BR.EditingTooltip(map, editTools, this.button); this.tooltip.enable(); // dummy, no own representation, delegating to EasyButton return L.DomUtil.create('div'); }, displayUploadError: function(message) { $('#nogoError').text(message ? message : ''); $('#nogoError').css('display', message ? 'block' : 'none'); }, uploadNogos: function() { var self = this; var geoJSONPromise; var nogoURL = $('#nogoURL').val(); var nogoFile = $('#nogoFile')[0].files[0]; if (nogoURL) { // TODO: Handle {{bbox}} geoJSONPromise = fetch(nogoURL).then(function(response) { response.json(); }); } else if (nogoFile) { geoJSONPromise = new Promise(function(resolve, reject) { var reader = new FileReader(); reader.onload = function() { resolve(reader.result); }; reader.onerror = function() { self.displayUploadError('Could not load file: ' + reader.error.message); }; reader.readAsText(nogoFile); }).then(function(response) { return JSON.parse(response); }); } else { // FIXME: use form validator instead self.displayUploadError('Missing file or URL.'); return false; } var nogoWeight = parseFloat($('#nogoWeight').val()); if (isNaN(nogoWeight)) { // FIXME: use form validator instead self.displayUploadError('Missing default nogo weight.'); return false; } var nogoRadius = parseFloat($('#nogoRadius').val()); if (isNaN(nogoRadius) || nogoRadius < 0) { // FIXME: use form validator instead self.displayUploadError('Invalid default nogo radius.'); return false; } var nogoBuffer = parseFloat($('#nogoBuffer').val()); if (isNaN(nogoBuffer)) { // FIXME: use form validator instead self.displayUploadError('Invalid nogo buffering radius.'); return false; } geoJSONPromise.then(function(response) { // Iterate on features in order to discard features without geometry var cleanedGeoJSONFeatures = []; turf.flattenEach(response, function(feature) { if (turf.getGeom(feature)) { var maybeBufferedFeature = feature; // Eventually buffer GeoJSON if (nogoBuffer !== 0) { maybeBufferedFeature = turf.buffer(maybeBufferedFeature, nogoBuffer, { units: 'meters' }); } cleanedGeoJSONFeatures.push(maybeBufferedFeature); } }); if (cleanedGeoJSONFeatures.length === 0) { self.displayUploadError('No valid area found in provided input.'); return false; } var geoJSON = L.geoJson(turf.featureCollection(cleanedGeoJSONFeatures), { onEachFeature: function(feature, layer) { layer.options.nogoWeight = feature.properties.nogoWeight || nogoWeight; } }); var nogosPoints = geoJSON.getLayers().filter(function(e) { return e.feature.geometry.type === 'Point'; }); nogosPoints = nogosPoints.map(function(item) { var radius = item.feature.properties.radius || nogoRadius; if (radius > 0) { return L.circle(item.getLatLng(), { radius: radius }); } return null; }); nogosPoints = nogosPoints.filter(function(e) { return e; }); self.setOptions({ nogos: nogosPoints, polygons: geoJSON.getLayers().filter(function(e) { return e.feature.geometry.type === 'Polygon'; }), polylines: geoJSON.getLayers().filter(function(e) { return e.feature.geometry.type === 'LineString'; }) }); self._fireUpdate(); self.displayUploadError(undefined); $('#loadNogos').modal('hide'); }); return false; }, // prevent route waypoint added after circle create (map click after up) preventRoutePointOnCreate: function(routing) { this.editTools.on( 'editable:drawing:start', function(e) { this._wasRouteDrawing = routing.isDrawing(); routing.draw(false); }, this ); // after create this.editTools.on( 'editable:drawing:end', function(e) { if (this._wasRouteDrawing) { setTimeout(function() { routing.draw(true); }, 0); } }, this ); }, getOptions: function() { return { nogos: this.drawnItems.getLayers().filter(function(e) { return e instanceof L.Circle; }), polygons: this.drawnItems.getLayers().filter(function(e) { return e instanceof L.Polygon; }), polylines: this.drawnItems.getLayers().filter(function(e) { return e instanceof L.Polyline && !(e instanceof L.Polygon); }) }; }, setOptions: function(options) { var nogos = options.nogos; var polylines = options.polylines; var polygons = options.polygons; this._clear(); if (nogos) { for (var i = 0; i < nogos.length; i++) { nogos[i].setStyle(this.style); this.drawnItems.addLayer(nogos[i]); } } if (polylines) { for (var i = 0; i < polylines.length; i++) { polylines[i].setStyle(this.style); this.drawnItems.addLayer(polylines[i]); } } if (polygons) { for (var i = 0; i < polygons.length; i++) { polygons[i].setStyle(this.style); this.drawnItems.addLayer(polygons[i]); } } }, _clear: function() { this.drawnItems.clearLayers(); }, clear: function() { this._clear(); this._fireUpdate(); }, _fireUpdate: function() { this.fire('update', { options: this.getOptions() }); }, getFeatureGroup: function() { return this.drawnItems; }, getEditGroup: function() { return this.editTools.editLayer; }, getButton: function() { return this.button; } }); BR.NogoAreas.include(L.Evented.prototype); BR.Editable = L.Editable.extend({ // Editable relies on L.Map.Tap for touch support. But the Tap handler is not added when // the Browser supports Pointer events, which is the case for mobile Chrome. So we add it // ourselves in this case, but disabled and only enable while drawing (#259). // Also, we generally disable the Tap handler in the map options for route dragging, // see Map.js, so we always need to enable for drawing. initialize: function(map, options) { L.Editable.prototype.initialize.call(this, map, options); if (!this.map.tap) { this.map.addHandler('tap', L.Map.Tap); this.map.tap.disable(); } }, registerForDrawing: function(editor) { this._tapEnabled = this.map.tap.enabled(); if (!this._tapEnabled) { this.map.tap.enable(); } L.Editable.prototype.registerForDrawing.call(this, editor); }, unregisterForDrawing: function(editor) { if (!this._tapEnabled) { this.map.tap.disable(); } L.Editable.prototype.unregisterForDrawing.call(this, editor); }, createVertexIcon: function(options) { return BR.Browser.touch ? new L.Editable.TouchVertexIcon(options) : new L.Editable.VertexIcon(options); } }); BR.EditingTooltip = L.Handler.extend({ options: { closeTimeout: 2000 }, initialize: function(map, editTools, button) { this.map = map; this.editTools = editTools; this.button = button; }, addHooks: function() { // hack: listen to EasyButton click (instead of editable:drawing:start), // to get mouse position from event for initial tooltip location L.DomEvent.addListener(this.button.button, 'click', this._addCreate, this); this.editTools.featuresLayer.on('layeradd', this._bind, this); this.editTools.on('editable:drawing:end', this._postCreate, this); this.editTools.on('editable:enable', this._enable, this); this.editTools.on('editable:disable', this._disable, this); }, removeHooks: function() { L.DomEvent.removeListener(this.button.button, 'click', this._addCreate, this); this.editTools.featuresLayer.off('layeradd', this._bind, this); this.editTools.off('editable:drawing:end', this._postCreate, this); this.editTools.off('editable:enable', this._enable, this); this.editTools.off('editable:disable', this._disable, this); }, _bind: function(e) { // Position tooltip at bottom of circle, less distracting than // sticky with cursor or at center. var layer = e.layer; layer.bindTooltip(BR.NogoAreas.MSG_DISABLED, { direction: 'bottom', className: 'editing-tooltip' }); // Override to set position to south instead of center (circle latlng); // works better with zooming than updating offset to match radius layer.openTooltip = function(layer, latlng) { if (!latlng && layer instanceof L.Layer) { latlng = L.latLng( layer.getBounds().getSouth(), 0.5 * (layer.getBounds().getWest() + layer.getBounds().getEast()) ); } L.Layer.prototype.openTooltip.call(this, layer, latlng); }; }, _addCreate: function(e) { // button cancel if (!this.editTools.drawing()) return; var initialLatLng = this.map.mouseEventToLatLng(e); var tooltip = L.tooltip({ // no effect with map tooltip sticky: true, // offset wrong with 'auto' when switching direction direction: 'right', offset: L.point(5, 28), className: 'editing-tooltip-create' }); // self-reference hack for _moveTooltip, as tooltip is not bound to layer tooltip._tooltip = tooltip; // simulate sticky feature (follow mouse) for map tooltip without layer var onOffMove = function(e) { var onOff = e.type === 'tooltipclose' ? 'off' : 'on'; this._map[onOff]('mousemove', this._moveTooltip, this); }; this.map.on('tooltipopen', onOffMove, tooltip); this.map.on('tooltipclose', onOffMove, tooltip); var onTooltipRemove = function(e) { this.map.off('tooltipopen', onOffMove, e.tooltip); this.map.off('tooltipclose', onOffMove, e.tooltip); this.map.off('tooltipclose', onTooltipRemove, this); e.tooltip._tooltip = null; }; this.map.on('tooltipclose', onTooltipRemove, this); tooltip.setTooltipContent(BR.NogoAreas.MSG_CREATE); this.map.openTooltip(tooltip, initialLatLng); var closeTooltip = function() { this.map.closeTooltip(tooltip); }; this.editTools.once('editable:editing editable:drawing:cancel', closeTooltip, this); if (BR.Browser.touch) { // can't move with cursor on touch devices, so show at start pos for a few seconds setTimeout(L.bind(closeTooltip, this), this.options.closeTimeout); } }, _setCloseTimeout: function(layer) { var timeoutId = setTimeout(function() { layer.closeTooltip(); }, this.options.closeTimeout); // prevent timer to close tooltip that changed in the meantime layer.once('tooltipopen', function(e) { clearTimeout(timeoutId); }); }, _postCreate: function() { // editing is disabled by another handler, tooltip won't stay open before this.editTools.once( 'editable:disable', function(e) { // show for a few seconds, as mouse often not hovering circle after create e.layer.openTooltip(e.layer); this._setCloseTimeout(e.layer); }, this ); }, _enable: function(e) { e.layer.setTooltipContent(BR.NogoAreas.MSG_ENABLED); this.editTools.once( 'editable:editing', function(e) { e.layer.closeTooltip(); }, this ); }, _disable: function(e) { e.layer.setTooltipContent(BR.NogoAreas.MSG_DISABLED); this._setCloseTimeout(e.layer); } }); BR.DeletableCircleEditor = L.Editable.CircleEditor.extend({ _computeDeleteLatLng: function() { // While circle is not added to the map, _radius is not set. var delta = (this.feature._radius || this.feature._mRadius) * Math.cos(Math.PI / 4), point = this.map.project(this.feature._latlng); return this.map.unproject([point.x - delta, point.y - delta]); }, _updateDeleteLatLng: function() { this._deleteLatLng.update(this._computeDeleteLatLng()); this._deleteLatLng.__vertex.update(); }, _addDeleteMarker: function() { if (!this.enabled()) return; this._deleteLatLng = this._computeDeleteLatLng(); return new BR.DeleteMarker(this._deleteLatLng, this); }, _delete: function() { this.disable(); this.tools.featuresLayer.removeLayer(this.feature); }, delete: function() { this._delete(); this.fireAndForward('editable:deleted'); }, initialize: function(map, feature, options) { L.Editable.CircleEditor.prototype.initialize.call(this, map, feature, options); this._deleteLatLng = this._computeDeleteLatLng(); // FeatureGroup instead of LayerGroup to propagate events to members this.editLayer = new L.FeatureGroup(); }, addHooks: function() { L.Editable.CircleEditor.prototype.addHooks.call(this); if (this.feature) { this._addDeleteMarker(); } return this; }, reset: function() { L.Editable.CircleEditor.prototype.reset.call(this); this._addDeleteMarker(); }, onDrawingMouseDown: function(e) { this._deleteLatLng.update(e.latlng); L.Editable.CircleEditor.prototype.onDrawingMouseDown.call(this, e); }, // override to cancel/remove created circle when added by click instead of drag, because: // - without resize, edit handles stacked on top of each other // - makes event handling more complicated (editable:vertex:dragend not called) onDrawingMouseUp: function(e) { if (this.feature.getRadius() > 0) { this.commitDrawing(e); } else { this.cancelDrawing(e); this._delete(); } e.originalEvent._simulated = false; L.Editable.PathEditor.prototype.onDrawingMouseUp.call(this, e); }, onVertexMarkerDrag: function(e) { this._updateDeleteLatLng(); L.Editable.CircleEditor.prototype.onVertexMarkerDrag.call(this, e); } }); BR.DeleteMarker = L.Marker.extend({ options: { draggable: false, icon: L.divIcon({ iconSize: BR.Browser.touch ? new L.Point(24, 24) : new L.Point(16, 16), className: 'leaflet-div-icon fa fa-trash-o nogo-delete-marker' }) }, initialize: function(latlng, editor, options) { // derived from L.Editable.VertexMarker.initialize // We don't use this._latlng, because on drag Leaflet replace it while // we want to keep reference. this.latlng = latlng; this.editor = editor; L.Marker.prototype.initialize.call(this, latlng, options); this.latlng.__vertex = this; this.editor.editLayer.addLayer(this); // to keep small circles editable, make sure delete button is below drag handle // (not using "+ 1" to place at bottom of other vertex markers) this.setZIndexOffset(editor.tools._lastZIndex); }, onAdd: function(map) { L.Marker.prototype.onAdd.call(this, map); this.on('click', this.onClick); }, onRemove: function(map) { delete this.latlng.__vertex; this.off('click', this.onClick); L.Marker.prototype.onRemove.call(this, map); }, onClick: function(e) { this.editor.delete(); } });