node-red-contrib-web-worldmap 5.6.1 → 5.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,13 @@
1
1
  /* eslint-disable no-undef */
2
2
 
3
+ /**
4
+ * @fileoverview Leaflet-based interactive world map client for Node-RED worldmap node.
5
+ * Connects to a Node-RED backend via SockJS WebSocket and renders markers,
6
+ * shapes, overlays, and TAK/CoT data on a Leaflet map.
7
+ * @author DCJ
8
+ * @version 2026
9
+ */
10
+
3
11
  var startpos = [51.05, -1.38]; // Start location - somewhere in UK :-)
4
12
  var startzoom = 10;
5
13
 
@@ -59,6 +67,12 @@ var iconSz = {
59
67
 
60
68
  var filesAdded = '';
61
69
 
70
+ /**
71
+ * Dynamically loads a JS or CSS file into the document <head>, skipping
72
+ * any file that has already been added.
73
+ * @param {string} fileName - URL or path of the .js or .css file to load.
74
+ * @returns {void}
75
+ */
62
76
  var loadStatic = function(fileName) {
63
77
  if (filesAdded.indexOf(fileName) !== -1) { return; }
64
78
  var head = document.getElementsByTagName('head')[0]
@@ -76,7 +90,7 @@ var loadStatic = function(fileName) {
76
90
  style.type = 'text/css';
77
91
  style.rel = 'stylesheet';
78
92
  console.log("Loading: ",fileName);
79
- head.append(style);;
93
+ head.append(style);
80
94
  filesAdded += ' ' + fileName;
81
95
  }
82
96
  else {
@@ -84,6 +98,13 @@ var loadStatic = function(fileName) {
84
98
  }
85
99
  }
86
100
 
101
+ /**
102
+ * Opens a SockJS WebSocket connection to the Node-RED backend.
103
+ * Handles connection, disconnection (with auto-reconnect after 2.5s),
104
+ * and incoming messages. On connect, sends client timezone and URL
105
+ * parameters to the server, then triggers map layer selection.
106
+ * @returns {void}
107
+ */
87
108
  // Create the socket
88
109
  var connect = function() {
89
110
  // var transports = ["websocket", "xhr-streaming", "xhr-polling"],
@@ -115,6 +136,14 @@ var connect = function() {
115
136
  };
116
137
  console.log("CONNECT TO",location.pathname + 'socket');
117
138
 
139
+ /**
140
+ * Routes incoming WebSocket data to the appropriate handler.
141
+ * Supports arrays of marker/command objects, raw XML strings (NVG, KML, GPX),
142
+ * GeoJSON Feature/FeatureCollection objects, TAK/CoT JSON events (both
143
+ * fastxml and Protobuf-decoded formats), and standard worldmap marker objects.
144
+ * @param {Object|Array|string} data - Parsed JSON data from the WebSocket message.
145
+ * @returns {void}
146
+ */
118
147
  var handleData = function(data) {
119
148
  if (Array.isArray(data)) {
120
149
  // console.log("ARRAY:",data.length);
@@ -169,25 +198,30 @@ var handleData = function(data) {
169
198
  if (JSON.stringify(data) !== '{}') {
170
199
  console.log("SKIP",data);
171
200
  }
172
- // if (typeof data === "string") { doDialog(data); }
173
- // else { console.log("SKIP",data); }
174
201
  }
175
202
  }
176
203
  }
177
204
 
178
205
  window.onunload = function() { if (ws) { ws.close(); } }
179
206
 
180
- var customTopoLayer = L.geoJson(null, {clickable:false, style: {color:"blue", weight:2, fillColor:"#cf6", fillOpacity:0.04}});
181
- layers["_countries"] = omnivore.topojson('images/world-50m-flat.json',null,customTopoLayer);
182
- overlays["countries"] = layers["_countries"];
183
-
207
+ // var customTopoLayer = L.geoJson(null, {clickable:false, style: {color:"blue", weight:2, fillColor:"#cf6", fillOpacity:0.04}});
208
+ // layers["_countries"] = omnivore.topojson('images/world-50m-flat.json',null,customTopoLayer);
209
+ // overlays["countries"] = layers["_countries"];
210
+
211
+ /**
212
+ * Adds an appropriate fallback base layer when the browser is offline,
213
+ * or when no base maps are available online. Uses a PMTiles layer if
214
+ * one is loaded, otherwise falls back to the countries outline overlay
215
+ * if available.
216
+ * @returns {void}
217
+ */
184
218
  var onoffline = function() {
185
219
  if (!navigator.onLine) {
186
220
  if (pmtloaded !== "") { basemaps[pmtloaded].addTo(map); layercontrol._update(); }
187
- else { map.addLayer(overlays["countries"]); }
221
+ else if (overlays["countries"]) { map.addLayer(overlays["countries"]); }
188
222
  }
189
223
  else if (Object.keys(basemaps).length === 0 ) {
190
- map.addLayer(overlays["countries"]);
224
+ if (overlays["countries"]) { map.addLayer(overlays["countries"]); }
191
225
  }
192
226
  }
193
227
 
@@ -316,7 +350,7 @@ var readFile = function(file) {
316
350
  ws.send(JSON.stringify({action:"file", name:file.name, type:file.type, content:content, lat:droplatlng.lat, lon:droplatlng.lng}));
317
351
  }
318
352
  else {
319
- console.log("NOT SURE WHAT THIS IS?",content)
353
+ console.log("NOT SURE WHAT TYPE OF FILE THIS IS ?",content)
320
354
  }
321
355
  });
322
356
  reader.readAsDataURL(file);
@@ -331,6 +365,7 @@ var followMode = { accuracy:true };
331
365
  var followState = false;
332
366
  var trackMeButton;
333
367
  var errRing;
368
+ var clrHeat;
334
369
 
335
370
  function onLocationFound(e) {
336
371
  if (followState === true) { map.panTo(e.latlng); }
@@ -409,9 +444,9 @@ else {
409
444
  // }, "Locate me").addTo(map);
410
445
 
411
446
  // Create the clear heatmap button
412
- var clrHeat = L.easyButton( 'fa-eraser', function() {
447
+ clrHeat = L.easyButton( 'fa-eraser', function() {
413
448
  console.log("Reset heatmap");
414
- heat.setLatLngs([]);
449
+ if (heat) { heat.setLatLngs([]); }
415
450
  }, "Clears the current heatmap", {position:"bottomright"});
416
451
  }
417
452
 
@@ -451,9 +486,9 @@ var edgeAware = function () {
451
486
  var mapBounds = map.getBounds();
452
487
  var mapBoundsCenter = mapBounds.getCenter();
453
488
 
454
- pSW = map.options.crs.latLngToPoint(mapBounds.getSouthWest(), map.getZoom());
455
- pNE = map.options.crs.latLngToPoint(mapBounds.getNorthEast(), map.getZoom());
456
- pCenter = map.options.crs.latLngToPoint(mapBoundsCenter, map.getZoom());
489
+ var pSW = map.options.crs.latLngToPoint(mapBounds.getSouthWest(), map.getZoom());
490
+ var pNE = map.options.crs.latLngToPoint(mapBounds.getNorthEast(), map.getZoom());
491
+ var pCenter = map.options.crs.latLngToPoint(mapBoundsCenter, map.getZoom());
457
492
 
458
493
  var viewBounds = L.latLngBounds(map.options.crs.pointToLatLng(L.point(pSW.x - (pCenter.x - pSW.x ), pSW.y - (pCenter.y - pSW.y )), map.getZoom()) , map.options.crs.pointToLatLng(L.point(pNE.x + (pNE.x - pCenter.x) , pNE.y + (pNE.y - pCenter.y) ), map.getZoom()) );
459
494
  for (var id in markers) {
@@ -513,7 +548,7 @@ function doPanit(v) {
513
548
 
514
549
  var heatAll = false;
515
550
  function doHeatAll(v) {
516
- if (v !== undefined) { heatall = v; }
551
+ if (v !== undefined) { heatAll = v; }
517
552
  console.log("Heatall set :",heatAll);
518
553
  }
519
554
 
@@ -538,10 +573,22 @@ function doLock(v) {
538
573
  //console.log("Map bounds lock :",lockit);
539
574
  }
540
575
 
576
+ /**
577
+ * Removes stale markers and optionally clears a named layer from the map.
578
+ * When called without an argument, sweeps all markers whose timestamp has
579
+ * expired based on the current maxage setting (marker sweep only — layer
580
+ * cleanup requires an explicit layer name argument).
581
+ * When called with a layer name, removes all markers on that layer and
582
+ * removes the layer itself from the map and layer control.
583
+ * Special case: passing "heatmap" clears all heatmap points.
584
+ * @param {string} [l] - Name of a specific layer to remove entirely.
585
+ * If omitted, only expired markers are swept.
586
+ * @returns {void}
587
+ */
541
588
  // Remove old markers
542
589
  function doTidyUp(l) {
543
590
  if (l === "heatmap") {
544
- heat.setLatLngs([]);
591
+ if (heat) { heat.setLatLngs([]); }
545
592
  }
546
593
  else {
547
594
  var d = parseInt(Date.now()/1000);
@@ -573,16 +620,28 @@ function doTidyUp(l) {
573
620
  }
574
621
  }
575
622
 
623
+ /**
624
+ * Reads the maxage value from the UI input and (re)starts the periodic
625
+ * marker sweep interval, which calls doTidyUp() every 20 seconds.
626
+ * Note: the interval invokes doTidyUp() without a layer argument, so
627
+ * only stale markers are swept — layer cleanup must be triggered explicitly.
628
+ * @returns {void}
629
+ */
576
630
  // Call tidyup every {maxage} seconds - default 10 mins
577
631
  var stale = null;
578
632
  function setMaxAge() {
579
633
  maxage = document.getElementById('maxage').value;
580
634
  if (stale) { clearInterval(stale); }
581
635
  //if (maxage > 0) {
582
- stale = setInterval( function() { doTidyUp() }, 20000); // check every 20 secs
636
+ stale = setInterval( function() { doTidyUp() }, 20000); // clear markers from all layers every 20 secs
583
637
  }
584
638
  setMaxAge();
585
639
 
640
+ /**
641
+ * Updates the day/night terminator line position on the map if the
642
+ * day/night overlay is currently active. Called on a 60-second interval.
643
+ * @returns {void}
644
+ */
586
645
  // move the daylight / nighttime boundary (if enabled) every minute
587
646
  function moveTerminator() { // if terminator line plotted move it every minute
588
647
  if (layers["_daynight"] && layers["_daynight"].getLayers().length > 0) {
@@ -592,6 +651,12 @@ function moveTerminator() { // if terminator line plotted move it every minute
592
651
  }
593
652
  setInterval( function() { moveTerminator() }, 60000 );
594
653
 
654
+ /**
655
+ * Refreshes the rainfall radar tile layer URL to the latest 10-minute
656
+ * radar snapshot from RainViewer, if the layer is active and the browser
657
+ * is online. Called on a 600-second (10-minute) interval.
658
+ * @returns {void}
659
+ */
595
660
  // move the rainfall overlay (if enabled) every 10 minutes
596
661
  function moveRainfall() {
597
662
  if (navigator.onLine && overlays.hasOwnProperty("rainfall") && map.hasLayer(overlays["rainfall"])) {
@@ -601,6 +666,12 @@ function moveRainfall() {
601
666
  }
602
667
  setInterval( function() { moveRainfall() }, 600000 );
603
668
 
669
+ /**
670
+ * Sets the zoom level at which markers are clustered and refreshes
671
+ * the current map zoom display.
672
+ * @param {number} [v=0] - Zoom level threshold for clustering. 0 disables clustering.
673
+ * @returns {void}
674
+ */
604
675
  function setCluster(v) {
605
676
  clusterAt = v || 0;
606
677
  console.log("clusterAt set:",clusterAt);
@@ -631,6 +702,12 @@ async function readPhoton(url) {
631
702
  }
632
703
  }
633
704
 
705
+ /**
706
+ * Searches for map markers matching the given search input by name or icon,
707
+ * constrained to the current map bounds. If no markers are found, falls back
708
+ * to a Nominatim geocoder lookup and pans the map to the result.
709
+ * @returns {void}
710
+ */
634
711
  // Search for markers with names of ... or icons of ...
635
712
  function doSearch() {
636
713
  var value = document.getElementById('search').value;
@@ -758,7 +835,7 @@ function toggleMenu() {
758
835
  }
759
836
  else {
760
837
  document.getElementById("menu").style.display = 'none';
761
- dialogue.close();
838
+ if (dialog) { dialog.close(); }
762
839
  }
763
840
  }
764
841
 
@@ -774,7 +851,7 @@ function closeMenu() {
774
851
  menuOpen = false;
775
852
  document.getElementById("menu").style.display = 'none';
776
853
  }
777
- dialogue.close();
854
+ if (dialog) { dialog.close(); }
778
855
  }
779
856
 
780
857
  document.getElementById("menu").style.display = 'none';
@@ -943,7 +1020,20 @@ var addmenu = "<b>Add marker</b><br><input type='text' id='rinput' autofocus onk
943
1020
  addmenu += '<br/><a href="unitgenerator.html" target="_new">MilSymbol SIDC generator</a>';
944
1021
  var rightmenuMap = L.popup({keepInView:true, minWidth:260}).setContent(addmenu);
945
1022
 
1023
+ /**
1024
+ * Converts an RGBA CSS colour string to a hex colour string.
1025
+ * @param {string} rgba - CSS rgba() or rgb() colour string.
1026
+ * @returns {string} Hex colour string (e.g. "#ff0000").
1027
+ */
946
1028
  const rgba2hex = (rgba) => `#${rgba.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+\.{0,1}\d*))?\)$/).slice(1).map((n, i) => (i === 3 ? Math.round(parseFloat(n) * 255) : parseFloat(n)).toString(16).padStart(2, '0').replace('NaN', '')).join('')}`;
1029
+
1030
+ /**
1031
+ * Resolves a CSS colour keyword (e.g. "red", "cornflowerblue") to a hex
1032
+ * colour string by temporarily creating a DOM element and reading its
1033
+ * computed colour value.
1034
+ * @param {string} colorKeyword - Any valid CSS colour keyword or value.
1035
+ * @returns {string} Hex colour string.
1036
+ */
947
1037
  const colorKeywordToRGB = (colorKeyword) => {
948
1038
  let el = document.createElement('div');
949
1039
  el.style.color = colorKeyword;
@@ -955,6 +1045,13 @@ const colorKeywordToRGB = (colorKeyword) => {
955
1045
 
956
1046
  var rclk = {};
957
1047
  var hiderightclick = false;
1048
+ /**
1049
+ * Reads the right-click popup input field and creates a new marker at
1050
+ * the last right-clicked map position. Parses a comma-separated string
1051
+ * of name, icon/SIDC, layer, colour, and heading. Sends the new point
1052
+ * to the server via WebSocket.
1053
+ * @returns {void}
1054
+ */
958
1055
  var addThing = function() {
959
1056
  var thing = document.getElementById('rinput').value;
960
1057
  map.closePopup();
@@ -980,7 +1077,7 @@ var addThing = function() {
980
1077
  ws.send(JSON.stringify(d));
981
1078
  delete d.action;
982
1079
  setMarker(d);
983
- map.addLayer(layers[lay]);
1080
+ if (layers[lay]) { map.addLayer(layers[lay]); }
984
1081
  }
985
1082
 
986
1083
  var form = {};
@@ -996,7 +1093,6 @@ var feedback = function(n = "map",v,a = "feedback",c) {
996
1093
  dataToSend.lon = rclk.lng;
997
1094
  }
998
1095
  ws.send(JSON.stringify(dataToSend));
999
-
1000
1096
  if (c === true) { map.closePopup(); }
1001
1097
  }
1002
1098
 
@@ -1004,11 +1100,22 @@ map.on('click', function(e) {
1004
1100
  ws.send(JSON.stringify({action:"click", lat:e.latlng.lat.toFixed(5), lon:e.latlng.lng.toFixed(5)}));
1005
1101
  });
1006
1102
 
1103
+ map.on('popupopen', function(e) {
1104
+ const mp = e.popup._source;
1105
+ const m = allData[mp["name"]];
1106
+ m.popped = true;
1107
+ m.action = "openPopup";
1108
+ ws.send(JSON.stringify(m));
1109
+ delete m.action;
1110
+ //ws.send(JSON.stringify({action:"openPopup",name:mp.name,layer:mp.lay,icon:mp.icon,iconColor:mp.iconColor,SIDC:mp.SIDC,draggable:true,lat:parseFloat(mp.getLatLng().lat.toFixed(6)),lon:parseFloat(mp.getLatLng().lng.toFixed(6))}));
1111
+ });
1112
+
1007
1113
  // allow double right click to zoom out (if enabled)
1008
1114
  // single right click opens a message window that adds a marker
1009
1115
  var rclicked = false;
1010
1116
  var rtout = null;
1011
1117
 
1118
+
1012
1119
  map.on('contextmenu', function(e) {
1013
1120
  if (rclicked) {
1014
1121
  rclicked = false;
@@ -1047,6 +1154,16 @@ map.on('contextmenu', function(e) {
1047
1154
  //var layercontrol = L.control.selectLayers(basemaps, overlays).addTo(map);
1048
1155
  layercontrol = L.control.layers(basemaps, overlays);
1049
1156
 
1157
+ /**
1158
+ * Adds the base tile map layers to the map based on a list of layer codes.
1159
+ * Handles both online tile layers (OSM variants, Esri, etc.) and offline
1160
+ * PMTiles layers. Initialises the layer control and selects the first
1161
+ * available layer.
1162
+ * @param {string} maplist - Space- or comma-separated string of layer codes
1163
+ * (e.g. "OSMG OSMC EsriS").
1164
+ * @param {string} [first] - Name of the layer to activate first.
1165
+ * @returns {void}
1166
+ */
1050
1167
  // Add all the base layer maps if we are online.
1051
1168
  var addBaseMaps = function(maplist,first) {
1052
1169
  // console.log("MAPS",first,maplist)
@@ -1062,7 +1179,7 @@ var addBaseMaps = function(maplist,first) {
1062
1179
  var osmAttrib='Map data © OpenStreetMap contributors';
1063
1180
 
1064
1181
  if (maplist.indexOf("MB3d")!==-1) { // handle the case of 3d by redirecting to that page instead.
1065
- window.location.href("index3d.html");
1182
+ window.location.href = "index3d.html";
1066
1183
  }
1067
1184
  if (maplist.indexOf("OSMG")!==-1) {
1068
1185
  basemaps[layerlookup["OSMG"]] = new L.TileLayer.Grayscale(osmUrl, {
@@ -1215,6 +1332,14 @@ var addBaseMaps = function(maplist,first) {
1215
1332
  }
1216
1333
  }
1217
1334
 
1335
+ /**
1336
+ * Adds optional overlay layers to the map based on a list of overlay codes.
1337
+ * Handles countries outline (CO), day/night terminator (DN), rainfall radar (RA),
1338
+ * OSM Buildings (BU), railways, heatmap, drawing tools, minimap, side-by-side
1339
+ * comparison, and more.
1340
+ * @param {string} overlist - Space- or comma-separated string of overlay codes.
1341
+ * @returns {void}
1342
+ */
1218
1343
  // Now add the overlays
1219
1344
  var addOverlays = function(overlist) {
1220
1345
  //console.log("OVERLAYS",overlist)
@@ -1255,7 +1380,7 @@ var addOverlays = function(overlist) {
1255
1380
  }
1256
1381
 
1257
1382
  var shape;
1258
- map.on("pm:create", (e) => {
1383
+ map.on('pm:create', (e) => {
1259
1384
  drawCount = drawCount + 1;
1260
1385
  var name = e.shape + drawCount;
1261
1386
 
@@ -1301,7 +1426,7 @@ var addOverlays = function(overlist) {
1301
1426
  rightmenuMarker = L.popup({offset:[0,-12]}).setContent(drawcontextmenu.replace(/\${name}/g,name).replace(/\${.*?}/g,'') || "<input type='text' autofocus value='"+name+"' id='dinput' placeholder='name (,icon, layer)'/><br/><button onclick='editPoly(\""+name+"\");'>Edit points</button><button onclick='editPoly(\""+name+"\",\"drag\");'>Drag</button><button onclick='editPoly(\""+name+"\",\"rot\");'>Rotate</button><button onclick='delMarker(\""+name+"\",true);'>Delete</button><button onclick='sendRoute(\""+name+"\");'>Route</button><button onclick='sendDrawing(\""+name+"\");'>OK</button>");
1302
1427
  }
1303
1428
  rightmenuMarker.setLatLng(cent);
1304
- setTimeout(function() {map.openPopup(rightmenuMarker).replace(/\${name}/g,name)},25);
1429
+ setTimeout(function() {map.openPopup(rightmenuMarker)},25);
1305
1430
  });
1306
1431
 
1307
1432
  sendDrawing = function(n,v,a) {
@@ -1515,16 +1640,6 @@ var coords = L.control.mouseCoordinate({position:"bottomleft"});
1515
1640
  // Add an optional legend
1516
1641
  var legend = L.control({position:"bottomleft"});
1517
1642
 
1518
- // Add the dialog box for messages
1519
- // var dialogue = L.control.dialog({initOpen:false, size:[600,400], anchor:[50,150]}).addTo(map);
1520
- // dialogue.freeze();
1521
-
1522
- var doDialog = function(d) {
1523
- //console.log("DIALOGUE",d);
1524
- dialogue.setContent(d);
1525
- dialogue.open();
1526
- }
1527
-
1528
1643
  var helpText = '<h3>Node-RED - Map all the things</h3><br/>';
1529
1644
  helpText += '<p><i class="fa fa-search fa-lg fa-fw"></i> <b>Search</b> - You may enter a name, or partial name, or icon name of an object to search for.';
1530
1645
  helpText += 'The map will then jump to centre on each of the results in turn. If nothing is found locally it will try to';
@@ -1542,7 +1657,31 @@ helpText += 'While active it also restricts the "auto pan" and "search" to withi
1542
1657
  helpText += '<p><i class="fa fa-globe fa-lg fa-fw"></i> <b>Heatmap all layers</b> - When selected';
1543
1658
  helpText += 'all layers whether hidden or not will contribute to the heatmap.';
1544
1659
  helpText += 'The default is that only visible layers add to the heatmap.</p>';
1660
+ helpText += '<button type="button" id="closeDialog">Close help</button>';
1661
+
1662
+ var dialog;
1663
+ /**
1664
+ * Displays the help dialog modal. Populates the dialog element with the
1665
+ * provided dialog content HTML string and wires up the close button.
1666
+ * @param {string} d - dialog content.
1667
+ * @returns {void}
1668
+ */
1669
+ var doDialog = function(d) {
1670
+ //console.log("DIALOGUE",d);
1671
+ dialog = document.getElementById("helpDialog");
1672
+ dialog.innerHTML = d;
1673
+ const closeButton = document.getElementById("closeDialog");
1674
+ closeButton.addEventListener("click", () => { dialog.close(); });
1675
+ dialog.showModal();
1676
+ }
1545
1677
 
1678
+ /**
1679
+ * Deletes a named marker or shape from the map and all internal data stores.
1680
+ * Optionally notifies the server via WebSocket that the item was deleted.
1681
+ * @param {string} dname - Name of the marker or shape to delete.
1682
+ * @param {boolean} [note=false] - If true, sends a delete action to the server.
1683
+ * @returns {void}
1684
+ */
1546
1685
  // Delete a marker or shape (and notify websocket)
1547
1686
  var delMarker = function(dname,note) {
1548
1687
  if (note) { map.closePopup(); }
@@ -1572,6 +1711,15 @@ var delMarker = function(dname,note) {
1572
1711
  }
1573
1712
  }
1574
1713
 
1714
+ /**
1715
+ * Enables editing (move, rotate, or drag) of a named polygon or shape
1716
+ * via Leaflet-Geoman. On double-click, disables editing and sends the
1717
+ * updated shape geometry to the server.
1718
+ * @param {string} pname - Name of the polygon/shape to edit.
1719
+ * @param {string} [fun] - Edit mode: "rot" for rotate, "drag" for drag,
1720
+ * or omitted for vertex editing.
1721
+ * @returns {void}
1722
+ */
1575
1723
  var editPoly = function(pname,fun) {
1576
1724
  map.closePopup();
1577
1725
  if (fun === "rot") { polygons[pname].pm.enableRotate(); }
@@ -1597,6 +1745,17 @@ var editPoly = function(pname,fun) {
1597
1745
  })
1598
1746
  }
1599
1747
 
1748
+ /**
1749
+ * Creates a Leaflet FeatureGroup of semi-circular range rings centred on
1750
+ * a given latlng, used to indicate sensor field-of-view or range arcs.
1751
+ * @param {L.LatLng} latlng - Centre point for the rings.
1752
+ * @param {Object} [options] - Options object.
1753
+ * @param {number|number[]} [options.ranges=[250,500,750,1000]] - Ring radius/radii in metres.
1754
+ * @param {number} [options.pan=0] - Bearing direction in degrees.
1755
+ * @param {number} [options.fov=60] - Field of view angle in degrees.
1756
+ * @param {string} [options.color='#aaaa00'] - Ring colour.
1757
+ * @returns {L.FeatureGroup} Feature group containing the range ring semi-circles.
1758
+ */
1600
1759
  var rangerings = function(latlng, options) {
1601
1760
  options = L.extend({
1602
1761
  ranges: [250,500,750,1000],
@@ -1626,6 +1785,28 @@ var rangerings = function(latlng, options) {
1626
1785
  // else { return str; }
1627
1786
  // };
1628
1787
 
1788
+ /**
1789
+ * The primary function for adding or updating a marker, shape, or overlay
1790
+ * on the map. Handles points, polylines, polygons, circles, images, arcs,
1791
+ * NATO MilSymbol (SIDC) icons, FontAwesome icons, and custom icon URLs.
1792
+ * Also manages TTL-based auto-deletion, heatmap contribution, popup binding,
1793
+ * leader lines, and layer assignment.
1794
+ * @param {Object} data - Marker/shape descriptor object. Key properties:
1795
+ * @param {string} data.name - Unique identifier for the marker.
1796
+ * @param {number} [data.lat] - Latitude.
1797
+ * @param {number} [data.lon] - Longitude.
1798
+ * @param {string} [data.layer] - Layer name to add the marker to.
1799
+ * @param {string} [data.icon] - FontAwesome icon name or special value.
1800
+ * @param {string} [data.SIDC] - NATO MilSymbol SIDC code.
1801
+ * @param {string} [data.iconColor] - Colour for the icon.
1802
+ * @param {boolean} [data.deleted] - If true, removes the marker from the map.
1803
+ * @param {number} [data.ttl] - Time-to-live in seconds (0 = permanent).
1804
+ * @param {Array} [data.area] - Array of latlng objects for a polygon.
1805
+ * @param {Array} [data.line] - Array of latlng objects for a polyline.
1806
+ * @param {number} [data.radius] - Radius in metres for a circle.
1807
+ * @param {Object} [data.options] - Leaflet path options override.
1808
+ * @returns {void}
1809
+ */
1629
1810
  // the MAIN add marker or shape to map function
1630
1811
  function setMarker(data) {
1631
1812
  if (!data) { return; }
@@ -1633,8 +1814,8 @@ function setMarker(data) {
1633
1814
  m.on('click', function(e) {
1634
1815
  var fb = allData[data["name"]];
1635
1816
  fb.action = "click";
1636
- if (fb.sendOnClick ?? true)
1637
- ws.send(JSON.stringify(fb));
1817
+ if (fb.sendOnClick ?? true) { ws.send(JSON.stringify(fb)); }
1818
+ delete fb.action;
1638
1819
  });
1639
1820
  // customise right click context menu
1640
1821
  var rightcontext = "";
@@ -1685,12 +1866,12 @@ function setMarker(data) {
1685
1866
  var opt = data.options || {};
1686
1867
  opt.color = opt.color ?? data.color ?? data.lineColor ?? "#910000";
1687
1868
  opt.fillColor = opt.fillColor ?? data.fillColor ?? "#910000";
1688
- opt.stroke = opt.stroke ?? (data.hasOwnProperty("stroke")) ? data.stroke : true;
1869
+ opt.stroke = opt.stroke ?? (data.hasOwnProperty("stroke") ? data.stroke : true);
1689
1870
  opt.weight = opt.weight ?? data.weight ?? 2;
1690
1871
  opt.opacity = opt.opacity ?? data.opacity ?? 1;
1691
1872
  if (!data.SIDC) { opt.fillOpacity = opt.fillOpacity ?? data.fillOpacity ?? 0.2; }
1692
1873
  opt.clickable = (data.hasOwnProperty("clickable")) ? data.clickable : false;
1693
- opt.fill = opt.fill ?? (data.hasOwnProperty("fill")) ? data.fill : true;
1874
+ opt.fill = opt.fill ?? (data.hasOwnProperty("fill") ? data.fill : true);
1694
1875
  if (data.hasOwnProperty("dashArray")) { opt.dashArray = data.dashArray; }
1695
1876
 
1696
1877
  // Replace building
@@ -2285,6 +2466,7 @@ function setMarker(data) {
2285
2466
  fb.lon = parseFloat(marker.getLatLng().lng.toFixed(6));
2286
2467
  fb.from = oldll;
2287
2468
  ws.send(JSON.stringify(fb));
2469
+ delete fb.action;
2288
2470
  });
2289
2471
  }
2290
2472
 
@@ -2371,15 +2553,8 @@ function setMarker(data) {
2371
2553
  }
2372
2554
  delete data.weblink;
2373
2555
  }
2374
- var p;
2375
- if (data.hasOwnProperty("popped") && (data.popped === true)) {
2376
- p = true;
2377
- delete data.popped;
2378
- }
2379
- if (data.hasOwnProperty("popped") && (data.popped === false)) {
2380
- marker.closePopup();
2381
- p = false;
2382
- delete data.popped;
2556
+ if (data.hasOwnProperty("popped")) {
2557
+ marker.popped = data.popped;
2383
2558
  }
2384
2559
  // If .label then use that rather than name tooltip
2385
2560
  if (data.hasOwnProperty("label")) {
@@ -2423,7 +2598,7 @@ function setMarker(data) {
2423
2598
  if (data.hasOwnProperty("radius")) { delete data.radius; }
2424
2599
  if (data.hasOwnProperty("greatcircle")) { delete data.greatcircle; }
2425
2600
 
2426
- if (!data.hasOwnProperty("clickable") && data.clickable != false) {
2601
+ if (!data.hasOwnProperty("clickable") || data.clickable == true) {
2427
2602
  var wopt = { autoClose:false, closeButton:true, closeOnClick:false, minWidth:200 };
2428
2603
  if (words.indexOf('<video ') >=0 || words.indexOf('<img ') >=0 ) { wopt.maxWidth="640"; } // make popup wider if it has an image or video
2429
2604
  if (data?.popupOptions) { // allow user to override popup options eg to add className
@@ -2437,7 +2612,7 @@ function setMarker(data) {
2437
2612
  else {
2438
2613
  words += '<table>';
2439
2614
  for (var i in data) {
2440
- if ((i != "name") && (i != "length") && (i != "clickable")) {
2615
+ if ((i != "name") && (i != "length") && (i != "clickable") && (i != "popped")) {
2441
2616
  if (typeof data[i] === "object") {
2442
2617
  words += '<tr><td valign="top">'+ i +'</td><td>' + JSON.stringify(data[i]) + '</td></tr>';
2443
2618
  }
@@ -2456,6 +2631,13 @@ function setMarker(data) {
2456
2631
  if (longline > 100) { wopt.minWidth="640"; } // make popup wider if it has a long line
2457
2632
  marker.bindPopup(words, wopt);
2458
2633
  marker._popup.dname = data["name"];
2634
+ marker.getPopup().on('remove', function() {
2635
+ const m = allData[marker["name"]];
2636
+ m.popped = false;
2637
+ m.action = "closePopup";
2638
+ ws.send(JSON.stringify(m));
2639
+ delete m.action;
2640
+ });
2459
2641
  }
2460
2642
 
2461
2643
  if (data.hasOwnProperty("clickURL")) {
@@ -2473,6 +2655,7 @@ function setMarker(data) {
2473
2655
  // var fb = allData[marker.name];
2474
2656
  // fb.action = "click";
2475
2657
  // ws.send(JSON.stringify(fb));
2658
+ // delete fb.action;
2476
2659
  // });
2477
2660
  if (heat && ((data.addtoheatmap != false) || (!data.hasOwnProperty("addtoheatmap")))) { // Added to give ability to control if points from active layer contribute to heatmap
2478
2661
  if (heatAll || map.hasLayer(layers[lay])) { heat.addLatLng(lli); }
@@ -2546,10 +2729,10 @@ function setMarker(data) {
2546
2729
  }
2547
2730
  }
2548
2731
  if (panit === true) { map.setView(ll,map.getZoom()); }
2549
- if (p === true) { marker.openPopup(); }
2732
+ if (marker.popped === true) { marker.openPopup(); }
2550
2733
  }
2551
2734
 
2552
- var custIco = function() {
2735
+ var custIco = function(cmd) {
2553
2736
  var col = cmd.map.iconColor ?? "#910000";
2554
2737
  var myMarker = L.VectorMarkers.icon({
2555
2738
  icon: "circle",
@@ -2578,6 +2761,22 @@ var custIco = function() {
2578
2761
  return customLayer;
2579
2762
  }
2580
2763
 
2764
+ /**
2765
+ * Processes incoming command objects from the server to control the map
2766
+ * remotely. Handles a wide range of commands including: map/overlay
2767
+ * initialisation, pan/zoom/rotation, layer visibility, marker age limits,
2768
+ * clustering, UI button injection, heatmap updates, user location tracking,
2769
+ * legend display, custom CSS/JS loading, and arbitrary eval of custom commands.
2770
+ * @param {Object} cmd - Command object received from the server.
2771
+ * @param {boolean} [cmd.init] - If true, triggers map/overlay initialisation.
2772
+ * @param {string} [cmd.layer] - Base layer to activate.
2773
+ * @param {number} [cmd.lat] - Latitude to pan to.
2774
+ * @param {number} [cmd.lon] - Longitude to pan to.
2775
+ * @param {number} [cmd.zoom] - Zoom level to set.
2776
+ * @param {string|string[]} [cmd.clearlayer] - Layer name(s) to clear.
2777
+ * @param {Object} [cmd.map] - Overlay/basemap descriptor for adding layers.
2778
+ * @returns {void}
2779
+ */
2581
2780
  // handle any incoming COMMANDS to control the map remotely
2582
2781
  function doCommand(cmd) {
2583
2782
  // console.log("COMMAND",cmd);
@@ -2586,7 +2785,7 @@ function doCommand(cmd) {
2586
2785
  addBaseMaps(cmd.maplist,cmd.layer);
2587
2786
  }
2588
2787
  if (cmd.init && cmd.hasOwnProperty("overlist")) {
2589
- overlays = [];
2788
+ overlays = {};
2590
2789
  addOverlays(cmd.overlist);
2591
2790
  }
2592
2791
  if (cmd.hasOwnProperty("toptitle")) {
@@ -2606,6 +2805,20 @@ function doCommand(cmd) {
2606
2805
  if (!Array.isArray(clr)) { clr = [ clr ]; }
2607
2806
  clr.forEach((el) => doTidyUp(el));
2608
2807
  }
2808
+ if (cmd.hasOwnProperty("showdialog")) {
2809
+ if (typeof cmd.showdialog === "string") {
2810
+ if (cmd.showdialog === "") {
2811
+ if (dialog) { dialog.close(); }
2812
+ }
2813
+ else if (!/script/i.test(cmd.showdialog)) {
2814
+ let dia = cmd.showdialog;
2815
+ if (!/id=\"closeDialog\"/.test(cmd.showdialog)) {
2816
+ dia += '<p><button type="button" id="closeDialog">Close</button></p>';
2817
+ }
2818
+ doDialog(dia);
2819
+ }
2820
+ }
2821
+ }
2609
2822
  if (cmd.hasOwnProperty("panit")) {
2610
2823
  if (cmd.panit == true || cmd.panit === "true") { panit = true; }
2611
2824
  else { panit = false; }
@@ -2949,7 +3162,7 @@ function doCommand(cmd) {
2949
3162
  var sidc = feature.properties.symbol.toUpperCase().replace("APP6A:",'')//.substr(0,13);
2950
3163
  var country;
2951
3164
  if (sidc.length > 12) { country = sidc.substr(12).replace(/-/g,''); sidc = sidc.substr(0,12); }
2952
- myMarker = new ms.Symbol( sidc, {
3165
+ var myMarker = new ms.Symbol( sidc, {
2953
3166
  uniqueDesignation:feature.properties.label,
2954
3167
  country:country,
2955
3168
  direction:feature.properties.course,
@@ -3073,7 +3286,7 @@ function doCommand(cmd) {
3073
3286
  // var json = window.toGeoJSON.gpx(gp);
3074
3287
  // console.log("j",json)
3075
3288
  // doGeojson(json.features[0].properties.name,json,json.features[0].properties.type) // name,geojson,layer,options
3076
- overlays[cmd.map.overlay] = omnivore.gpx.parse(cmd.map.gpx, null, custIco());
3289
+ overlays[cmd.map.overlay] = omnivore.gpx.parse(cmd.map.gpx, null, custIco(cmd));
3077
3290
  if (!existsalready) {
3078
3291
  layercontrol.addOverlay(overlays[cmd.map.overlay],cmd.map.overlay);
3079
3292
  }
@@ -3291,6 +3504,18 @@ function doCommand(cmd) {
3291
3504
  }
3292
3505
  }
3293
3506
 
3507
+ /**
3508
+ * Renders a GeoJSON Feature or FeatureCollection on the map as a named
3509
+ * overlay layer. Supports SIDC symbols, FontAwesome icons, and vector
3510
+ * markers for point features, and applies style properties from feature
3511
+ * properties or the options argument for polygon/line features.
3512
+ * @param {string} n - Name for the overlay layer.
3513
+ * @param {Object} g - GeoJSON Feature or FeatureCollection object.
3514
+ * @param {string} [l] - Layer name override (defaults to g.name or "unknown").
3515
+ * @param {Object} [o] - Leaflet path style options to apply to features.
3516
+ * @param {string} [i] - Default icon name for point features without an icon property.
3517
+ * @returns {void}
3518
+ */
3294
3519
  // handle any incoming GEOJSON directly - may style badly
3295
3520
  function doGeojson(n,g,l,o,i) { // name, geojson, layer, options, icon
3296
3521
  var lay = l ?? g.name ?? "unknown";
@@ -3443,6 +3668,14 @@ function doGeojson(n,g,l,o,i) { // name, geojson, layer, options, icon
3443
3668
  map.addLayer(layers[lay]);
3444
3669
  }
3445
3670
 
3671
+ /**
3672
+ * Handles a TAK/CoT event object (from the tak-ingest or fastxml Node-RED nodes)
3673
+ * and converts it to a worldmap marker, then calls setMarker().
3674
+ * Processes atom types (a-) for personnel/vehicle tracks and b- types for
3675
+ * spot markers, position indicators, emergency alerts, and geofence events.
3676
+ * @param {Object} p - Parsed CoT event object with point, detail, type, uid fields.
3677
+ * @returns {void}
3678
+ */
3446
3679
  // handle TAK messages from TAK server tcp - XML->JSON
3447
3680
  function doTAKjson(p) {
3448
3681
  //console.log("TAK event",p);
@@ -3472,7 +3705,7 @@ function doTAKjson(p) {
3472
3705
  }
3473
3706
  d.type = p.type;
3474
3707
  d.remarks = p.detail?.remarks
3475
- if (p.detail?.remarks && p.detail.remarks.hasOwnProperty["#text"]) {
3708
+ if (p.detail?.remarks && p.detail.remarks.hasOwnProperty("#text")) {
3476
3709
  d.remarks = p.detail.remarks["#text"];
3477
3710
  }
3478
3711
  d.uid = p.uid;
@@ -3499,6 +3732,13 @@ function doTAKjson(p) {
3499
3732
  }
3500
3733
  }
3501
3734
 
3735
+ /**
3736
+ * Handles a TAK Multicast event object decoded from Protobuf via the
3737
+ * tak-ingest Node-RED node, converting it to a worldmap marker.
3738
+ * Only processes atom types (a-) for tracks.
3739
+ * @param {Object} p - Parsed TAK multicast CoT object with lat, lon, type, uid fields.
3740
+ * @returns {void}
3741
+ */
3502
3742
  // handle TAK messages from TAK Multicast - Protobuf->JSON
3503
3743
  function doTAKMCjson(p) {
3504
3744
  // console.log("TAK Multicast event",p);
@@ -3542,6 +3782,12 @@ function doTAKMCjson(p) {
3542
3782
  }
3543
3783
  }
3544
3784
 
3785
+ /**
3786
+ * Converts a 32-bit ARGB integer colour value (as used in CoT/TAK messages)
3787
+ * to a CSS hex colour string, discarding the alpha channel.
3788
+ * @param {number} color - 32-bit integer ARGB colour value.
3789
+ * @returns {string} CSS hex colour string (e.g. "#ff0000").
3790
+ */
3545
3791
  function convertCOTtoCIFColour(color) {
3546
3792
  // const c = parseInt(color);
3547
3793
  const arr = new ArrayBuffer(4);
@@ -3551,12 +3797,24 @@ function convertCOTtoCIFColour(color) {
3551
3797
  return "#" + b2h.substr(2);
3552
3798
  }
3553
3799
 
3800
+ /**
3801
+ * Converts an ArrayBuffer to a lowercase hex string.
3802
+ * @param {ArrayBuffer} buffer - The buffer to convert.
3803
+ * @returns {string} Hex-encoded string representation of the buffer bytes.
3804
+ */
3554
3805
  function buf2hex(buffer) { // buffer is an ArrayBuffer
3555
3806
  return [...new Uint8Array(buffer)]
3556
3807
  .map(x => x.toString(16).padStart(2, '0'))
3557
3808
  .join('');
3558
3809
  }
3559
3810
 
3811
+ /**
3812
+ * Generates an array of intermediate range ring values for a given maximum
3813
+ * range, using step sizes of 100, 1000, or 10000 depending on the magnitude.
3814
+ * Returns the value directly if it is 100 or less.
3815
+ * @param {number} r - Maximum range in metres.
3816
+ * @returns {number|number[]} Single value if r <= 100, otherwise an array of ring radii.
3817
+ */
3560
3818
  function createRings(r) {
3561
3819
  if (r <= 100) { return r; }
3562
3820
  var rings = [];
@@ -3570,6 +3828,15 @@ function createRings(r) {
3570
3828
  return rings;
3571
3829
  }
3572
3830
 
3831
+ /**
3832
+ * Maps a CoT type string to an appropriate SIDC code or icon on the marker
3833
+ * data object. Handles atom types (a-) via milsymbol conversion and a
3834
+ * selection of b- types (spot markers, position indicators, emergency alerts,
3835
+ * geofence events, waypoints). Modifies the data object in place.
3836
+ * @param {Object} d - Worldmap marker data object to be mutated.
3837
+ * @param {Object} p - Raw CoT/TAK event object containing type, detail, etc.
3838
+ * @returns {Object} The mutated data object.
3839
+ */
3573
3840
  function handleCoTtypes(d,p) {
3574
3841
  if (d.type.indexOf('a-') === 0) { // handle a- types
3575
3842
  if (p?.detail?.__milsym?.id) {