diff --git a/CHANGELOG.md b/CHANGELOG.md index 8de197a..1d3a0ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ### Change Log for Node-RED Worldmap + - v2.2.0 - Add rangering arcs function - v2.1.6 - Add legend command to allow inserting an html legend - v2.1.5 - Fix squawk icon color handling - v2.1.4 - Fix alt and speed as strings diff --git a/README.md b/README.md index 60d5ec0..02fa1d7 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # node-red-contrib-web-worldmap [![npm version](https://badge.fury.io/js/node-red-contrib-web-worldmap.svg)](https://badge.fury.io/js/node-red-contrib-web-worldmap) -[![GitHub license](https://github.com/dceejay/redmap/blob/master/LICENSE)](https://img.shields.io/github/license/dceejay/redmap.svg) +[![GitHub license](https://img.shields.io/github/license/dceejay/redmap.svg)](https://github.com/dceejay/redmap/blob/master/LICENSE) A Node-RED node to provide a world map web page for plotting "things" on. @@ -10,6 +10,7 @@ map web page for plotting "things" on. ### Updates +- v2.2.0 - Add rangerings arcs function - v2.1.6 - Add legend command to allow inserting an html legend - v2.1.5 - Fix squawk icon color handling - v2.1.4 - Fix alt and speed as strings @@ -17,27 +18,8 @@ map web page for plotting "things" on. - v2.1.2 - Fix layercontrol remove bug. Issue #116 - v2.1.1 - fix bug in repeated add with polygon - v2.1.0 - add ui-worldmap node to make embedding in Dashboard easier. Let -in node specify connection actions only. -- v2.0.22 - fix SIDC missing property -- v2.0.21 - allow adding overlays without making them visible (visible:false). Issue #108 -- v2.0.20 - ensure `fit` option is boolean, Issue #109. Fix track layers, Issue #110. -- v2.0.18 - Stop map contextmenu bleedthrough to marker. Add compress middleware. -- v2.0.17 - Let clear command also clear tracks from tracks node -- v2.0.16 - Revert use of ES6 import. Keep IE11 happy for while -- v2.0.13 - Fix tracks colour. -- v2.0.12 - Ensure default icon is in place if not specified (regression) -- v2.0.9 - Only update maxage on screen once it exists -- v2.0.8 - Drop beta flag, re-organise index, js and css files. Now using leaflet 1.4 -- v2.0.7-beta - Switch Ruler control to be independent of Draw library. -- v2.0.6-beta - Re-enable editing of draw layer, add rectangles to lines and areas. Make individual objects editable. -- v2.0.5-beta - Fix clustering on zoom (update old library) -- v2.0.4-beta - Add helicopter icon. Correct Leaflet.Coordinates file name. Fix right contextmenu. -- v2.0.3-beta - Let circles have popups. Better drawing of ellipses -- v2.0.2-beta - Let lines and areas also have popups -- v2.0.1-beta - Add optional graticule -- v2.0.0-beta - Move to leaflet 1.4.x plus all plugins updated - - ... -see [CHANGELOG](https://github.com/dceejay/RedMap/blob/master/CHANGELOG.md) for full list. +- see [CHANGELOG](https://github.com/dceejay/RedMap/blob/master/CHANGELOG.md) for full list. ## Install @@ -45,7 +27,6 @@ Either use the Manage Palette option in the Node-RED Editor menu, or run the fol npm i node-red-contrib-web-worldmap - ## Usage Plots "things" on a map. By default the map will be served from `{httpRoot}/worldmap`, but this @@ -225,6 +206,7 @@ a number of degrees. msg.payload = { "name":"Bristol Channel", "lat":51.5, "lon":-2.9, "radius":[30000,70000], "tilt":45 }; + ### Options Areas, Rectangles, Lines, Circles and Ellipses can also specify more optional properties: @@ -242,6 +224,31 @@ Areas, Rectangles, Lines, Circles and Ellipses can also specify more optional pr Other properties can be found in the leaflet documentation. + +### Arcs, Range Rings + +You can add supplemental arc(s) to an icon by adding an **arc** property as below. +Supplemental means that you can also specify a line using a **bearing** and **length** property. + +``` +msg.payload = { name:"Camera01", icon:"fa-camera", lat:51.05, lon:-1.35, + bearing: 235, + length: 2200, + arc: { + ranges: [500,1000,2000], + pan: 228, + fov: 40, + color: '#aaaa00' + } +} +``` + +**ranges** can be a single number or an array of arc distances from the marker. +The **pan** is the bearing of the centre of the arc, and the **fov** (Field of view) +specifies the angle of the arc. +Defaults are shown above. + + ## Drawing A single *right click* will allow you to add a point to the map - you must specify the `name` and optionally the `icon` and `layer`. @@ -251,6 +258,7 @@ Right-clicking on an icon will allow you to delete it. If you select the **drawing** layer you can also add and edit polylines, polygons, rectangles and circles. Once an item is drawn you can right click to edit or delete it. Double click the object to exit edit mode. + ## Events from the map The **worldmap in** node can be used to receive various events from the map. Examples of messages coming FROM the map include: diff --git a/package.json b/package.json index eec6a36..00d2e8d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "node-red-contrib-web-worldmap", - "version": "2.1.6", + "version": "2.2.0", "description": "A Node-RED node to provide a web page of a world map for plotting things on.", "dependencies": { "cgi": "0.3.1", diff --git a/worldmap/index.html b/worldmap/index.html index 69df42a..6e9f789 100644 --- a/worldmap/index.html +++ b/worldmap/index.html @@ -67,6 +67,7 @@ + diff --git a/worldmap/leaflet/Semicircle.js b/worldmap/leaflet/Semicircle.js new file mode 100644 index 0000000..89ff3b8 --- /dev/null +++ b/worldmap/leaflet/Semicircle.js @@ -0,0 +1,196 @@ +/** + * Semicircle extension for L.Circle. + * Jan Pieter Waagmeester + * + * This version is tested with leaflet 1.0.2 + */ +(function (factory) { + if (typeof define === 'function' && define.amd) { + // AMD + define(['leaflet'], factory); + } else if (typeof module !== 'undefined' && typeof require !== 'undefined') { + // Node/CommonJS + module.exports = factory(require('leaflet')); + } else { + // Browser globals + if (typeof window.L === 'undefined') { + throw 'Leaflet must be loaded first'; + } + factory(window.L); + } +})(function (L) { + var DEG_TO_RAD = Math.PI / 180; + + // make sure 0 degrees is up (North) and convert to radians. + function fixAngle (angle) { + return (angle - 90) * DEG_TO_RAD; + } + + // rotate point [x + r, y+r] around [x, y] by `angle` radians. + function rotated (p, angle, r) { + return p.add( + L.point(Math.cos(angle), Math.sin(angle)).multiplyBy(r) + ); + } + + L.Point.prototype.rotated = function (angle, r) { + return rotated(this, angle, r); + }; + + var semicircle = { + options: { + startAngle: 0, + stopAngle: 359.9999 + }, + + startAngle: function () { + if (this.options.startAngle < this.options.stopAngle) { + return fixAngle(this.options.startAngle); + } else { + return fixAngle(this.options.stopAngle); + } + }, + + stopAngle: function () { + if (this.options.startAngle < this.options.stopAngle) { + return fixAngle(this.options.stopAngle); + } else { + return fixAngle(this.options.startAngle); + } + }, + + setStartAngle: function (angle) { + this.options.startAngle = angle; + return this.redraw(); + }, + + setStopAngle: function (angle) { + this.options.stopAngle = angle; + return this.redraw(); + }, + + setDirection: function (direction, degrees) { + if (degrees === undefined) { + degrees = 10; + } + this.options.startAngle = direction - (degrees / 2); + this.options.stopAngle = direction + (degrees / 2); + + return this.redraw(); + }, + getDirection: function () { + return this.stopAngle() - (this.stopAngle() - this.startAngle()) / 2; + }, + + isSemicircle: function () { + var startAngle = this.options.startAngle, + stopAngle = this.options.stopAngle; + + return ( + !(startAngle === 0 && stopAngle > 359) && + !(startAngle === stopAngle) + ); + }, + _containsPoint: function (p) { + function normalize (angle) { + while (angle <= -Math.PI) { + angle += 2.0 * Math.PI; + } + while (angle > Math.PI) { + angle -= 2.0 * Math.PI; + } + return angle; + } + var angle = Math.atan2(p.y - this._point.y, p.x - this._point.x); + var nStart = normalize(this.startAngle()); + var nStop = normalize(this.stopAngle()); + if (nStop <= nStart) { + nStop += 2.0 * Math.PI; + } + if (angle <= nStart) { + angle += 2.0 * Math.PI; + } + return ( + nStart < angle && angle <= nStop && + p.distanceTo(this._point) <= this._radius + this._clickTolerance() + ); + } + }; + + L.SemiCircle = L.Circle.extend(semicircle); + L.SemiCircleMarker = L.CircleMarker.extend(semicircle); + + L.semiCircle = function (latlng, options) { + return new L.SemiCircle(latlng, options); + }; + L.semiCircleMarker = function (latlng, options) { + return new L.SemiCircleMarker(latlng, options); + }; + + var _updateCircleSVG = L.SVG.prototype._updateCircle; + var _updateCircleCanvas = L.Canvas.prototype._updateCircle; + + L.SVG.include({ + _updateCircle: function (layer) { + // If we want a circle, we use the original function + if (!(layer instanceof L.SemiCircle || layer instanceof L.SemiCircleMarker) || + !layer.isSemicircle()) { + return _updateCircleSVG.call(this, layer); + } + if (layer._empty()) { + return this._setPath(layer, 'M0 0'); + } + + var p = layer._map.latLngToLayerPoint(layer._latlng), + r = layer._radius, + r2 = Math.round(layer._radiusY || r), + start = p.rotated(layer.startAngle(), r), + end = p.rotated(layer.stopAngle(), r); + + var largeArc = (layer.options.stopAngle - layer.options.startAngle >= 180) ? '1' : '0'; + + var d = 'M' + p.x + ',' + p.y + + // line to first start point + 'L' + start.x + ',' + start.y + + 'A ' + r + ',' + r2 + ',0,' + largeArc + ',1,' + end.x + ',' + end.y + + ' z'; + + this._setPath(layer, d); + } + }); + + L.Canvas.include({ + _updateCircle: function (layer) { + // If we want a circle, we use the original function + if (!(layer instanceof L.SemiCircle || layer instanceof L.SemiCircleMarker) || + !layer.isSemicircle()) { + return _updateCircleCanvas.call(this, layer); + } + + if (!this._drawing || layer._empty()) { return; } + + var p = layer._point, + ctx = this._ctx, + r = layer._radius, + s = (layer._radiusY || r) / r, + start = p.rotated(layer.startAngle(), r); + + if (s !== 1) { + ctx.save(); + ctx.scale(1, s); + } + + ctx.beginPath(); + ctx.moveTo(p.x, p.y); + ctx.lineTo(start.x, start.y); + ctx.arc(p.x, p.y, r, layer.startAngle(), layer.stopAngle()); + ctx.lineTo(p.x, p.y); + + if (s !== 1) { + ctx.restore(); + } + + this._fillStroke(ctx, layer); + } + }); +}); \ No newline at end of file diff --git a/worldmap/worldmap.js b/worldmap/worldmap.js index af1b578..2c12b53 100644 --- a/worldmap/worldmap.js +++ b/worldmap/worldmap.js @@ -1,3 +1,4 @@ +/* eslint-disable no-undef */ var startpos = [51.03, -1.379]; // Start location - somewhere in UK :-) var startzoom = 10; @@ -147,6 +148,23 @@ var yellowButton = L.easyButton('fa-square wm-yellow', function(btn) { console.l var blackButton = L.easyButton('fa-square wm-black', function(btn) { console.log("BLACK",btn); }) var colorControl = L.easyBar([redButton,blueButton,greenButton,yellowButton,blackButton]); + +function onLocationFound(e) { + var radius = e.accuracy; + //L.marker(e.latlng).addTo(map).bindPopup("You are within " + radius + " meters from this point").openPopup(); + L.circle(e.latlng, radius, {color:"cyan", weight:4, opacity:0.8, fill:false, clickable:false}).addTo(map); + if (e.hasOwnProperty("heading")) { + var lengthAsDegrees = e.speed * 60 / 110540; + var ya = e.latlng.lat + Math.sin((90-e.heading)/180*Math.PI)*lengthAsDegrees*Math.cos(e.latlng.lng/180*Math.PI); + var xa = e.latlng.lng + Math.cos((90-e.heading)/180*Math.PI)*lengthAsDegrees; + var lla = new L.LatLng(ya,xa); + L.polygon([ e.latlng, lla ], {color:"cyan", weight:3, opacity:0.8, clickable:false}).addTo(map); + } + ws.send(JSON.stringify({action:"point", lat:e.latlng.lat.toFixed(5), lon:e.latlng.lng.toFixed(5), point:"self", bearing:e.heading, speed:(e.speed*3.6 || undefined)})); +} + +function onLocationError(e) { console.log(e.message); } + // Move some bits around if in an iframe if (window.self !== window.top) { console.log("IN an iframe"); @@ -174,20 +192,7 @@ else { L.easyButton( 'fa-crosshairs fa-lg', function() { map.locate({setView:true, maxZoom:16}); }, "Locate me").addTo(map); - function onLocationFound(e) { - var radius = e.accuracy; - //L.marker(e.latlng).addTo(map).bindPopup("You are within " + radius + " meters from this point").openPopup(); - L.circle(e.latlng, radius, {color:"cyan", weight:4, opacity:0.8, fill:false, clickable:false}).addTo(map); - if (e.hasOwnProperty("heading")) { - var lengthAsDegrees = e.speed * 60 / 110540; - var ya = e.latlng.lat + Math.sin((90-e.heading)/180*Math.PI)*lengthAsDegrees*Math.cos(e.latlng.lng/180*Math.PI); - var xa = e.latlng.lng + Math.cos((90-e.heading)/180*Math.PI)*lengthAsDegrees; - var lla = new L.LatLng(ya,xa); - L.polygon([ e.latlng, lla ], {color:"cyan", weight:3, opacity:0.8, clickable:false}).addTo(map); - } - ws.send(JSON.stringify({action:"point", lat:e.latlng.lat.toFixed(5), lon:e.latlng.lng.toFixed(5), point:"self", bearing:e.heading, speed:(e.speed*3.6 || undefined)})); - } - function onLocationError(e) { console.log(e.message); } + map.on('locationfound', onLocationFound); map.on('locationerror', onLocationError); @@ -233,15 +238,15 @@ if (showUserMenu) { // Add graticule var showGrid = false; var Lgrid = L.latlngGraticule({ - font: "Verdana", + font: "Verdana", fontColor: "#666", zoomInterval: [ - {start:1, end:2, interval:40}, - {start:3, end:3, interval:20}, - {start:4, end:4, interval:10}, - {start:5, end:7, interval:5}, - {start:8, end:20, interval:1} - ] + {start:1, end:2, interval:40}, + {start:3, end:3, interval:20}, + {start:4, end:4, interval:10}, + {start:5, end:7, interval:5}, + {start:8, end:20, interval:1} + ] }); var panit = false; @@ -352,22 +357,22 @@ function doSearch() { var searchUrl = protocol + "//nominatim.openstreetmap.org/search?format=json&limit=1&q="; fetch(searchUrl + value) // Call the fetch function passing the url of the API as a parameter - .then(function(resp) { return resp.json(); }) - .then(function(data) { - if (data.length > 0) { - var bb = data[0].boundingbox; - map.fitBounds([ [bb[0],bb[2]], [bb[1],bb[3]] ]); - map.panTo([data[0].lat, data[0].lon]); - } - else { - document.getElementById('searchResult').innerHTML = " Not Found"; - } - }) - .catch(function(err) { - if (err.toString() === "TypeError: Failed to fetch") { - document.getElementById('searchResult').innerHTML = " Not Found"; - } - }); + .then(function(resp) { return resp.json(); }) + .then(function(data) { + if (data.length > 0) { + var bb = data[0].boundingbox; + map.fitBounds([ [bb[0],bb[2]], [bb[1],bb[3]] ]); + map.panTo([data[0].lat, data[0].lon]); + } + else { + document.getElementById('searchResult').innerHTML = " Not Found"; + } + }) + .catch(function(err) { + if (err.toString() === "TypeError: Failed to fetch") { + document.getElementById('searchResult').innerHTML = " Not Found"; + } + }); } else { if (lockit) { @@ -512,7 +517,16 @@ function showMapCurrentZoom() { polygons[key].setStyle({opacity:0}); } } - polygons[key].redraw(); + try { + if (polygons[key].hasOwnProperty("_layers")) { + polygons[key].eachLayer(function(layer) { layer.redraw(); }); + } + else { + polygons[key].redraw(); + } + } catch(e) { + console.log(key,polygons[key],e) + } } } },750); @@ -911,6 +925,26 @@ var editPoly = function(pname) { editHandler.enable(); } +var rangerings = function(latlng, options) { + options = L.extend({ + ranges: [250,500,750,1000], + pan: 0, + fov: 60, + color: '#910000' + }, options); + var rings = L.featureGroup(); + if (typeof options.ranges === "number") { options.ranges = [ options.ranges ]; } + for (var i = 0; i < options.ranges.length; i++) { + L.semiCircle(latlng, { + radius: options.ranges[i], + fill: false, + color: options.color, + weight: 1 + }).setDirection(options.pan, options.fov).addTo(rings); + } + return rings; +} + // the MAIN add something to map function function setMarker(data) { var rightmenu = function(m) { @@ -1019,6 +1053,7 @@ function setMarker(data) { catch(e) { console.log("OOPS"); } } } + if (typeof polygons[data.name] != "undefined") { layers[lay].removeLayer(polygons[data.name]); } if (data.hasOwnProperty("line") && Array.isArray(data.line)) { @@ -1052,19 +1087,23 @@ function setMarker(data) { polygons[data.name] = polycirc; } } + else if (data.hasOwnProperty("arc")) { + if (data.hasOwnProperty("lat") && data.hasOwnProperty("lon")) { + polygons[data.name] = rangerings(new L.LatLng((data.lat*1), (data.lon*1)), data.arc); + } + } if (polygons[data.name] !== undefined) { polygons[data.name].lay = lay; if (opt.clickable === true) { var words = ""+data.name+""; - if (data.popup) { var words = words + "
" + data.popup; } + if (data.popup) { words = words + "
" + data.popup; } polygons[data.name].bindPopup(words, {autoClose:false, closeButton:true, closeOnClick:false, minWidth:200}); } //polygons[data.name] = rightmenu(polygons[data.name]); // DCJ Investigate layers[lay].addLayer(polygons[data.name]); } - if (typeof data.coordinates == "object") { ll = new L.LatLng(data.coordinates[1],data.coordinates[0]); } else if (data.hasOwnProperty("position") && data.position.hasOwnProperty("lat") && data.position.hasOwnProperty("lon")) { data.lat = data.position.lat*1; @@ -1444,6 +1483,7 @@ function setMarker(data) { var llc = data.lineColor || data.color; delete data.lat; delete data.lon; + if (data.arc) { delete data.arc; } if (data.layer) { delete data.layer; } if (data.lineColor) { delete data.lineColor; } if (data.color) { delete data.color; } @@ -1481,7 +1521,9 @@ function setMarker(data) { if (data.bearing != null) { // if there is a heading if (data.speed != null) { data.length = parseFloat(data.speed || "0") * 50; } // and a speed if (data.length != null) { - if (polygons[data.name] != null) { map.removeLayer(polygons[data.name]); } + if (polygons[data.name] != null && !polygons[data.name].hasOwnProperty("_layers")) { + map.removeLayer(polygons[data.name]); + } var x = ll.lng * 1; // X coordinate var y = ll.lat * 1; // Y coordinate var ll1 = ll; @@ -1490,16 +1532,16 @@ function setMarker(data) { var polygon = null; if (data.accuracy != null) { data.accuracy = Number(data.accuracy); - var y2 = y + Math.sin((90-angle+data.accuracy)/180*Math.PI)*lengthAsDegrees*Math.cos(y/180*Math.PI); - var x2 = x + Math.cos((90-angle+data.accuracy)/180*Math.PI)*lengthAsDegrees; + var y2 = y + Math.sin((90-angle+data.accuracy)/180*Math.PI)*lengthAsDegrees; + var x2 = x + Math.cos((90-angle+data.accuracy)/180*Math.PI)*lengthAsDegrees/Math.cos(y/180*Math.PI); var ll2 = new L.LatLng(y2,x2); - var y3 = y + Math.sin((90-angle-data.accuracy)/180*Math.PI)*lengthAsDegrees*Math.cos(y/180*Math.PI); - var x3 = x + Math.cos((90-angle-data.accuracy)/180*Math.PI)*lengthAsDegrees; + var y3 = y + Math.sin((90-angle-data.accuracy)/180*Math.PI)*lengthAsDegrees; + var x3 = x + Math.cos((90-angle-data.accuracy)/180*Math.PI)*lengthAsDegrees/Math.cos(y/180*Math.PI); var ll3 = new L.LatLng(y3,x3); polygon = L.polygon([ ll1, ll2, ll3 ], {weight:2, color:llc||'#f30', fillOpacity:0.06, clickable:false}); } else { - var ya = y + Math.sin((90-angle)/180*Math.PI)*lengthAsDegrees*Math.cos(y/180*Math.PI); - var xa = x + Math.cos((90-angle)/180*Math.PI)*lengthAsDegrees; + var ya = y + Math.sin((90-angle)/180*Math.PI)*lengthAsDegrees; + var xa = x + Math.cos((90-angle)/180*Math.PI)*lengthAsDegrees/Math.cos(y/180*Math.PI); var lla = new L.LatLng(ya,xa); polygon = L.polygon([ ll1, lla ], {weight:2, color:llc||'#f30', clickable:false}); } @@ -1509,7 +1551,12 @@ function setMarker(data) { polygon.setStyle({opacity:0}); } } - polygons[data.name] = polygon; + if (polygons[data.name] != null && polygons[data.name].hasOwnProperty("_layers")) { + polygons[data.name].addLayer(polygon); + } + else { + polygons[data.name] = polygon; + } polygons[data.name].lay = lay; layers[lay].addLayer(polygon); } diff --git a/worldmap/worldmaphead.html b/worldmap/worldmaphead.html index f02100b..ba06425 100644 --- a/worldmap/worldmaphead.html +++ b/worldmap/worldmaphead.html @@ -41,6 +41,7 @@ +