diff --git a/package-lock.json b/package-lock.json index 2a983bc6..571c41bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,10 +33,10 @@ "leaflet-minimap": "^3.6.1", "leaflet-routing-machine": "^3.2.12", "leaflet-svg-shape-markers": "^1.3.0", + "leaflet.markercluster": "^1.5.3", "leaflet.measure": "^1.0.0", "leaflet.polylinemeasure": "^3.0.0", "leaflet.vectorgrid": "^1.3.0", - "markerwithlabel": "^2.0.2", "moment": "^2.29.1", "nunjucks": "^3.2.4", "remove": "^0.1.5", @@ -67,7 +67,7 @@ "style-loader": "^3.3.1", "typescript": "^4.5.4", "underscore": "^1.13.1", - "webpack": "^5.98.0", + "webpack": "^5.105.0", "webpack-cli": "^4.9.1", "webpack-extension-manifest-plugin": "^0.8.0", "webpack-merge": "^5.8.0", @@ -2075,9 +2075,9 @@ } }, "node_modules/@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "dev": true, "license": "MIT", "dependencies": { @@ -2309,9 +2309,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, @@ -2352,10 +2352,11 @@ "dev": true }, "node_modules/@types/json-schema": { - "version": "7.0.12", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz", - "integrity": "sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==", - "dev": true + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" }, "node_modules/@types/mapbox__point-geometry": { "version": "0.1.4", @@ -2806,9 +2807,9 @@ "integrity": "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==" }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", "bin": { @@ -2818,6 +2819,19 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -3131,6 +3145,16 @@ } ] }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, "node_modules/big-integer": { "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", @@ -3238,9 +3262,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", - "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, "funding": [ { @@ -3258,10 +3282,11 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001688", - "electron-to-chromium": "^1.5.73", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.1" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -3334,9 +3359,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001707", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001707.tgz", - "integrity": "sha512-3qtRjw/HQSMlDWf+X79N206fepf4SOOU6SQLMaq/0KkZLmSjPxAkBOQQ+FxbHKfHmYLZFfdWsO3KA90ceHPSnw==", + "version": "1.0.30001769", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", + "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", "dev": true, "funding": [ { @@ -4149,9 +4174,9 @@ "dev": true }, "node_modules/electron-to-chromium": { - "version": "1.5.129", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.129.tgz", - "integrity": "sha512-JlXUemX4s0+9f8mLqib/bHH8gOHf5elKS6KeWG3sk3xozb/JTq/RLXIv8OKUWiK4Ah00Wm88EFj5PYkFr4RUPA==", + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", "dev": true, "license": "ISC" }, @@ -4170,14 +4195,14 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.18.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", - "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" + "tapable": "^2.3.0" }, "engines": { "node": ">=10.13.0" @@ -4196,10 +4221,11 @@ } }, "node_modules/es-module-lexer": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.3.0.tgz", - "integrity": "sha512-vZK7T0N2CBmBOixhmjdqx2gWVbFZ4DXZ/NyRMZVlJXPa7CyFS+/a4QQsDGDQy9ZfEzxFuNEsMLeQJnKP2p5/JA==", - "dev": true + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" }, "node_modules/escalade": { "version": "3.2.0", @@ -5693,6 +5719,14 @@ "resolved": "https://registry.npmjs.org/leaflet-svg-shape-markers/-/leaflet-svg-shape-markers-1.4.0.tgz", "integrity": "sha512-vUBwso51+4ZVGcLZbhdBGxz+xrbul5jDYxool2yTKbIjAC6rvOMLjr8YBTQbLaa1LBRBQIaWUbmCafdXm17pxw==" }, + "node_modules/leaflet.markercluster": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz", + "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==", + "peerDependencies": { + "leaflet": "^1.3.1" + } + }, "node_modules/leaflet.measure": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/leaflet.measure/-/leaflet.measure-1.0.0.tgz", @@ -5924,12 +5958,17 @@ } }, "node_modules/loader-runner": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", + "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/locate-path": { @@ -6186,11 +6225,6 @@ "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" } }, - "node_modules/markerwithlabel": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/markerwithlabel/-/markerwithlabel-2.0.2.tgz", - "integrity": "sha512-C/cbm1A0h/u54gwHk5ZJNdUU3V3+1BbCpRPMsMyFA7vF4yL+aB4rWpxACz29TpQ+cTg6/iQroExh0PMSRGtQFg==" - }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -6250,9 +6284,10 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", + "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==", + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -6336,9 +6371,9 @@ "dev": true }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "dev": true, "license": "MIT" }, @@ -6905,9 +6940,10 @@ } }, "node_modules/readdir-glob/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.7.tgz", + "integrity": "sha512-FjiwU9HaHW6YB3H4a1sFudnv93lvydNjz2lmyUXR6IwKhGI+bgL3SOZrBGn6kvvX2pJvhEkGSGjyTHN47O4rqA==", + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -7254,9 +7290,9 @@ } }, "node_modules/schema-utils": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz", - "integrity": "sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", "dependencies": { @@ -7633,13 +7669,17 @@ } }, "node_modules/tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", "dev": true, "license": "MIT", "engines": { "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/tar-stream": { @@ -7658,14 +7698,14 @@ } }, "node_modules/terser": { - "version": "5.39.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz", - "integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==", + "version": "5.46.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", + "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", + "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, @@ -7677,9 +7717,9 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", - "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", + "version": "5.3.16", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", + "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", "dev": true, "license": "MIT", "dependencies": { @@ -7980,9 +8020,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -8053,9 +8093,9 @@ } }, "node_modules/watchpack": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", - "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", "dev": true, "license": "MIT", "dependencies": { @@ -8067,35 +8107,37 @@ } }, "node_modules/webpack": { - "version": "5.98.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.98.0.tgz", - "integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==", + "version": "5.105.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", + "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", "dev": true, "license": "MIT", "dependencies": { "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.6", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.14.0", - "browserslist": "^4.24.0", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.1", - "es-module-lexer": "^1.2.1", + "enhanced-resolve": "^5.19.0", + "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", + "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", - "schema-utils": "^4.3.0", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.11", - "watchpack": "^2.4.1", - "webpack-sources": "^3.2.3" + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.16", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.3" }, "bin": { "webpack": "bin/webpack.js" @@ -8215,10 +8257,11 @@ } }, "node_modules/webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", + "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", "dev": true, + "license": "MIT", "engines": { "node": ">=10.13.0" } diff --git a/package.json b/package.json index deab7c43..fe3d4730 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "leaflet-minimap": "^3.6.1", "leaflet-routing-machine": "^3.2.12", "leaflet-svg-shape-markers": "^1.3.0", + "leaflet.markercluster": "^1.5.3", "leaflet.measure": "^1.0.0", "leaflet.polylinemeasure": "^3.0.0", "leaflet.vectorgrid": "^1.3.0", @@ -90,7 +91,7 @@ "style-loader": "^3.3.1", "typescript": "^4.5.4", "underscore": "^1.13.1", - "webpack": "^5.98.0", + "webpack": "^5.105.0", "webpack-cli": "^4.9.1", "webpack-extension-manifest-plugin": "^0.8.0", "webpack-merge": "^5.8.0", diff --git a/src/contentscripts/identity.js b/src/contentscripts/identity.js deleted file mode 100644 index 0214a9b7..00000000 --- a/src/contentscripts/identity.js +++ /dev/null @@ -1,25 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -var DOM = require('jsx-dom-factory').default; -var encodedJson = document.getElementById("modelJson").textContent.replace(/"/g, '"'); -var model = JSON.parse(encodedJson); - - -if (model.clientName == "beacon" || model.clientName == "beacon Train" || model.clientName == "beacon Preview")//only run if we are logging into a beacon page. -{ - var $ = require('jquery/dist/jquery.min'); - require('../pages/lib/shared_chrome_code.js'); // side-effect - - let version = 'v'+chrome.manifest.version_name+' '+(chrome.manifest.name.includes("Development") ? "Development" : "Production") - $('body').append( - -
- Running Lighthouse extension {version} edition. -
Designed & developed by volunteers of the NSW SES. Lighthouse is distributed under an MIT Licence.
-
-
- ); - - - $('#lhbg').css({ 'z-index':'-100', 'background-image': 'url('+chrome.runtime.getURL("icons/lhbackdrop_dark.png")+')','background-repeat': 'no-repeat', 'background-size': 'auto 70%','background-position': 'bottom right','width': '100%','height':'100%','position':'absolute','top': '0px'}) - -} diff --git a/src/pages/tasking/bindings/sortableArray.js b/src/pages/tasking/bindings/sortableArray.js index 0082146c..942d6c45 100644 --- a/src/pages/tasking/bindings/sortableArray.js +++ b/src/pages/tasking/bindings/sortableArray.js @@ -17,8 +17,21 @@ export function installSortableArrayBindings() { // allow dragging from handle only if provided const getHandle = () => (handleSel ? (el.querySelector(handleSel) || el) : el); - // mark draggable - el.setAttribute("draggable", "true"); + // Track whether the pointer started on the drag handle. + // In dragstart, e.target is always the draggable element (the
  • ), + // not the child the user clicked, so we must track it via pointerdown. + let _pointerOnHandle = !handleSel; + + if (handleSel) { + el.setAttribute("draggable", "false"); + el.addEventListener("pointerdown", (e) => { + const h = getHandle(); + _pointerOnHandle = !!(h && (h === e.target || h.contains(e.target))); + el.setAttribute("draggable", String(_pointerOnHandle)); + }); + } else { + el.setAttribute("draggable", "true"); + } // KSB-safe: item is from bindingContext.$data const getIndex = () => { @@ -28,9 +41,7 @@ export function installSortableArrayBindings() { }; el.addEventListener("dragstart", (e) => { - const h = getHandle(); - // if handle selector is used, only allow drag when starting on/within handle - if (handleSel && e.target && !h.contains(e.target)) { + if (handleSel && !_pointerOnHandle) { e.preventDefault(); return; } diff --git a/src/pages/tasking/components/alerts.js b/src/pages/tasking/components/alerts.js index 3fcd424e..9c90ead1 100644 --- a/src/pages/tasking/components/alerts.js +++ b/src/pages/tasking/components/alerts.js @@ -252,7 +252,11 @@ function buildDefaultRules(vm) { count: unackedNotifications.length, onClick: (id) => { const found = jobs.find(j => jobKey(j) === id); - found?.focusAndExpandInList(); + if (found) { + found.focusAndExpandInList(); + // Also open the timeline modal to show the notifications + found.attachAndFillTimelineModal?.(); + } } }, { diff --git a/src/pages/tasking/components/job_icon.js b/src/pages/tasking/components/job_icon.js index 8fdb6917..985a4002 100644 --- a/src/pages/tasking/components/job_icon.js +++ b/src/pages/tasking/components/job_icon.js @@ -144,6 +144,80 @@ export function styleForJob(job) { // tweak strokeWidth if you need stronger outlines } +/** + * Build an SVG outline that matches the marker shape, used as the pulse-ring + * overlay. `w`/`h` are the pixel dimensions of the ring's L.divIcon box. + */ +export function buildPulseRingSvg(shape, w, h) { + const cx = w / 2, cy = h / 2; + const sw = 2; + const r = Math.min(w, h) / 2 - sw; + + let outline; + switch (shape) { + case "circle": + outline = ``; + break; + case "square": { + outline = ``; + break; + } + case "diamond": + outline = ``; + break; + case "triangle": { + const th = r * Math.sqrt(3); + outline = ``; + break; + } + case "hex": { + const pts = []; + for (let i = 0; i < 6; i++) { + const a = (Math.PI / 3) * i - Math.PI / 6; + pts.push(`${cx + r * Math.cos(a)},${cy + r * Math.sin(a)}`); + } + outline = ``; + break; + } + case "star": { + const spikes = 5, step = Math.PI / spikes, pts = []; + for (let i = 0; i < 2 * spikes; i++) { + const len = i % 2 === 0 ? r : r / 2.5; + const a = i * step - Math.PI / 2; + pts.push(`${cx + Math.cos(a) * len},${cy + Math.sin(a) * len}`); + } + outline = ``; + break; + } + case "pentagon": { + const pts = []; + for (let i = 0; i < 5; i++) { + const a = (2 * Math.PI / 5) * i - Math.PI / 2; + pts.push(`${cx + r * Math.cos(a)},${cy + r * Math.sin(a)}`); + } + outline = ``; + break; + } + case "teardrop": + outline = ``; + break; + case "cross": { + const s = r * 0.35; + outline = ``; + break; + } + default: + outline = ``; + } + + return ` + + ${outline} + + `; +} + diff --git a/src/pages/tasking/components/job_popup.js b/src/pages/tasking/components/job_popup.js index 716a2833..e36f9ab7 100644 --- a/src/pages/tasking/components/job_popup.js +++ b/src/pages/tasking/components/job_popup.js @@ -6,7 +6,7 @@ export function buildJobPopupKO() { class="fw-bold text-center" style="color:white;background: black"> - +
    + + +
    + + Unacknowledged ICEMS notifications +
    +
    diff --git a/src/pages/tasking/components/legend.js b/src/pages/tasking/components/legend.js index b75e83bb..6911ddc4 100644 --- a/src/pages/tasking/components/legend.js +++ b/src/pages/tasking/components/legend.js @@ -2,7 +2,7 @@ var L = require('leaflet'); // Legend control (collapsible) export const LegendControl = L.Control.extend({ - options: { position: "bottomleft", collapsed: false, persist: true }, + options: { position: "bottomleft", collapsed: true, persist: true }, onAdd() { const div = L.DomUtil.create("div", "legend-container leaflet-bar"); @@ -26,7 +26,7 @@ export const LegendControl = L.Control.extend({
    -
    Priority → Fill
    +
    Priority → Fill
    Priority
    Immediate
    @@ -36,7 +36,7 @@ export const LegendControl = L.Control.extend({
    -
    FR: Category → Fill
    +
    FR: Category → Fill
    Cat 1
    Cat 2
    @@ -48,14 +48,45 @@ export const LegendControl = L.Control.extend({
    -
    Overlays
    -
    -
    Unacknowledged incident
    +
    Overlays
    +
    +
    + + Unacknowledged Incident +
    +
    + + + + 7 + + Cluster of 7 Incidents +
    +
    + + + + + + + 5 + + Cluster Of Mixed Priorities +
    +
    + + + + 3 + + + Cluster Contains Unacked Incidents +
    -
    -
    -
    -
    Assets
    +
    + + +
    Assets
    @@ -146,12 +177,9 @@ export const LegendControl = L.Control.extend({ this._body = div.querySelector(".legend-body"); this._btn = div.querySelector(".toggle-legend"); - // initial state - const collapsed = - this.options.persist && - localStorage.getItem("legendCollapsed") === "1" - ? true - : !!this.options.collapsed; + // initial state: use stored preference if available, otherwise fall back to option default + const stored = this.options.persist ? localStorage.getItem("legendCollapsed") : null; + const collapsed = stored !== null ? stored === "1" : !!this.options.collapsed; this._setCollapsed(collapsed); // prevent map drag/zoom on click diff --git a/src/pages/tasking/main.js b/src/pages/tasking/main.js index 5222eb2e..6a1b09ac 100644 --- a/src/pages/tasking/main.js +++ b/src/pages/tasking/main.js @@ -59,7 +59,30 @@ import { registerHazardWatchWarningsLayer } from "./mapLayers/hazardwatch.js" import { registerPowerBoundariesGridLayer } from "./mapLayers/power.js"; import { registerWaterNSWBoundariesLayer, registerEPAContaminationSitesLayer } from "./mapLayers/waternsw.js"; import { registerBOMLandWarningsLayer } from "./mapLayers/bom.js"; -import { registerRainRadarLayer } from "./mapLayers/weather.js"; +import { registerRainRadarLayer } from "./mapLayers/rainviewer.js"; +import { + registerBOMRainfallLayer, + registerBOMRadarLayer, + registerBOMAllFloodLevelsLayer, + registerBOMSatTrueColorLayer, + registerBOMThunderstormTrackingLayer, + registerBOMWindLayer, + registerBOMMSLPLayer, + registerBOMLightningLayer, + registerBOMLightning24hLayer, + registerBOMTsunamiLayer, + registerBOMTropicalCycloneLayer, + registerBOMFireDangerRatingLayer, + registerBOMHeatwaveLayer, + registerBOMHazardousSurfLayer, + registerBOMCoastalHazardLayer, + registerBOMRoadWeatherLayer, + registerBOMSurfaceGustLayer, + registerBOMSurfaceTempLayer, + registerBOMFireBehaviourIndexLayer, + registerBOMHazardousWindLayer, + registerBOMFloodWarningBoundariesLayer, + registerBOMFireWeatherDistrictsLayer } from "./mapLayers/weather.js"; import { fetchHqDetailsSummary } from './utils/hqSummary.js'; @@ -81,7 +104,7 @@ var MiniMap = require('leaflet-minimap'); var esriVector = require('esri-leaflet-vector'); -import { GeoSearchControl } from 'leaflet-geosearch'; +import GeoSearchControl from 'leaflet-geosearch/lib/SearchControl'; import { AwsLambdaGeocoderProvider } from './utils/geocode.js'; @@ -96,6 +119,7 @@ import 'leaflet.polylinemeasure'; //css order reasons... load this last import '../../../styles/pages/tasking.css'; +import '../../../styles/pages/darkmode.css'; let token = ''; @@ -163,6 +187,7 @@ async function getToken() { const params = getSearchParameters(); const apiHost = params.host +const sourceUrl = params.source var ko; var myViewModel; @@ -278,7 +303,7 @@ map.createPane('pane-tippy-top-plus'); map.getPane('pane-tippy-top-plus').style. var osm2 = new L.TileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { minZoom: 0, maxZoom: 13 }); new MiniMap(osm2, { toggleDisplay: true }).addTo(map); -const legend = new LegendControl({ collapsed: false, persist: true }); +const legend = new LegendControl({ collapsed: true, persist: true }); legend.addTo(map); ResizeDividers(map) @@ -297,6 +322,26 @@ function VM() { self.jobsLoading = ko.observable(true); self.tagsLoading = ko.observable(true); + // Incidents visibility toggle + self.incidentsVisible = ko.observable(localStorage.getItem('map.incidentsVisible') !== 'false'); + self.incidentsVisible.subscribe(v => { + localStorage.setItem('map.incidentsVisible', v ? 'true' : 'false'); + + if (v) { + // Show incidents – add the appropriate layer + const targetLayer = self.mapVM.clusteringEnabled + ? self.mapVM.jobClusterGroup + : self.mapVM.unclusteredJobLayer; + if (!map.hasLayer(targetLayer)) { + targetLayer.addTo(map); + } + } else { + // Hide incidents – remove both possible layers + map.removeLayer(self.mapVM.jobClusterGroup); + map.removeLayer(self.mapVM.unclusteredJobLayer); + } + }); + self.pageIsLoading = ko.pureComputed(() => { return self.tokenLoading() || self.tagsLoading(); }); @@ -315,6 +360,12 @@ function VM() { self.trackableAssets = ko.observableArray([]); self.sectors = ko.observableArray([]); + // Shared 30s ticker used by assets for relative-time labels + self.relativeUpdateTick30s = ko.observable(0); + setInterval(() => { + self.relativeUpdateTick30s(self.relativeUpdateTick30s() + 1); + }, 1000 * 30); + self.allTags = ko.observableArray([]); ///opslog short cuts @@ -388,13 +439,15 @@ function VM() { self.countPinnedTeams = ko.pureComputed(() => { if (!self.config || !self.config.pinnedTeamIds) return 0; - return self.filteredTeams().filter(t => self.isTeamPinned(t.id())).length; - }) + const pinnedIds = new Set((self.config.pinnedTeamIds() || []).map(id => normPinId(id))); + return self.filteredTeams().filter(t => pinnedIds.has(normPinId(t.id()))).length; + }); self.countPinnedIncidents = ko.pureComputed(() => { if (!self.config || !self.config.pinnedIncidentIds) return 0; - return self.filteredJobs().filter(j => self.isIncidentPinned(j.id())).length; - }) + const pinnedIds = new Set((self.config.pinnedIncidentIds() || []).map(id => normPinId(id))); + return self.filteredJobs().filter(j => pinnedIds.has(normPinId(j.id()))).length; + }); self.toggleTeamPinned = (teamId) => { if (!self.config || !self.config.pinnedTeamIds) return false; @@ -571,8 +624,8 @@ function VM() { self.filteredJobsAgainstConfig = ko.pureComputed(() => { - const hqsFilter = self.config.incidentFilters().map(f => ({ Id: f.id })); - const sectorFilter = self.config.sectorFilters().map(s => s.id); + const hqIds = new Set((self.config.incidentFilters() || []).map(f => String(f.id))); + const sectorIds = new Set((self.config.sectorFilters() || []).map(s => String(s.id))); // If sector filtering is active, only include jobs in those sectors @@ -580,7 +633,13 @@ function VM() { const term = self.jobSearch().toLowerCase(); const allowedStatus = self.config.jobStatusFilter(); // allow-list - const incidentTypeAllowedById = self.config.allowedIncidentTypeIds(); // allow-list + const allowedStatusSet = new Set(allowedStatus || []); + const incidentTypeAllowedById = self.config.allowedIncidentTypeIds(); // allow-list (Set in ConfigVM) + const incidentTypeIterable = + incidentTypeAllowedById && typeof incidentTypeAllowedById[Symbol.iterator] === "function" + ? incidentTypeAllowedById + : []; + const incidentTypeSet = new Set(Array.from(incidentTypeIterable, id => String(id))); var start = new Date(); var end = new Date(); @@ -597,10 +656,10 @@ function VM() { return ko.utils.arrayFilter(this.jobs(), jb => { const statusName = jb.statusName(); - - - const hqMatch = hqsFilter.length === 0 || hqsFilter.some(f => f.Id === jb.entityAssignedTo.id()); - const sectorMatch = sectorFilter.length === 0 || (jb.sector() && sectorFilter.includes(jb.sector().id())); + const jobHqId = String(jb.entityAssignedTo.id()); + const sectorId = String(jb.sector().id()); + const hqMatch = hqIds.size === 0 || hqIds.has(jobHqId); + const sectorMatch = sectorIds.size === 0 || sectorIds.has(sectorId); //must match sector filter //if no sector and config says to exclude, filter out @@ -611,12 +670,12 @@ function VM() { if (jb.sector().id() && !sectorMatch) return false; // If allow-list non-empty, only show jobs whose status is in it - if (allowedStatus.length > 0 && !allowedStatus.includes(statusName)) { + if (allowedStatusSet.size > 0 && !allowedStatusSet.has(statusName)) { return false; } // If incident type filter non-empty, only show jobs whose type is in it - if (incidentTypeAllowedById.length > 0 && !incidentTypeAllowedById.includes(jb.typeId())) { + if (incidentTypeSet.size > 0 && !incidentTypeSet.has(String(jb.typeId()))) { return false; } @@ -636,17 +695,18 @@ function VM() { jb.id().toString().toLowerCase().includes(term) || jb.address.prettyAddress().toLowerCase().includes(term)); }); - }).extend({ trackArrayChanges: true, rateLimit: 50 }); + }).extend({ rateLimit: { timeout: 50, method: 'notifyWhenChangesStop' } }); self.filteredJobs = ko.pureComputed(() => { const pinnedOnlyIncidents = self.showPinnedIncidentsOnly(); const pinnedIncidentIds = (self.config && self.config.pinnedIncidentIds) ? self.config.pinnedIncidentIds() : []; + const pinnedIncidentSet = new Set((pinnedIncidentIds || []).map(id => String(id))); return ko.utils.arrayFilter(this.filteredJobsAgainstConfig(), jb => { // pinned-only filter - if (pinnedOnlyIncidents && !pinnedIncidentIds.includes(String(jb.id()))) { + if (pinnedOnlyIncidents && !pinnedIncidentSet.has(String(jb.id()))) { return false; } return true; @@ -662,6 +722,8 @@ function VM() { self.filteredTeamsAgainstConfig = ko.pureComputed(() => { const allowed = self.config.teamStatusFilter(); // allow-list + const allowedSet = new Set(allowed || []); + const hqFilterIds = new Set((self.config.teamFilters() || []).map(f => String(f.id))); var start = new Date(); var end = new Date(); @@ -677,15 +739,14 @@ function VM() { return ko.utils.arrayFilter(self.teams(), tm => { const status = tm.teamStatusType()?.Name; - - - const hqMatch = self.config.teamFilters().length === 0 || self.config.teamFilters().some((f) => f.id == tm.assignedTo().id()); + const teamHqId = String(tm.assignedTo().id()); + const hqMatch = hqFilterIds.size === 0 || hqFilterIds.has(teamHqId); if (status == null) { return false; } // If allow-list non-empty, only show teams whose status is in it - if (allowed.length > 0 && !allowed.includes(status)) { + if (allowedSet.size > 0 && !allowedSet.has(status)) { return false; } @@ -706,11 +767,12 @@ function VM() { self.filteredTeams = ko.pureComputed(() => { const pinnedOnlyTeams = self.showPinnedTeamsOnly(); const pinnedTeamIds = (self.config && self.config.pinnedTeamIds) ? self.config.pinnedTeamIds() : []; + const pinnedTeamSet = new Set((pinnedTeamIds || []).map(id => String(id))); return ko.utils.arrayFilter(self.filteredTeamsAgainstConfig(), tm => { // pinned-only filter - if (pinnedOnlyTeams && !pinnedTeamIds.includes(String(tm.id()))) { + if (pinnedOnlyTeams && !pinnedTeamSet.has(String(tm.id()))) { return false; } @@ -815,8 +877,31 @@ function VM() { const lat = job.address.latitude?.(); const lng = job.address.longitude?.(); if (Number.isFinite(lat) && Number.isFinite(lng)) { - map.flyTo([lat, lng], 16, { animate: true, duration: 0.10 }); - job.marker?.openPopup?.(); + // If the popup is already open (i.e. this call originated + // from the popupopen event chain rather than a deliberate + // UI action), bail out. flyTo can change the zoom level + // which triggers markercluster reclustering — the marker + // disappears and the popup is abruptly closed. + if (job.marker?.isPopupOpen?.()) return; + + const m = job.marker; + const cg = self.mapVM?.jobClusterGroup; + + // If the marker lives in the cluster group, use + // zoomToShowLayer – it zooms/pans as needed, spiderfies + // the parent cluster if the markers can't separate + // further, and only then fires the callback so the + // popup opens on the visible, individual marker. + if (m && cg && cg.hasLayer(m)) { + cg.zoomToShowLayer(m, () => { + m.openPopup(); + }); + } else { + // Marker is on a standalone layer (rescueJobLayer, + // unclusteredJobLayer) or doesn't exist yet – simple flyTo. + map.flyTo([lat, lng], 16, { animate: true, duration: 0.10 }); + m?.openPopup?.(); + } } }, @@ -844,9 +929,26 @@ function VM() { self.untaskTeam(tasking, payload, cb) }, - fetchUnacknowledgedJobNotifications: (job) => { - self.fetchUnacknowledgedJobNotifications(job); + fetchUnacknowledgedJobNotifications: (job) => self.fetchUnacknowledgedJobNotifications(job), + acknowledgeUnacceptedNotification: async (notificationId) => { + const tk = await getToken(); + return BeaconClient.notifications.acknowledge(notificationId, apiHost, params.userId, tk); + }, + fetchMessageById: async (messageId) => { + const tk = await getToken(); + return new Promise((resolve, reject) => { + BeaconClient.icems.getMessageById(messageId, apiHost, params.userId, tk, resolve, reject); + }); + }, + acknowledgeIumMessage: async (notificationId, messageData) => { + const tk = await getToken(); + return new Promise((resolve, reject) => { + BeaconClient.icems.acknowledgeIum(notificationId, messageData, apiHost, params.userId, tk, resolve, reject); + }); }, + relativeUpdateTick: self.relativeUpdateTick30s, + notifySuccess: (message) => showAlert(message, 'success', 3000), + notifyError: (message) => showAlert(message, 'danger', 5000), drawJobTargetRing: (job) => { self.drawJobTargetRing(job); }, @@ -919,6 +1021,7 @@ function VM() { isTeamPinned: (id) => self.isTeamPinned(id), toggleTeamPinned: (id) => self.toggleTeamPinned(id), currentlyOpenMapPopup: self.mapVM?.openPopup, + saveTaskingSequence: (sequences) => self.saveTaskingSequence(sequences), }; team = new Team(teamJson, deps); @@ -930,12 +1033,25 @@ function VM() { const configDeps = { - entitiesSearch: (q) => new Promise((resolve) => { - BeaconClient.entities.search(q, apiHost, params.userId, token, (data) => resolve(data.Results || [])); - }), - entitiesChildren: (parentId) => new Promise((resolve) => { - BeaconClient.entities.children(parentId, apiHost, params.userId, token, (data) => resolve(data || [])); - }), + entitiesSearch: async (q) => { + const t = await getToken(); + return new Promise((resolve) => { + BeaconClient.entities.search(q, apiHost, params.userId, t, (data) => resolve(data.Results || [])); + }); + }, + entitiesChildren: async (parentId) => { + const t = await getToken(); + return new Promise((resolve) => { + BeaconClient.entities.children(parentId, apiHost, params.userId, t, (data) => resolve(data || [])); + }); + }, + entity: async (id) => { + const t = await getToken(); + console.log("Fetching entity for config:", id, t); + return new Promise((resolve) => { + BeaconClient.entities.fetch(id, apiHost, params.userId, t, (data) => resolve(data)); + }); + }, fetchAllSectors: (hqs) => self.fetchAllSectors(hqs), }; @@ -975,6 +1091,17 @@ function VM() { self.attachJobTimelineModal = function (job) { const modalEl = document.getElementById('jobTimelineModal'); const modal = new bootstrap.Modal(modalEl); + + if (modalEl && !modalEl.__timelineRefreshBound) { + modalEl.addEventListener('shown.bs.modal', () => { + self.jobTimelineVM.startAutoRefresh?.(); + }); + modalEl.addEventListener('hidden.bs.modal', () => { + self.jobTimelineVM.stopAutoRefresh?.(); + }); + modalEl.__timelineRefreshBound = true; + } + self.jobTimelineVM.openForJob(job); modal.show(); } @@ -1155,6 +1282,14 @@ function VM() { if (job) { //console.log("Updating existing job:", job.id()); job.updateFromJson(jobJson); + + // If the job is filtered-in but has no marker yet (e.g. it was + // originally created without GPS coordinates and now has them), + // create the marker now. + if (job.isFilteredIn() && !job.marker) { + addOrUpdateJobMarker(ko, map, self, job); + } + return job; } job = new Job(jobJson, deps); @@ -1179,7 +1314,7 @@ function VM() { asset.updateFromJson(assetJson); return asset; } else { //new asset - create, store, attach to teams - asset = new Asset(assetJson); + asset = new Asset(assetJson, { relativeUpdateTick: self.relativeUpdateTick30s }); self.trackableAssets.push(asset); self.assetsById.set(asset.id(), asset); } @@ -1481,32 +1616,90 @@ function VM() { try { self._spotlightModal?.hide(); } catch { /* empty */ } }; + // --- Initial load state management --- self.initialFitDone = false; let initialFetchesPending = 3; // teams, jobs, assets + let userHasInteracted = false; // Track if user manually panned/zoomed + + // Create loading overlay element + const loadingOverlay = document.createElement('div'); + loadingOverlay.id = 'mapLoadingOverlay'; + loadingOverlay.innerHTML = '
    Loading...

    Loading data...please wait. or don\'t

    '; + loadingOverlay.style.cssText = 'position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 999; font-weight: 500;'; + map.getContainer().appendChild(loadingOverlay); + + // Track user map interactions (only real user input, not internal operations) + const markUserInteracted = () => { userHasInteracted = true; }; + map.on('click', markUserInteracted); + map.on('mousedown', markUserInteracted); + map.on('touchstart', markUserInteracted); + map.on('wheel', markUserInteracted); function debounce(fn, ms) { let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; } - const tryInitialFit = debounce(() => { - if (self.initialFitDone || initialFetchesPending > 0) return; + let initialFitRetries = 0; + const MAX_INITIAL_FIT_RETRIES = 4; // give up after ~1.25s to avoid infinite retry loops in bad states - // Gather every current marker into one feature group + const tryInitialFit = debounce(() => { + if (self.initialFitDone || initialFetchesPending > 0 || userHasInteracted) return; + console.log("Attempting initial map fit..."); + // Prefer map layers if already attached const fg = L.featureGroup(); // vehicle (assets) - self.mapVM.assetLayer.eachLayer(l => fg.addLayer(l)); + if (self.mapVM.assetLayer && map.hasLayer(self.mapVM.assetLayer)) { + self.mapVM.assetLayer.eachLayer(l => fg.addLayer(l)); + } - // all job marker layer groups - for (const { layerGroup } of self.mapVM.jobMarkerGroups.values()) { - layerGroup.eachLayer(l => fg.addLayer(l)); + // Job layers - use whichever is currently visible + if (self.mapVM.jobClusterGroup && map.hasLayer(self.mapVM.jobClusterGroup)) { + self.mapVM.jobClusterGroup.eachLayer(l => fg.addLayer(l)); + } else if (self.mapVM.unclusteredJobLayer && map.hasLayer(self.mapVM.unclusteredJobLayer)) { + self.mapVM.unclusteredJobLayer.eachLayer(l => fg.addLayer(l)); } const layers = fg.getLayers(); - if (!layers.length) return; - // Fit with a little padding, once - map.fitBounds(fg.getBounds().pad(0.12), { maxZoom: 15 }); + let bounds = null; + + if (layers.length > 0) { + bounds = fg.getBounds(); + } else { + // Fallback: compute bounds from data (covers cases where markers have not attached yet) + const latLngs = []; + + (self.filteredJobs?.() || []).forEach((j) => { + const lat = j?.address?.latitude?.(); + const lng = j?.address?.longitude?.(); + if (Number.isFinite(lat) && Number.isFinite(lng)) { + latLngs.push([lat, lng]); + } + }); + + (self.filteredTrackableAssets?.() || []).forEach((a) => { + const lat = a?.latitude?.(); + const lng = a?.longitude?.(); + if (Number.isFinite(lat) && Number.isFinite(lng)) { + latLngs.push([lat, lng]); + } + }); + + if (latLngs.length > 0) { + bounds = L.latLngBounds(latLngs); + } + } + + if (!bounds || !bounds.isValid()) { + if (initialFitRetries < MAX_INITIAL_FIT_RETRIES) { + initialFitRetries += 1; + setTimeout(() => tryInitialFit(), 250); + } + return; + } + + map.fitBounds(bounds.pad(0.12), { maxZoom: 15 }); self.initialFitDone = true; }, 150); @@ -1518,6 +1711,11 @@ function VM() { // Give subscriptions time to attach markers, then attempt fit tryInitialFit(); } + + // Once all fetches are done, hide the loading overlay + if (initialFetchesPending === 0 && loadingOverlay) { + loadingOverlay.style.display = 'none'; + } }; self._linkTaskingAndJob = function (tasking, job) { @@ -1606,11 +1804,8 @@ function VM() { self.fetchUnacknowledgedJobNotifications = async function (job) { const t = await getToken(); // blocks here until token is ready - BeaconClient.notifications.unaccepted(job.id(), apiHost, params.userId, t, function (data) { - job.unacceptedNotifications(data); - }, function (err) { - console.error("Failed to fetch unacknowledged job notifications:", err); - showAlert('Failed to fetch unacknowledged job notifications. Your session may have expired', 'danger', 5000); + return new Promise((resolve, reject) => { + BeaconClient.notifications.unaccepted(job.id(), apiHost, params.userId, t, resolve, reject); }); } @@ -1629,10 +1824,98 @@ function VM() { }) } + self.saveTaskingSequence = async function (sequences) { + const t = await getToken(); + return new Promise((resolve, reject) => { + BeaconClient.tasking.sequence( + { Sequences: sequences }, + apiHost, + t, + function (data) { + if (data) { + showAlert('Tasking order saved.', 'success', 3000); + resolve(data); + } else { + showAlert('Failed to save tasking order.', 'danger', 5000); + reject(new Error('Failed to save tasking order')); + } + } + ); + }); + }; + //update spotlight index on team/job filter changes self.filteredTeamsAgainstConfig.subscribe(() => { self.spotlightSearchVM.rebuildIndex?.() }, null, "arrayChange"); self.filteredJobsAgainstConfig.subscribe(() => { self.spotlightSearchVM.rebuildIndex?.() }, null, "arrayChange"); + // --- Marker batching (reduces layout/reflow thrash on burst updates) --- + const getItemId = (item) => { + if (!item) return null; + if (typeof item.id === "function") return item.id(); + return item.id ?? null; + }; + + function createMarkerBatcher({ addFn, removeFn }) { + const pendingAdds = new Map(); + const pendingRemoves = new Map(); + let rafHandle = null; + + const flush = () => { + rafHandle = null; + + pendingRemoves.forEach((item) => removeFn(item)); + pendingAdds.forEach((item) => addFn(item)); + + pendingRemoves.clear(); + pendingAdds.clear(); + }; + + const ensureFlush = () => { + if (rafHandle == null) { + rafHandle = requestAnimationFrame(flush); + } + }; + + const scheduleAdd = (item) => { + const id = getItemId(item); + if (id == null) { + addFn(item); + return; + } + pendingRemoves.delete(id); + pendingAdds.set(id, item); + ensureFlush(); + }; + + const scheduleRemove = (item) => { + const id = getItemId(item); + if (id == null) { + removeFn(item); + return; + } + pendingAdds.delete(id); + pendingRemoves.set(id, item); + ensureFlush(); + }; + + return { scheduleAdd, scheduleRemove }; + } + + const jobMarkerBatcher = createMarkerBatcher({ + addFn: (job) => addOrUpdateJobMarker(ko, map, self, job), + removeFn: (job) => removeJobMarker(self, job), + }); + + const matchedAssetMarkerBatcher = createMarkerBatcher({ + addFn: (asset) => attachAssetMarker(ko, map, self, asset), + removeFn: (asset) => detachAssetMarker(ko, map, self, asset), + }); + + const unmatchedAssetMarkerBatcher = createMarkerBatcher({ + addFn: (asset) => attachUnmatchedAssetMarker(ko, map, self, asset), + removeFn: (asset) => detachUnmatchedAssetMarker(ko, map, self, asset), + }); + //fetch tasking if a team is added self.filteredTeams.subscribe((changes) => { changes.forEach(ch => { @@ -1652,16 +1935,20 @@ function VM() { // automatically refresh markers when jobs change self.filteredJobs.subscribe((changes) => { + // Bail early if incidents are not visible – no need to attach markers + if (!self.incidentsVisible()) { + return; + } changes.forEach(ch => { if (ch.status === 'added') { - addOrUpdateJobMarker(ko, map, self, ch.value); + jobMarkerBatcher.scheduleAdd(ch.value); ch.value.isFilteredIn(true); ch.value.fetchTasking(); } else if (ch.status === 'deleted') { if (ch.value.expanded() || ch.value.popUpIsOpen()) { showAlert("The job you were viewing has been refreshed and filtered out based on the current filters. It has probably been closed or completed.", "warning", 4000); } - removeJobMarker(self, ch.value); + jobMarkerBatcher.scheduleRemove(ch.value); ch.value.isFilteredIn(false); } @@ -1678,11 +1965,11 @@ function VM() { changes.forEach(ch => { const a = ch.value; if (ch.status === 'added') { - attachAssetMarker(ko, map, self, a); + matchedAssetMarkerBatcher.scheduleAdd(a); } else if (ch.status === 'deleted') { //console.log("Detaching marker for asset no longer filtered in:", a.id()); // keep the asset in registry, but remove map marker + subs - detachAssetMarker(ko, map, self, a); + matchedAssetMarkerBatcher.scheduleRemove(a); } }); }, null, "arrayChange"); @@ -1695,9 +1982,9 @@ function VM() { changes.forEach(ch => { const a = ch.value; if (ch.status === 'added') { - attachUnmatchedAssetMarker(ko, map, self, a); + unmatchedAssetMarkerBatcher.scheduleAdd(a); } else if (ch.status === 'deleted') { - detachUnmatchedAssetMarker(ko, map, self, a); + unmatchedAssetMarkerBatcher.scheduleRemove(a); } }); }, null, "arrayChange"); @@ -1707,7 +1994,7 @@ function VM() { if (ev.layer !== self.mapVM.assetLayer) return; const assets = self.filteredTrackableAssets?.() || []; assets.forEach(a => { - attachAssetMarker(ko, map, self, a); + matchedAssetMarkerBatcher.scheduleAdd(a); }); }); @@ -1715,7 +2002,7 @@ function VM() { if (ev.layer !== self.mapVM.assetLayer) return; const assets = self.filteredTrackableAssets?.() || []; assets.forEach(a => { - detachAssetMarker(ko, map, self, a); + matchedAssetMarkerBatcher.scheduleRemove(a); }); }); @@ -1724,7 +2011,7 @@ function VM() { if (ev.layer !== self.mapVM.unmatchedAssetLayer) return; const assets = self.unmatchedTrackableAssets?.() || []; assets.forEach(a => { - attachUnmatchedAssetMarker(ko, map, self, a); + unmatchedAssetMarkerBatcher.scheduleAdd(a); }); }); @@ -1732,7 +2019,7 @@ function VM() { if (ev.layer !== self.mapVM.unmatchedAssetLayer) return; const assets = self.unmatchedTrackableAssets?.() || []; assets.forEach(a => { - detachUnmatchedAssetMarker(ko, map, self, a); + unmatchedAssetMarkerBatcher.scheduleRemove(a); }); }); @@ -2164,18 +2451,42 @@ function VM() { // --- Polling layers --- registerTransportCamerasLayer(self, map, getToken, apiHost, params); + registerHazardWatchWarningsLayer(self, apiHost); + registerUnitBoundaryLayer(self, map, getToken, apiHost, params); registerTransportIncidentsLayer(self, map, getToken, apiHost, params); + registerSESZonesGridLayer(self); registerSESUnitsZonesHybridGridLayer(self, map); - registerHazardWatchWarningsLayer(self, apiHost); registerSESUnitLocationsLayer(self); renderFRAOSLayer(self, map, getToken, apiHost, params); registerPowerBoundariesGridLayer(self, map); registerWaterNSWBoundariesLayer(self); registerEPAContaminationSitesLayer(self); registerBOMLandWarningsLayer(self); - registerRainRadarLayer(self, map); + registerBOMRainfallLayer(self, sourceUrl); + registerBOMRadarLayer(self, sourceUrl); + registerBOMAllFloodLevelsLayer(self, sourceUrl); + registerBOMSatTrueColorLayer(self, sourceUrl); + registerBOMThunderstormTrackingLayer(self, sourceUrl); + registerBOMWindLayer(self, sourceUrl); + registerBOMMSLPLayer(self, sourceUrl); + registerBOMLightningLayer(self, sourceUrl); + registerBOMLightning24hLayer(self, sourceUrl); + registerBOMTsunamiLayer(self, sourceUrl); + registerBOMTropicalCycloneLayer(self, sourceUrl); + registerBOMFireDangerRatingLayer(self, sourceUrl); + registerBOMHeatwaveLayer(self, sourceUrl); + registerBOMHazardousSurfLayer(self, sourceUrl); + registerBOMCoastalHazardLayer(self, sourceUrl); + registerBOMRoadWeatherLayer(self, sourceUrl); + registerBOMSurfaceGustLayer(self, sourceUrl); + registerBOMSurfaceTempLayer(self, sourceUrl); + registerBOMFireBehaviourIndexLayer(self, sourceUrl); + registerBOMHazardousWindLayer(self, sourceUrl); + registerBOMFloodWarningBoundariesLayer(self, sourceUrl); + registerBOMFireWeatherDistrictsLayer(self, sourceUrl); + registerRainRadarLayer(self, map); // --- Layers Drawer (under zoom) const LayersDrawer = L.Control.extend({ @@ -2189,171 +2500,150 @@ function VM() { }, onAdd(map) { - const c = L.DomUtil.create("div", "layers-drawer leaflet-bar"); + const c = L.DomUtil.create("div", "layers-drawer"); // stop wheel -> no map zoom when scrolling the panel - c.addEventListener( - "wheel", - (e) => { - e.stopPropagation(); - }, - { passive: false } - ); + c.addEventListener("wheel", (e) => { e.stopPropagation(); }, { passive: false }); - // Bootstrap-flavoured shell c.innerHTML = ` - - +
    + +
    -
    -
    - Basemap -
    -
    + +
    +
    - -
    - -
    -
    Overlays
    -
    + +
    +
    + +
    `; - // --- Basemap buttons (single-select) --- + // --- Basemap dropdown --- const basemapNames = [ { name: "Esri Topographic", key: "Topographic" }, { name: "Esri Streets", key: "Streets" }, { name: "Esri Imagery", key: "Imagery" }, { name: "Esri Dark", key: "DarkGray" }, - { name: "SIX Maps Topographic", key: "nsw-vector" }, + { name: "Spatial NSW", key: "nsw-vector" }, { name: "SIX Maps Base Map", key: "nsw-base" }, { name: "SIX Maps Imagery", key: "nsw-imagery" } ]; - const basesEl = c.querySelector(".ld-bases"); + const basemapThumbs = { + "Topographic": "https://services.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/16/39312/60258", + "Streets": "https://services.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/16/39312/60258", + "Imagery": "https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/16/39312/60258", + "DarkGray": "https://services.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Base/MapServer/tile/16/39312/60258", + "nsw-vector": "https://static.lighthouse-extension.com/map/nsw_vector.png", + "nsw-base": "https://maps.six.nsw.gov.au/arcgis/rest/services/public/NSW_Base_Map/MapServer/tile/16/39312/60258", + "nsw-imagery": "https://maps.six.nsw.gov.au/arcgis/rest/services/public/NSW_Imagery/MapServer/tile/16/39312/60258", + }; - basemapNames.forEach(({ name, key }) => { - const btn = document.createElement("button"); - btn.type = "button"; - btn.className = - "btn btn-outline-secondary flex-grow-1 mb-1" + - (key === this._baseKey ? " active btn-primary" : ""); - btn.textContent = name; - btn.dataset.baseKey = key; + const basemapLabel = c.querySelector(".ld-basemap-label"); + const basemapMenu = c.querySelector(".ld-basemap-menu"); + + const currentBaseName = basemapNames.find(b => b.key === this._baseKey)?.name || "Basemap"; + basemapLabel.textContent = currentBaseName; + basemapNames.forEach(({ name, key }) => { + const li = document.createElement("li"); + const thumb = basemapThumbs[key] || basemapThumbs["Topographic"]; + li.innerHTML = ``; + const btn = li.querySelector("button"); btn.addEventListener("click", () => { if (key === this._baseKey) return; - this._setBasemap(key, map); this._baseKey = key; localStorage.setItem("map.base", key); - - // update active styles - basesEl.querySelectorAll("button").forEach((b) => { - b.classList.remove("active", "btn-primary"); - b.classList.add("btn-outline-secondary"); - }); - btn.classList.add("active", "btn-primary"); - btn.classList.remove("btn-outline-secondary"); + basemapLabel.textContent = name; + basemapMenu.querySelectorAll(".dropdown-item").forEach(d => d.classList.remove("active")); + btn.classList.add("active"); }); - - basesEl.appendChild(btn); + basemapMenu.appendChild(li); }); - // apply initial basemap this._setBasemap(this._baseKey, map); - // --- Overlays as toggle buttons (multi-select) --- - const overlaysEl = c.querySelector(".ld-overlays"); + // --- Overlays: group by def.group --- const overlayDefs = self.mapVM.getOverlayDefsForControl() || []; - - // Group by def.group (parent menu layer) const groups = new Map(); overlayDefs.forEach((def) => { - const g = def.group || ""; // '' = ungrouped + const g = def.group || ""; if (!groups.has(g)) groups.set(g, []); groups.get(g).push(def); }); - // Build Bootstrap accordion container - const acc = document.createElement("div"); - acc.className = "accordion accordion-flush"; - acc.id = "ld-overlays-accordion"; - overlaysEl.appendChild(acc); - - // Helpers - const safeId = (s) => - String(s || "") - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/(^-|-$)/g, "") || "other"; - const groupTitle = (k) => (k && String(k).trim() ? k : "Other"); - // Build one accordion item per group (sub section) - let idx = 0; - groups.forEach((defs, groupKey) => { - idx += 1; - - const gid = safeId(groupKey); - const storeKey = `layers.ovgrp.${gid}`; - const open = localStorage.getItem(storeKey) === "1"; // default closed + // --- Build two-column grid of always-visible groups --- + const grid = c.querySelector(".ld-grid"); - const item = document.createElement("div"); - item.className = "accordion-item"; - - const headerId = `ld-ov-h-${gid}-${idx}`; - const collapseId = `ld-ov-c-${gid}-${idx}`; - - item.innerHTML = ` -

    - -

    -
    -
    -
    - `; - - acc.appendChild(item); - - const btn = item.querySelector(".accordion-button"); - const body = item.querySelector(".accordion-body"); - const collapseEl = item.querySelector(".accordion-collapse"); - - // Bootstrap Collapse instance with accordion behaviour (only one open at a time) - const collapse = new bootstrap.Collapse(collapseEl, { toggle: false, parent: acc }); - - btn.addEventListener("click", () => collapse.toggle()); - - collapseEl.addEventListener("shown.bs.collapse", () => { - localStorage.setItem(storeKey, "1"); - btn.classList.remove("collapsed"); - btn.setAttribute("aria-expanded", "true"); - }); + groups.forEach((defs, groupKey) => { + const cell = document.createElement("div"); + cell.className = "ld-grid-cell"; + + // Group header (static label) + const header = document.createElement("div"); + header.className = "ld-group-header"; + header.innerHTML = `${groupTitle(groupKey)}`; + + // Layer list (always visible) + const body = document.createElement("div"); + body.className = "ld-group-body"; + + // If this is the Visibility group, add Incidents toggle first + if (groupKey === 'Visibility') { + const incidentsBtn = document.createElement("button"); + incidentsBtn.type = "button"; + const isIncidentsOn = self.incidentsVisible(); + incidentsBtn.className = "btn btn-sm w-100 text-start d-flex align-items-center justify-content-between mb-1 ld-overlay-btn " + + (isIncidentsOn ? "btn-outline-secondary active" : "btn-outline-secondary"); + incidentsBtn.dataset.label = "Incidents"; + incidentsBtn.innerHTML = ` + Incidents + + + `; + + incidentsBtn.addEventListener("click", () => { + const icon = incidentsBtn.querySelector("i"); + const currentState = self.incidentsVisible(); + const newState = !currentState; + self.incidentsVisible(newState); + + if (newState) { + incidentsBtn.classList.add("active"); + if (icon) { icon.classList.remove("fa-toggle-off"); icon.classList.add("fa-toggle-on"); } + } else { + incidentsBtn.classList.remove("active"); + if (icon) { icon.classList.remove("fa-toggle-on"); icon.classList.add("fa-toggle-off"); } + } + }); - collapseEl.addEventListener("hidden.bs.collapse", () => { - localStorage.setItem(storeKey, "0"); - btn.classList.add("collapsed"); - btn.setAttribute("aria-expanded", "false"); - }); + body.appendChild(incidentsBtn); + } - // Render overlay buttons inside this group's accordion body + // Build layer toggle buttons defs.forEach(({ key, label, layer, visibleByDefault }) => { const stored = localStorage.getItem(`ov.${key}`); const saved = (stored === null) @@ -2368,64 +2658,115 @@ function VM() { "btn btn-sm w-100 text-start d-flex align-items-center justify-content-between mb-1 ld-overlay-btn " + (saved ? "btn-outline-secondary active" : "btn-outline-secondary"); obtn.dataset.key = key; + obtn.dataset.label = label; obtn.innerHTML = ` - ${label} - - - - `; + ${label} + + + `; obtn.addEventListener("click", () => { const icon = obtn.querySelector("i"); const isOn = obtn.classList.contains("active"); - if (isOn) { map.removeLayer(layer); localStorage.setItem(`ov.${key}`, "0"); obtn.classList.remove("active"); - if (icon) { - icon.classList.remove("fa-toggle-on"); - icon.classList.add("fa-toggle-off"); - } + if (icon) { icon.classList.remove("fa-toggle-on"); icon.classList.add("fa-toggle-off"); } } else { map.addLayer(layer); localStorage.setItem(`ov.${key}`, "1"); obtn.classList.add("active"); - if (icon) { - icon.classList.remove("fa-toggle-off"); - icon.classList.add("fa-toggle-on"); - } + if (icon) { icon.classList.remove("fa-toggle-off"); icon.classList.add("fa-toggle-on"); } } }); - body.appendChild(obtn); }); + + cell.appendChild(header); + cell.appendChild(body); + grid.appendChild(cell); }); + // --- Search filter --- + const searchInput = c.querySelector(".ld-search-input"); + const searchFilter = (query) => { + const q = query.toLowerCase().trim(); + const cells = grid.querySelectorAll(".ld-grid-cell"); + + cells.forEach(cell => { + const buttons = cell.querySelectorAll(".ld-overlay-btn"); + let anyVisible = false; + + buttons.forEach(btn => { + let shouldShow = !q; // Show all if no query + + if (q) { + // Extract label from the span.me-2 text content + const labelSpan = btn.querySelector("span.me-2"); + const label = labelSpan ? labelSpan.textContent.trim().toLowerCase() : ""; + shouldShow = label.includes(q); + } + + btn.style.setProperty("display", shouldShow ? "" : "none", "important"); + if (shouldShow) anyVisible = true; + }); + + // Show cell only if at least one button is visible + cell.style.setProperty("display", anyVisible ? "" : "none", "important"); + }); + }; + + searchInput.addEventListener("input", (e) => { + searchFilter(e.target.value); + }); - const btn = c.querySelector(".btn"); + // --- Toggle button --- + const toggleBtn = c.querySelector(".ld-toggle-btn"); const panel = c.querySelector(".ld-panel"); - L.DomEvent.on(btn, "click", (ev) => { + + const fitPanel = () => { + requestAnimationFrame(() => { + const rect = panel.getBoundingClientRect(); + const avail = window.innerHeight - rect.top - 20; // 20px bottom margin + panel.style.maxHeight = Math.max(avail, 160) + "px"; + }); + }; + + L.DomEvent.on(toggleBtn, "click", (ev) => { L.DomEvent.stop(ev); const hidden = panel.classList.toggle("d-none"); - btn.setAttribute("aria-expanded", (!hidden).toString()); - btn.parentElement.classList.toggle("no-border", !hidden); + toggleBtn.setAttribute("aria-expanded", (!hidden).toString()); + toggleBtn.parentElement.classList.toggle("no-border", !hidden); localStorage.setItem("layers.open", hidden ? "0" : "1"); + if (!hidden) { + // Clear search when opening + searchInput.value = ""; + searchFilter(""); + fitPanel(); + } }); - // prevent clicks/scrolls from falling through to map - L.DomEvent.disableClickPropagation(c); + // Re-fit when window resizes + window.addEventListener("resize", () => { + if (!panel.classList.contains("d-none")) fitPanel(); + }); - // tuck the drawer under the zoom control - setTimeout(() => { - const position = map._controlCorners.topleft.querySelector( - ".leaflet-control-geosearch" - ); - if (position && c.parentElement === map._controlCorners.topleft) { - position.insertAdjacentElement("afterend", c); + // Initial fit if panel starts open + if (this._open) setTimeout(fitPanel, 50); + + // Close panel when map is clicked + map.on("click", () => { + if (!panel.classList.contains("d-none")) { + panel.classList.add("d-none"); + toggleBtn.setAttribute("aria-expanded", "false"); + toggleBtn.parentElement.classList.remove("no-border"); + localStorage.setItem("layers.open", "0"); } - }, 0); + }); + + L.DomEvent.disableClickPropagation(c); this._container = c; return c; @@ -2533,15 +2874,28 @@ function VM() { const layersDrawer = new LayersDrawer(); layersDrawer.addTo(map); + self.mapVM.layersDrawer = layersDrawer; setTimeout(() => { - // force ordering: place directly under zoom buttons - const zoom = document.querySelector('.leaflet-control-zoom'); - const toggle = document.querySelector('.sidebar-toggle'); - if (zoom && toggle) { - zoom.parentNode.insertBefore(toggle, zoom.nextSibling); + // force ordering: zoom → hide → layers → measure → geosearch + const corner = map._controlCorners.topleft; + const zoom = corner.querySelector('.leaflet-control-zoom'); + const layers = corner.querySelector('.layers-drawer'); + const measure = document.getElementById('polyline-measure-control'); + const measureCtl = measure ? measure.closest('.leaflet-control') : null; + const geosearch = corner.querySelector('.leaflet-control-geosearch'); + const hide = corner.querySelector('.sidebar-toggle'); + + // Insert in reverse order after zoom so the last insert ends up first + const order = [geosearch, measureCtl, layers, hide]; + if (zoom) { + order.forEach(el => { + if (el && el.parentElement === corner) { + zoom.parentElement.insertBefore(el, zoom.nextSibling); + } + }); } - }, 0); + }, 100); @@ -2757,8 +3111,8 @@ document.addEventListener('DOMContentLoaded', function () { }) -// wait for full CSS + DOM -window.addEventListener('load', function () { +// show page once DOM + CSS are ready (don't wait for map tiles) +document.addEventListener('DOMContentLoaded', function () { document.body.style.opacity = '1'; }); diff --git a/src/pages/tasking/mapLayers/bom.js b/src/pages/tasking/mapLayers/bom.js index d7f7445e..64364cb5 100644 --- a/src/pages/tasking/mapLayers/bom.js +++ b/src/pages/tasking/mapLayers/bom.js @@ -57,7 +57,7 @@ function severityLabel(code) { export function registerBOMLandWarningsLayer(vm) { vm.mapVM.registerPollingLayer("bomLandWarnings", { label: "BOM Land Warnings", - menuGroup: "Bureau of Meteorology", + menuGroup: "BOM Warnings", refreshMs: 300000, // 5 min – warnings update frequently visibleByDefault: localStorage.getItem(`ov.bomLandWarnings`) || false, fetchFn: async () => { diff --git a/src/pages/tasking/mapLayers/geoservices.js b/src/pages/tasking/mapLayers/geoservices.js index cea5e80b..5aa0c88c 100644 --- a/src/pages/tasking/mapLayers/geoservices.js +++ b/src/pages/tasking/mapLayers/geoservices.js @@ -276,7 +276,7 @@ function colorByUnitCode(code) { export function registerSESUnitLocationsLayer(vm) { vm.mapVM.registerPollingLayer("sesUnitLocations", { label: "SES Unit Locations", - menuGroup: "Lighthouse Geoservices", + menuGroup: "NSW SES Geoservices", refreshMs: 0, // No auto-refresh, only redraw on filter change visibleByDefault: localStorage.getItem(`ov.unit-locations`) || false, fetchFn: async () => { diff --git a/src/pages/tasking/mapLayers/rainviewer.js b/src/pages/tasking/mapLayers/rainviewer.js new file mode 100644 index 00000000..aba015b6 --- /dev/null +++ b/src/pages/tasking/mapLayers/rainviewer.js @@ -0,0 +1,372 @@ +import L from "leaflet"; + + + +/* ══════════════════════════════════════════════════════════════ + * RainViewer Radar Overlay (animated, past ~2 h) + * API docs: https://www.rainviewer.com/api/weather-maps-api.html + * ══════════════════════════════════════════════════════════════ */ + +const RAINVIEWER_API = "https://api.rainviewer.com/public/weather-maps.json"; +const FRAME_INTERVAL_MS = 500; // default playback speed +const DATA_REFRESH_MS = 300000; // re-fetch frame list every 5 min + +// Universal Blue color scale (dBZ values to hex colors) - from RainViewer API +// Key thresholds from meteorological standards: +// <10 dBZ: Overcast/No Precipitation, 10: Drizzle, 20: Light Rain, 30: Moderate Rain, +// 40: Shower, 50: Small hail possible, 55: Hail possible, 60+: Hail likely +const COLOR_SCALE = [ + { dBZ: -10, color: "#63615914", label: "Overcast" }, + { dBZ: -5, color: "#6c685d24", label: "Overcast" }, + { dBZ: 0, color: "#827b6949", label: "Overcast" }, + { dBZ: 5, color: "#92887164", label: "Overcast" }, + { dBZ: 10, color: "#d2c48ba0", label: "Drizzle" }, + { dBZ: 13, color: "#d8ddeeff", label: "Drizzle" }, + { dBZ: 16, color: "#51c5e8ff", label: "Light Rain" }, + { dBZ: 19, color: "#00a3e0ff", label: "Light Rain" }, + { dBZ: 20, color: "#00a3e0ff", label: "Light Rain" }, + { dBZ: 25, color: "#0088bfff", label: "Moderate Rain" }, + { dBZ: 30, color: "#005588ff", label: "Moderate Rain" }, + { dBZ: 35, color: "#ffee00ff", label: "Shower" }, + { dBZ: 40, color: "#ffaa00ff", label: "Shower" }, + { dBZ: 45, color: "#ff6600ff", label: "Shower" }, + { dBZ: 50, color: "#c10000ff", label: "Small Hail Possible" }, + { dBZ: 52, color: "#d6000dff", label: "Small Hail Possible" }, + { dBZ: 54, color: "#d70013ff", label: "Hail Possible" }, + { dBZ: 55, color: "#ff4fffff", label: "Hail Possible" }, + { dBZ: 57, color: "#ff5fffff", label: "Hail Possible" }, + { dBZ: 59, color: "#ff6fffff", label: "Hail Likely" }, + { dBZ: 60, color: "#ff62ffff", label: "Hail Likely" }, + { dBZ: 63, color: "#ff58ffff", label: "Hail Likely" }, + { dBZ: 66, color: "#f5e7fbff", label: "Hail Likely" }, + { dBZ: 70, color: "#ffffffff", label: "Hail Likely" }, +]; + +/** + * Register an animated rainfall radar overlay powered by the RainViewer API. + * Loops through the past ~2 hours of composite radar frames with on-map + * playback controls (play / pause / step / scrub). + */ +export function registerRainRadarLayer(vm, map) { + /* ── state ─────────────────────────────────────────────────── */ + let frames = []; // [{ time, path }] + let host = null; + let tileLayers = []; // parallel to `frames` + let frameIdx = -1; + let playing = false; + let playTimer = null; + let dataTimer = null; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let activeGroup = null; + let control = null; + let speed = FRAME_INTERVAL_MS; + + /* ── helpers ───────────────────────────────────────────────── */ + function buildTileLayer(framePath) { + return L.tileLayer( + `${host}${framePath}/512/{z}/{x}/{y}/2/1_1.png`, + { + pane: "pane-lowest-plus", + tileSize: 512, + zoomOffset: -1, + maxNativeZoom: 7, + maxZoom: 18, + opacity: 0, // start invisible; showFrame will reveal the active one + attribution: + 'RainViewer', + } + ); + } + + function fmtTime(unix) { + const d = new Date(unix * 1000); + return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + } + + /* ── frame display ─────────────────────────────────────────── */ + function showFrame(idx) { + if (!Number.isInteger(idx) || tileLayers.length === 0 || idx < 0 || idx >= tileLayers.length) return; + + // hide previous + if (frameIdx >= 0 && frameIdx < tileLayers.length) { + tileLayers[frameIdx].setOpacity(0); + } + frameIdx = idx; + + // Get current opacity from control, default to 0.6 + let opacity = 0.6; + if (control && control._container) { + const opacitySlider = control._container.querySelector(".rv-opacity"); + if (opacitySlider) { + opacity = parseInt(opacitySlider.value, 10) / 100; + } + } + + if (tileLayers[frameIdx]) { + tileLayers[frameIdx].setOpacity(opacity); + } + + // update control UI + if (control) { + const ts = control._container.querySelector(".rv-timestamp"); + const slider = control._container.querySelector(".rv-slider"); + const frame = frames[frameIdx]; + if (ts) ts.textContent = frame?.time ? fmtTime(frame.time) : "--:--"; + if (slider) slider.value = frameIdx; + } + } + + function stepForward() { + if (tileLayers.length === 0) return; + showFrame((frameIdx + 1) % tileLayers.length); + } + + function stepBack() { + if (tileLayers.length === 0) return; + showFrame((frameIdx - 1 + tileLayers.length) % tileLayers.length); + } + + function play() { + if (playing) return; + playing = true; + updatePlayBtn(); + playTimer = setInterval(stepForward, speed); + } + + function pause() { + playing = false; + updatePlayBtn(); + if (playTimer) { clearInterval(playTimer); playTimer = null; } + } + + function togglePlay() { playing ? pause() : play(); } + + function updatePlayBtn() { + if (!control) return; + const btn = control._container.querySelector(".rv-play"); + if (btn) btn.textContent = playing ? "⏸" : "▶"; + } + + /* ── data loading ──────────────────────────────────────────── */ + async function loadFrames(layerGroup) { + try { + const res = await fetch(RAINVIEWER_API); + if (!res.ok) throw new Error(`RainViewer API ${res.status}`); + const json = await res.json(); + const past = json.radar?.past; + if (!past || past.length === 0) return; + + const newHost = json.host; + const pathsMatch = + host === newHost && + frames.length === past.length && + frames.every((f, i) => f.path === past[i].path); + + if (pathsMatch) return; // nothing changed + + // tear down old tile layers + tileLayers.forEach((tl) => { + if (layerGroup.hasLayer(tl)) layerGroup.removeLayer(tl); + }); + + host = newHost; + frames = past; + tileLayers = frames.map((f) => { + const tl = buildTileLayer(f.path); + layerGroup.addLayer(tl); + return tl; + }); + + // update slider range + if (control) { + const slider = control._container.querySelector(".rv-slider"); + if (slider) { slider.max = frames.length - 1; slider.value = frames.length - 1; } + } + + // show latest frame + frameIdx = -1; + showFrame(frames.length - 1); + } catch (err) { + console.warn("[RainViewer Radar] Failed to load frames:", err); + } + } + + /* ── Radar playback bar (bottom-center of map) with legend ──────── */ + class RadarControl { + constructor() { this._container = null; this._map = null; this._legendContainer = null; } + + addTo(m) { + this._map = m; + const wrapper = document.createElement("div"); + wrapper.className = "rv-wrapper"; + + // Legend above the controls + const legendDiv = document.createElement("div"); + legendDiv.className = "rv-legend-container"; + legendDiv.innerHTML = ` +
    + Rainfall Intensity (dBZ) +
    +
    `; + + const legendBar = legendDiv.querySelector(".rv-legend-bar"); + COLOR_SCALE.forEach((item) => { + const swatch = document.createElement("div"); + swatch.style.flex = "1"; + swatch.style.backgroundColor = item.color; + swatch.style.cursor = "help"; + swatch.style.pointerEvents = "auto"; + swatch.style.position = "relative"; + + // Fast custom tooltip + const tooltip = document.createElement("div"); + tooltip.style.position = "absolute"; + tooltip.style.bottom = "100%"; + tooltip.style.left = "50%"; + tooltip.style.transform = "translateX(-50%)"; + tooltip.style.marginBottom = "6px"; + tooltip.style.padding = "6px 8px"; + tooltip.style.backgroundColor = "#333"; + tooltip.style.color = "#fff"; + tooltip.style.fontSize = "11px"; + tooltip.style.fontWeight = "500"; + tooltip.style.whiteSpace = "nowrap"; + tooltip.style.borderRadius = "3px"; + tooltip.style.pointerEvents = "none"; + tooltip.style.zIndex = "1001"; + tooltip.style.opacity = "0"; + tooltip.style.transition = "opacity 0.1s ease"; + tooltip.textContent = `${item.dBZ} dBZ: ${item.label}`; + swatch.appendChild(tooltip); + + swatch.addEventListener("mouseenter", () => { + tooltip.style.opacity = "1"; + }); + swatch.addEventListener("mouseleave", () => { + tooltip.style.opacity = "0"; + }); + + legendBar.appendChild(swatch); + }); + + this._legendContainer = legendDiv; + wrapper.appendChild(legendDiv); + + // Control bar below legend + const c = document.createElement("div"); + c.className = "rv-control"; + L.DomEvent.disableClickPropagation(c); + L.DomEvent.disableScrollPropagation(c); + + c.innerHTML = ` +
    +
    + + + +
    + + + --:-- + + + +
    + +
    + + + 60% +
    +
    `; + + c.querySelector(".rv-play").addEventListener("click", togglePlay); + c.querySelector(".rv-step-back").addEventListener("click", () => { pause(); stepBack(); }); + c.querySelector(".rv-step-fwd").addEventListener("click", () => { pause(); stepForward(); }); + c.querySelector(".rv-slider").addEventListener("input", (e) => { + pause(); + showFrame(parseInt(e.target.value, 10)); + }); + c.querySelector(".rv-speed").addEventListener("change", (e) => { + speed = parseInt(e.target.value, 10); + if (playing) { pause(); play(); } + }); + + // Opacity control + c.querySelector(".rv-opacity").addEventListener("input", (e) => { + const opacityVal = parseInt(e.target.value, 10) / 100; + c.querySelector(".rv-opacity-val").textContent = e.target.value + "%"; + if (frameIdx >= 0 && frameIdx < tileLayers.length && tileLayers[frameIdx]) { + tileLayers[frameIdx].setOpacity(opacityVal); + } + }); + + wrapper.appendChild(c); + m.getContainer().appendChild(wrapper); + this._container = c; + return this; + } + + remove() { + const wrapper = this._container?.parentNode; + if (wrapper && wrapper.parentNode) { + wrapper.parentNode.removeChild(wrapper); + } + this._container = null; + this._legendContainer = null; + this._map = null; + } + } + + /* ── layer registration ────────────────────────────────────── */ + vm.mapVM.registerPollingLayer("rainRadar", { + label: "RainViewer Animated Rainfall Radar", + menuGroup: "RainViewer", + refreshMs: 0, + visibleByDefault: localStorage.getItem(`ov.rainRadar`) || false, + fetchFn: async () => { + return {}; + }, + drawFn: (layerGroup, data) => { + if (!data) return; + + // Clean up any previous cycle (toggle off → on) + pause(); + if (dataTimer) { clearInterval(dataTimer); dataTimer = null; } + if (control) { control.remove(); control = null; } + tileLayers = []; + frames = []; + frameIdx = -1; + activeGroup = layerGroup; + + // Add playback control to the map + control = new RadarControl(); + control.addTo(map); + + // Clean up control + timers when the layerGroup is removed from the map + layerGroup.on("remove", cleanup); + + // Initial load + periodic refresh of frame list + loadFrames(layerGroup); + dataTimer = setInterval(() => loadFrames(layerGroup), DATA_REFRESH_MS); + + // Auto-play + play(); + }, + }); + + function cleanup() { + pause(); + if (dataTimer) { clearInterval(dataTimer); dataTimer = null; } + if (control) { control.remove(); control = null; } + tileLayers = []; + frames = []; + frameIdx = -1; + activeGroup = null; + } +} \ No newline at end of file diff --git a/src/pages/tasking/mapLayers/weather.js b/src/pages/tasking/mapLayers/weather.js index ca4eea49..0189459d 100644 --- a/src/pages/tasking/mapLayers/weather.js +++ b/src/pages/tasking/mapLayers/weather.js @@ -1,243 +1,644 @@ import L from "leaflet"; +/** + * Register BOM All WMS layer using provided URL + */ +export function registerBOMAllFloodLevelsLayer(vm, sourceUrl) { + const allUrl = `${sourceUrl}/MappingLayers/RequestBomLayer`; + vm.mapVM.registerPollingLayer("bomAll", { + label: "BOM Flood Levels", + menuGroup: "BOM Observations", + refreshMs: 600000, // 10 min + visibleByDefault: localStorage.getItem(`ov.bomAllFlood`) || false, + fetchFn: async () => { + return {}; + }, + drawFn: (layerGroup, _data) => { + const wmsLayer = L.tileLayer.wms(allUrl, { + layers: "IDN62011_all", + styles: "default", + format: "image/png", + transparent: true, + bgcolor: "0xFFFFFF", + version: "1.3.0", + crs: L.CRS.EPSG4326, + attribution: "Bureau of Meteorology", + }); + layerGroup.addLayer(wmsLayer); + }, + }); +} + +/** + * Register BOM Rainfall WMS layer from Beacon + */ +export function registerBOMRainfallLayer(vm, sourceUrl) { + const rainfallUrl = `${sourceUrl}/MappingLayers/RequestBomLayer`; + vm.mapVM.registerPollingLayer("bomRainfall", { + label: "BOM Rainfall (9am)", + menuGroup: "BOM Observations", + refreshMs: 600000, // 10 min + visibleByDefault: localStorage.getItem(`ov.bomRainfall`) || false, + fetchFn: async () => { + return {}; + }, + drawFn: (layerGroup, _data) => { + const wmsLayer = L.tileLayer.wms(rainfallUrl, { + layers: "IDZ20010_rainfall_9am", + styles: "default", + format: "image/png", + transparent: true, + bgcolor: "0xFFFFFF", + version: "1.3.0", + crs: L.CRS.EPSG4326, + attribution: "Bureau of Meteorology", + // Default NSW bounding box, but map will handle view + }); + layerGroup.addLayer(wmsLayer); + }, + }); +} -/* ══════════════════════════════════════════════════════════════ - * RainViewer Radar Overlay (animated, past ~2 h) - * API docs: https://www.rainviewer.com/api/weather-maps-api.html - * ══════════════════════════════════════════════════════════════ */ - -const RAINVIEWER_API = "https://api.rainviewer.com/public/weather-maps.json"; -const FRAME_INTERVAL_MS = 500; // default playback speed -const DATA_REFRESH_MS = 300000; // re-fetch frame list every 5 min - -/** - * Register an animated rainfall radar overlay powered by the RainViewer API. - * Loops through the past ~2 hours of composite radar frames with on-map - * playback controls (play / pause / step / scrub). - */ -export function registerRainRadarLayer(vm, map) { - /* ── state ─────────────────────────────────────────────────── */ - let frames = []; // [{ time, path }] - let host = null; - let tileLayers = []; // parallel to `frames` - let frameIdx = -1; - let playing = false; - let playTimer = null; - let dataTimer = null; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - let activeGroup = null; - let control = null; - let speed = FRAME_INTERVAL_MS; - - /* ── helpers ───────────────────────────────────────────────── */ - function buildTileLayer(framePath) { - return L.tileLayer( - `${host}${framePath}/512/{z}/{x}/{y}/2/1_1.png`, - { - pane: "pane-lowest-plus", - tileSize: 512, - zoomOffset: -1, - maxNativeZoom: 7, - maxZoom: 18, - opacity: 0, // start invisible; showFrame will reveal the active one - attribution: - 'RainViewer', - } - ); - } - - function fmtTime(unix) { - const d = new Date(unix * 1000); - return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); - } - - /* ── frame display ─────────────────────────────────────────── */ - function showFrame(idx) { - if (idx < 0 || idx >= tileLayers.length) return; - - // hide previous - if (frameIdx >= 0 && frameIdx < tileLayers.length) { - tileLayers[frameIdx].setOpacity(0); - } - frameIdx = idx; - tileLayers[frameIdx].setOpacity(0.6); - - // update control UI - if (control) { - const ts = control._container.querySelector(".rv-timestamp"); - const slider = control._container.querySelector(".rv-slider"); - if (ts) ts.textContent = fmtTime(frames[frameIdx].time); - if (slider) slider.value = frameIdx; - } - } - - function stepForward() { - showFrame((frameIdx + 1) % tileLayers.length); - } - - function stepBack() { - showFrame((frameIdx - 1 + tileLayers.length) % tileLayers.length); - } - - function play() { - if (playing) return; - playing = true; - updatePlayBtn(); - playTimer = setInterval(stepForward, speed); - } - - function pause() { - playing = false; - updatePlayBtn(); - if (playTimer) { clearInterval(playTimer); playTimer = null; } - } - - function togglePlay() { playing ? pause() : play(); } - - function updatePlayBtn() { - if (!control) return; - const btn = control._container.querySelector(".rv-play"); - if (btn) btn.textContent = playing ? "⏸" : "▶"; - } - - /* ── data loading ──────────────────────────────────────────── */ - async function loadFrames(layerGroup) { - try { - const res = await fetch(RAINVIEWER_API); - if (!res.ok) throw new Error(`RainViewer API ${res.status}`); - const json = await res.json(); - const past = json.radar?.past; - if (!past || past.length === 0) return; - - const newHost = json.host; - const pathsMatch = - host === newHost && - frames.length === past.length && - frames.every((f, i) => f.path === past[i].path); - - if (pathsMatch) return; // nothing changed - - // tear down old tile layers - tileLayers.forEach((tl) => { - if (layerGroup.hasLayer(tl)) layerGroup.removeLayer(tl); +/** + * Register BOM Radar WMS layer from Beacon + */ +export function registerBOMRadarLayer(vm, sourceUrl) { + const radarUrl = `${sourceUrl}/MappingLayers/RequestBomLayer`; + vm.mapVM.registerPollingLayer("bomRadar", { + label: "BOM Rain Radar Still", + menuGroup: "BOM Radar & Satellite", + refreshMs: 600000, // 10 min + visibleByDefault: localStorage.getItem(`ov.bomRadar`) || false, + fetchFn: async () => { + return {}; + }, + drawFn: (layerGroup, _data) => { + const wmsLayer = L.tileLayer.wms(radarUrl, { + layers: "IDR00010", + styles: "default", + format: "image/png", + transparent: true, + bgcolor: "0xFFFFFF", + version: "1.3.0", + crs: L.CRS.EPSG4326, + attribution: "Bureau of Meteorology", }); + layerGroup.addLayer(wmsLayer); + }, + }); +} + - host = newHost; - frames = past; - tileLayers = frames.map((f) => { - const tl = buildTileLayer(f.path); - layerGroup.addLayer(tl); - return tl; +/** + * Register BOM Zehr Himawari WMS layer from Beacon + */ +export function registerBOMSatTrueColorLayer(vm, sourceUrl) { + const radarUrl = `${sourceUrl}/MappingLayers/RequestBomLayer`; + vm.mapVM.registerPollingLayer("bomSatTrueColor", { + label: "BOM Satellite Composite True Colour", + menuGroup: "BOM Radar & Satellite", + refreshMs: 600000, // 10 min + visibleByDefault: localStorage.getItem(`ov.bomSatTrueColor`) || false, + fetchFn: async () => { + return {}; + }, + drawFn: (layerGroup, _data) => { + const wmsLayer = L.tileLayer.wms(radarUrl, { + layers: "IDE00435", + styles: "default", + format: "image/png", + transparent: true, + bgcolor: "0xFFFFFF", + version: "1.3.0", + crs: L.CRS.EPSG4326, + attribution: "Japan Meteorological Agency via Bureau of Meteorology" }); + layerGroup.addLayer(wmsLayer); + }, + }); +} + - // update slider range - if (control) { - const slider = control._container.querySelector(".rv-slider"); - if (slider) { slider.max = frames.length - 1; slider.value = frames.length - 1; } - } - - // show latest frame - frameIdx = -1; - showFrame(frames.length - 1); - } catch (err) { - console.warn("[RainViewer Radar] Failed to load frames:", err); - } - } - - /* ── Leaflet control for playback ──────────────────────────── */ - const RadarControl = L.Control.extend({ - options: { position: "bottomleft" }, - onAdd() { - const c = L.DomUtil.create("div", "leaflet-bar rv-control"); - L.DomEvent.disableClickPropagation(c); - L.DomEvent.disableScrollPropagation(c); - - c.innerHTML = ` -
    - - - - - --:-- - -
    `; - - c.querySelector(".rv-play").addEventListener("click", togglePlay); - c.querySelector(".rv-step-back").addEventListener("click", () => { pause(); stepBack(); }); - c.querySelector(".rv-step-fwd").addEventListener("click", () => { pause(); stepForward(); }); - c.querySelector(".rv-slider").addEventListener("input", (e) => { - pause(); - showFrame(parseInt(e.target.value, 10)); +/** + * Register BOM Thunderstorm Tracking from Beacon + */ +export function registerBOMThunderstormTrackingLayer(vm, sourceUrl) { + const radarUrl = `${sourceUrl}/MappingLayers/RequestBomLayer`; + vm.mapVM.registerPollingLayer("bomThunderstormTracking", { + label: "BOM Thunderstorm Tracking", + menuGroup: "BOM Radar & Satellite", + refreshMs: 600000, // 10 min + visibleByDefault: localStorage.getItem(`ov.bomThunderstormTracking`) || false, + fetchFn: async () => { + return {}; + }, + drawFn: (layerGroup, _data) => { + const wmsLayer = L.tileLayer.wms(radarUrl, { + layers: ["IDR00011","IDR00011_track"], + styles: "default", + format: "image/png", + transparent: true, + bgcolor: "0xFFFFFF", + version: "1.3.0", + crs: L.CRS.EPSG4326, + attribution: "Bureau of Meteorology", + opacity: 0.7 }); - c.querySelector(".rv-speed").addEventListener("change", (e) => { - speed = parseInt(e.target.value, 10); - if (playing) { pause(); play(); } + layerGroup.addLayer(wmsLayer); + }, + }); +} + +/** + * Register BOM Wind Layer from Beacon + */ +export function registerBOMWindLayer(vm, sourceUrl) { + const radarUrl = `${sourceUrl}/MappingLayers/RequestBomLayer`; + vm.mapVM.registerPollingLayer("bomWind", { + label: "BOM Wind Barbs & Raster", + menuGroup: "BOM Forecasts", + + refreshMs: 600000, // 10 min + visibleByDefault: localStorage.getItem(`ov.bomWind`) || false, + fetchFn: async () => { + return {}; + }, + drawFn: (layerGroup, _data) => { + const wmsLayer = L.tileLayer.wms(radarUrl, { + layers: ["IDY25026_windpt","IDY25026_windrast"], + styles: "default", + format: "image/png", + transparent: true, + bgcolor: "0xFFFFFF", + opacity: 0.5, // Adjust opacity for more transparency + version: "1.3.0", + crs: L.CRS.EPSG4326, + attribution: "Bureau of Meteorology" }); + layerGroup.addLayer(wmsLayer); + }, + }); +} - return c; +/** + * Register BOM MSLP from Beacon + */ +export function registerBOMMSLPLayer(vm, sourceUrl) { + const radarUrl = `${sourceUrl}/MappingLayers/RequestBomLayer`; + vm.mapVM.registerPollingLayer("bomMSLP", { + label: "BOM MSLP Contours", + menuGroup: "BOM Forecasts", + refreshMs: 600000, // 10 min + visibleByDefault: localStorage.getItem(`ov.bomMSLP`) || false, + fetchFn: async () => { + return {}; + }, + drawFn: (layerGroup, _data) => { + const wmsLayer = L.tileLayer.wms(radarUrl, { + layers: ["IDY25026_mslp"], + styles: "default", + format: "image/png", + transparent: true, + bgcolor: "0xFFFFFF", + version: "1.3.0", + crs: L.CRS.EPSG4326, + attribution: "Bureau of Meteorology" + }); + layerGroup.addLayer(wmsLayer); }, }); +} + - /* ── layer registration ────────────────────────────────────── */ - vm.mapVM.registerPollingLayer("rainRadar", { - label: "Rainfall Radar - Animated", - menuGroup: "Weather", - refreshMs: 0, - visibleByDefault: localStorage.getItem(`ov.rainRadar`) || false, +/** + * Register BOM Lightning from Beacon + */ +export function registerBOMLightningLayer(vm, sourceUrl) { + const radarUrl = `${sourceUrl}/MappingLayers/RequestBomLayer`; + vm.mapVM.registerPollingLayer("bomLightning", { + label: "BOM Lightning Strikes (0-2 hrs ago) Cloud to Ground", + menuGroup: "BOM Radar & Satellite", + refreshMs: 600000, // 10 min + visibleByDefault: localStorage.getItem(`ov.bomLightning`) || false, fetchFn: async () => { return {}; }, - drawFn: (layerGroup, data) => { - if (!data) return; + drawFn: (layerGroup, _data) => { + const wmsLayer = L.tileLayer.wms(radarUrl, { + layers: ["IDZ20019_c2g_2h"], + styles: "default", + format: "image/png", + transparent: true, + bgcolor: "0xFFFFFF", + version: "1.3.0", + crs: L.CRS.EPSG4326, + attribution: "Bureau of Meteorology", + }); + layerGroup.addLayer(wmsLayer); + }, + }); +} + + +/** + * Register BOM Lightning (2-24h) from Beacon + */ +export function registerBOMLightning24hLayer(vm, sourceUrl) { + const wmsUrl = `${sourceUrl}/MappingLayers/RequestBomLayer`; + vm.mapVM.registerPollingLayer("bomLightning24h", { + label: "BOM Lightning (2-24 hrs ago) Cloud to Ground", + menuGroup: "BOM Radar & Satellite", + refreshMs: 600000, + visibleByDefault: localStorage.getItem(`ov.bomLightning24h`) || false, + fetchFn: async () => ({}), + drawFn: (layerGroup, _data) => { + layerGroup.addLayer( + L.tileLayer.wms(wmsUrl, { + layers: "IDZ20019_c2g_2-24h", + styles: "default", + format: "image/png", + transparent: true, + version: "1.3.0", + crs: L.CRS.EPSG4326, + attribution: "Bureau of Meteorology", + }) + ); + }, + }); +} + + +/** + * Register BOM Tsunami Warning from Beacon + */ +export function registerBOMTsunamiLayer(vm, sourceUrl) { + const wmsUrl = `${sourceUrl}/MappingLayers/RequestBomLayer`; + vm.mapVM.registerPollingLayer("bomTsunami", { + label: "BOM Tsunami Warning", + menuGroup: "BOM Warnings", + refreshMs: 300000, // 5 min – critical warning + visibleByDefault: localStorage.getItem(`ov.bomTsunami`) || false, + fetchFn: async () => ({}), + drawFn: (layerGroup, _data) => { + layerGroup.addLayer( + L.tileLayer.wms(wmsUrl, { + layers: ["IDZ20002", "IDZ20002_info"], + styles: "default", + format: "image/png", + transparent: true, + version: "1.3.0", + crs: L.CRS.EPSG4326, + attribution: "Bureau of Meteorology", + }) + ); + }, + }); +} + + +/** + * Register BOM Tropical Cyclone Tracking from Beacon + */ +export function registerBOMTropicalCycloneLayer(vm, sourceUrl) { + const wmsUrl = `${sourceUrl}/MappingLayers/RequestBomLayer`; + vm.mapVM.registerPollingLayer("bomTropicalCyclone", { + label: "BOM Tropical Cyclone Tracking", + menuGroup: "BOM Warnings", + refreshMs: 600000, + visibleByDefault: localStorage.getItem(`ov.bomTropicalCyclone`) || false, + fetchFn: async () => ({}), + drawFn: (layerGroup, _data) => { + layerGroup.addLayer( + L.tileLayer.wms(wmsUrl, { + layers: [ + "IDZ20009_trackarea", + "IDZ20009_threatarea", + "IDZ20009_windarea", + "IDZ20009_track", + "IDZ20009_fix", + "IDZ20009_name", + ], + styles: "default", + format: "image/png", + transparent: true, + version: "1.3.0", + crs: L.CRS.EPSG4326, + attribution: "Bureau of Meteorology", + }) + ); + }, + }); +} + + +/** + * Register BOM Fire Danger Rating (today) from Beacon + */ +export function registerBOMFireDangerRatingLayer(vm, sourceUrl) { + const wmsUrl = `${sourceUrl}/MappingLayers/RequestBomLayer`; + vm.mapVM.registerPollingLayer("bomFireDangerRating", { + label: "BOM Fire Danger Rating (Today)", + menuGroup: "BOM Forecasts", + refreshMs: 600000, + visibleByDefault: localStorage.getItem(`ov.bomFireDangerRating`) || false, + fetchFn: async () => ({}), + drawFn: (layerGroup, _data) => { + layerGroup.addLayer( + L.tileLayer.wms(wmsUrl, { + layers: "IDZ20022000", + styles: "default", + format: "image/png", + transparent: true, + version: "1.3.0", + crs: L.CRS.EPSG4326, + opacity: 0.6, + attribution: "Bureau of Meteorology", + }) + ); + }, + }); +} + + +/** + * Register BOM Heatwave Forecast (today +2 days) from Beacon + */ +export function registerBOMHeatwaveLayer(vm, sourceUrl) { + const wmsUrl = `${sourceUrl}/MappingLayers/RequestBomLayer`; + vm.mapVM.registerPollingLayer("bomHeatwave", { + label: "BOM Heatwave Forecast (Days +0 to +2)", + menuGroup: "BOM Forecasts", + refreshMs: 600000, + visibleByDefault: localStorage.getItem(`ov.bomHeatwave`) || false, + fetchFn: async () => ({}), + drawFn: (layerGroup, _data) => { + layerGroup.addLayer( + L.tileLayer.wms(wmsUrl, { + layers: "IDY10012_day0", + styles: "default", + format: "image/png", + transparent: true, + version: "1.3.0", + crs: L.CRS.EPSG4326, + opacity: 0.6, + attribution: "Bureau of Meteorology", + }) + ); + }, + }); +} + + +/** + * Register BOM Hazardous Surf Warning (today) from Beacon + */ +export function registerBOMHazardousSurfLayer(vm, sourceUrl) { + const wmsUrl = `${sourceUrl}/MappingLayers/RequestBomLayer`; + vm.mapVM.registerPollingLayer("bomHazardousSurf", { + label: "BOM Hazardous Surf Warning (Today)", + menuGroup: "BOM Warnings", + refreshMs: 600000, + visibleByDefault: localStorage.getItem(`ov.bomHazardousSurf`) || false, + fetchFn: async () => ({}), + drawFn: (layerGroup, _data) => { + layerGroup.addLayer( + L.tileLayer.wms(wmsUrl, { + layers: "IDZ20017000", + styles: "default", + format: "image/png", + transparent: true, + version: "1.3.0", + crs: L.CRS.EPSG4326, + attribution: "Bureau of Meteorology", + }) + ); + }, + }); +} + + +/** + * Register BOM Coastal Hazard Warning from Beacon + */ +export function registerBOMCoastalHazardLayer(vm, sourceUrl) { + const wmsUrl = `${sourceUrl}/MappingLayers/RequestBomLayer`; + vm.mapVM.registerPollingLayer("bomCoastalHazard", { + label: "BOM Coastal Hazard Warning", + menuGroup: "BOM Warnings", + refreshMs: 600000, + visibleByDefault: localStorage.getItem(`ov.bomCoastalHazard`) || false, + fetchFn: async () => ({}), + drawFn: (layerGroup, _data) => { + layerGroup.addLayer( + L.tileLayer.wms(wmsUrl, { + layers: "IDZ20023", + styles: "default", + format: "image/png", + transparent: true, + version: "1.3.0", + crs: L.CRS.EPSG4326, + attribution: "Bureau of Meteorology", + }) + ); + }, + }); +} + + +/** + * Register BOM Road Weather Alert from Beacon + */ +export function registerBOMRoadWeatherLayer(vm, sourceUrl) { + const wmsUrl = `${sourceUrl}/MappingLayers/RequestBomLayer`; + vm.mapVM.registerPollingLayer("bomRoadWeather", { + label: "BOM Road Weather Alert (Metro)", + menuGroup: "BOM Warnings", + refreshMs: 600000, + visibleByDefault: localStorage.getItem(`ov.bomRoadWeather`) || false, + fetchFn: async () => ({}), + drawFn: (layerGroup, _data) => { + layerGroup.addLayer( + L.tileLayer.wms(wmsUrl, { + layers: "IDZ20014", + styles: "default", + format: "image/png", + transparent: true, + version: "1.3.0", + crs: L.CRS.EPSG4326, + attribution: "Bureau of Meteorology", + }) + ); + }, + }); +} + - // Clean up any previous cycle (toggle off → on) - pause(); - if (dataTimer) { clearInterval(dataTimer); dataTimer = null; } - if (control) { map.removeControl(control); control = null; } - tileLayers = []; - frames = []; - frameIdx = -1; - activeGroup = layerGroup; +/** + * Register BOM Surface Obs – Wind Gust (km/h) from Beacon + */ +export function registerBOMSurfaceGustLayer(vm, sourceUrl) { + const wmsUrl = `${sourceUrl}/MappingLayers/RequestBomLayer`; + vm.mapVM.registerPollingLayer("bomSurfaceGust", { + label: "BOM Surface Obs Wind Gust (km/h)", + menuGroup: "BOM Observations", + refreshMs: 600000, + visibleByDefault: localStorage.getItem(`ov.bomSurfaceGust`) || false, + fetchFn: async () => ({}), + drawFn: (layerGroup, _data) => { + layerGroup.addLayer( + L.tileLayer.wms(wmsUrl, { + layers: "IDZ20010_gustkmh", + styles: "default", + format: "image/png", + transparent: true, + version: "1.3.0", + crs: L.CRS.EPSG4326, + attribution: "Bureau of Meteorology", + }) + ); + }, + }); +} - // Add playback control to the map - control = new RadarControl(); - map.addControl(control); - // Clean up control + timers when the layerGroup is removed from the map - layerGroup.on("remove", cleanup); +/** + * Register BOM Surface Obs – Air Temperature (°C) from Beacon + */ +export function registerBOMSurfaceTempLayer(vm, sourceUrl) { + const wmsUrl = `${sourceUrl}/MappingLayers/RequestBomLayer`; + vm.mapVM.registerPollingLayer("bomSurfaceTemp", { + label: "BOM Surface Obs Air Temp (°C)", + menuGroup: "BOM Observations", + refreshMs: 600000, + visibleByDefault: localStorage.getItem(`ov.bomSurfaceTemp`) || false, + fetchFn: async () => ({}), + drawFn: (layerGroup, _data) => { + layerGroup.addLayer( + L.tileLayer.wms(wmsUrl, { + layers: "IDZ20010_air_temperature", + styles: "default", + format: "image/png", + transparent: true, + version: "1.3.0", + crs: L.CRS.EPSG4326, + attribution: "Bureau of Meteorology", + }) + ); + }, + }); +} - // Initial load + periodic refresh of frame list - loadFrames(layerGroup); - dataTimer = setInterval(() => loadFrames(layerGroup), DATA_REFRESH_MS); - // Auto-play - play(); +/** + * Register BOM Fire Behaviour Index (AFDRS) from Beacon + */ +export function registerBOMFireBehaviourIndexLayer(vm, sourceUrl) { + const wmsUrl = `${sourceUrl}/MappingLayers/RequestBomLayer`; + vm.mapVM.registerPollingLayer("bomFireBehaviourIndex", { + label: "BOM Fire Behaviour Index (AFDRS)", + menuGroup: "BOM Forecasts", + refreshMs: 600000, + visibleByDefault: localStorage.getItem(`ov.bomFireBehaviourIndex`) || false, + fetchFn: async () => ({}), + drawFn: (layerGroup, _data) => { + layerGroup.addLayer( + L.tileLayer.wms(wmsUrl, { + layers: "IDZ10135", + styles: "default", + format: "image/png", + transparent: true, + version: "1.3.0", + crs: L.CRS.EPSG4326, + opacity: 0.6, + attribution: "Bureau of Meteorology", + }) + ); }, }); +} + - function cleanup() { - pause(); - if (dataTimer) { clearInterval(dataTimer); dataTimer = null; } - if (control) { map.removeControl(control); control = null; } - tileLayers = []; - frames = []; - frameIdx = -1; - activeGroup = null; - } +/** + * Register BOM Hazardous Wind Onset (next 6h) from Beacon + */ +export function registerBOMHazardousWindLayer(vm, sourceUrl) { + const wmsUrl = `${sourceUrl}/MappingLayers/RequestBomLayer`; + vm.mapVM.registerPollingLayer("bomHazardousWind", { + label: "BOM Hazardous Wind Onset (6 hrs)", + menuGroup: "BOM Forecasts", + refreshMs: 600000, + visibleByDefault: localStorage.getItem(`ov.bomHazardousWind`) || false, + fetchFn: async () => ({}), + drawFn: (layerGroup, _data) => { + layerGroup.addLayer( + L.tileLayer.wms(wmsUrl, { + layers: "IDZ71153", + styles: "default", + format: "image/png", + transparent: true, + version: "1.3.0", + crs: L.CRS.EPSG4326, + opacity: 0.6, + attribution: "Bureau of Meteorology", + }) + ); + }, + }); } + + +/** + * Register BOM Flood Warning Boundaries from Beacon + */ +export function registerBOMFloodWarningBoundariesLayer(vm, sourceUrl) { + const wmsUrl = `${sourceUrl}/MappingLayers/RequestBomLayer`; + vm.mapVM.registerPollingLayer("bomFloodWarningBoundaries", { + label: "BOM Flood Warning Boundaries", + menuGroup: "BOM Observations", + refreshMs: 3600000, // 1 hr – reference data + visibleByDefault: localStorage.getItem(`ov.bomFloodWarningBoundaries`) || false, + fetchFn: async () => ({}), + drawFn: (layerGroup, _data) => { + layerGroup.addLayer( + L.tileLayer.wms(wmsUrl, { + layers: "IDM00017", + styles: "default", + format: "image/png", + transparent: true, + version: "1.3.0", + crs: L.CRS.EPSG4326, + attribution: "Bureau of Meteorology", + }) + ); + }, + }); +} + + +/** + * Register BOM Fire Weather Districts from Beacon + */ +export function registerBOMFireWeatherDistrictsLayer(vm, sourceUrl) { + const wmsUrl = `${sourceUrl}/MappingLayers/RequestBomLayer`; + vm.mapVM.registerPollingLayer("bomFireWeatherDistricts", { + label: "BOM Fire Weather Districts", + menuGroup: "BOM Observations", + refreshMs: 3600000, // 1 hr – reference data + visibleByDefault: localStorage.getItem(`ov.bomFireWeatherDistricts`) || false, + fetchFn: async () => ({}), + drawFn: (layerGroup, _data) => { + layerGroup.addLayer( + L.tileLayer.wms(wmsUrl, { + layers: ["IDM00007", "IDM00021"], + styles: "default", + format: "image/png", + transparent: true, + version: "1.3.0", + crs: L.CRS.EPSG4326, + attribution: "Bureau of Meteorology", + }) + ); + }, + }); +} \ No newline at end of file diff --git a/src/pages/tasking/markers/assetMarker.js b/src/pages/tasking/markers/assetMarker.js index 9fc9e679..dc46f2a9 100644 --- a/src/pages/tasking/markers/assetMarker.js +++ b/src/pages/tasking/markers/assetMarker.js @@ -235,8 +235,13 @@ function bindPopupWithKO(ko, marker, vm, asset, popupVm) { // Unbind after popup is fully closed for visual cleanliness const closeHandler = (e) => { const el = e.popup?.getContent(); - vm.mapVM.clearCrowFliesLine(); - vm.mapVM.clearRoutes?.(); + // Don't clear routes/crow-flies if the popup was closed as a + // side-effect of a flyToBounds animation (e.g. spider collapse + // from a zoom change after drawing a route). + if (!vm.mapVM._flyingToBounds) { + vm.mapVM.clearCrowFliesLine(); + vm.mapVM.clearRoutes?.(); + } vm.mapVM.clearOpen?.(); asset.matchingTeamsInView()?.length !== 0 && asset.matchingTeamsInView()[0].onPopupClose(); diff --git a/src/pages/tasking/markers/jobMarker.js b/src/pages/tasking/markers/jobMarker.js index ad5ed062..cc764e3c 100644 --- a/src/pages/tasking/markers/jobMarker.js +++ b/src/pages/tasking/markers/jobMarker.js @@ -2,7 +2,7 @@ var L = require('leaflet'); var ko = require('knockout'); import { buildJobPopupKO } from '../components/job_popup.js'; -import { makeShapeIcon, styleForJob } from '../components/job_icon.js'; +import { makeShapeIcon, styleForJob, buildPulseRingSvg } from '../components/job_icon.js'; import { makePopupNode, bindKoToPopup, unbindKoFromPopup, deferPopupUpdate } from '../utils/popup_dom_utils.js'; @@ -15,15 +15,23 @@ export function addOrUpdateJobMarker(ko, map, vm, job) { if (!(Number.isFinite(lat) && Number.isFinite(lng)) || id == null) return; - const type = job.typeName?.() || "default"; - const { layerGroup, markers } = ensureGroup(vm, map, type); + const isRescue = (job.priorityName?.() || '').toLowerCase() === 'rescue'; + const clusterRescue = !!vm.config?.clusterRescueJobs?.(); + const clusteringOn = vm.mapVM.clusteringEnabled; + const targetLayer = !clusteringOn + ? vm.mapVM.unclusteredJobLayer // clustering disabled – plain layer + : (isRescue && !clusterRescue) + ? vm.mapVM.rescueJobLayer // rescue excluded from clusters + : vm.mapVM.jobClusterGroup; // normal clustering + const markers = vm.mapVM.jobMarkerIndex; + const pulseLayer = vm.mapVM.jobPulseLayer; const style = styleForJob(job); const html = buildJobPopupKO(); const contentEl = makePopupNode(html, 'job-pop-root') var popup = L.popup({ minWidth: 380, - maxWidth: 380, + maxWidth: 760, minHeight: 300, autoPan: true, autoPanPadding: [16, 16] @@ -41,18 +49,34 @@ export function addOrUpdateJobMarker(ko, map, vm, job) { const node = makePopupNode(html, 'job-pop-root'); const m = markers.get(id); const pt = m.getLatLng(); - if (pt.lat !== lat || pt.lng !== lng) m.setLatLng([lat, lng]); + // When spiderfied, _latlng is the spider position — don't overwrite + // it or the spider layout breaks. The real position is stored in + // _preSpiderfyLatlng and will be restored on unspiderfy. + if (!m._spiderLeg && (pt.lat !== lat || pt.lng !== lng)) m.setLatLng([lat, lng]); const key = JSON.stringify(style); if (m._styleKey !== key) { m.setIcon(makeShapeIcon(style)); m._styleKey = key; } + m._priorityColor = style.fill || '#6b7280'; if (!m._popupBound) { m.setPopupContent(node); wireKoForPopup(ko, m, job, vm, popupVM); } - // keep the "New" ring in correct state - upsertPulseRing(layerGroup, job, m); + // keep the "New" ring and _isNew flag in correct state + upsertPulseRing(pulseLayer, job, m); + const wasNew = m._isNew; + m._isNew = (job.statusName?.() || '').toLowerCase() === 'new'; + if (wasNew !== m._isNew && vm.mapVM.clusteringEnabled) { + vm.mapVM.jobClusterGroup.refreshClusters(m); + } // ensure we have a status subscription exactly once if (!m._pulseSubs || m._pulseSubs.length === 0) { (m._pulseSubs ||= []).push( - job.statusName.subscribe(() => upsertPulseRing(layerGroup, job, m)) + job.statusName.subscribe(() => { + upsertPulseRing(pulseLayer, job, m); + const prev = m._isNew; + m._isNew = (job.statusName?.() || '').toLowerCase() === 'new'; + if (prev !== m._isNew && vm.mapVM.clusteringEnabled) { + vm.mapVM.jobClusterGroup.refreshClusters(m); + } + }) ); } @@ -63,13 +87,24 @@ export function addOrUpdateJobMarker(ko, map, vm, job) { marker._styleKey = JSON.stringify(style); - marker.addTo(layerGroup); + marker._isRescue = isRescue; + marker._isNew = (job.statusName?.() || '').toLowerCase() === 'new'; + marker._priorityColor = style.fill || '#6b7280'; + marker.addTo(targetLayer); markers.set(id, marker); job.marker = marker; - upsertPulseRing(layerGroup, job, marker); + upsertPulseRing(pulseLayer, job, marker); (marker._pulseSubs ||= []).push( - job.statusName.subscribe(() => upsertPulseRing(layerGroup, job, marker)) + job.statusName.subscribe(() => { + upsertPulseRing(pulseLayer, job, marker); + const wasNew = marker._isNew; + marker._isNew = (job.statusName?.() || '').toLowerCase() === 'new'; + // If the flag changed, refresh ancestor cluster icons + if (wasNew !== marker._isNew && vm.mapVM.clusteringEnabled) { + vm.mapVM.jobClusterGroup.refreshClusters(marker); + } + }) ); @@ -82,6 +117,9 @@ export function addOrUpdateJobMarker(ko, map, vm, job) { job.address.longitude.subscribe(() => safeMove(marker, job)), ]; + // Sync pulse ring visibility after adding + vm.mapVM._syncPulseRings?.(); + return marker; } @@ -89,32 +127,35 @@ export function removeJobMarker(vm, jobOrId) { const id = typeof jobOrId === 'number' ? jobOrId : jobOrId?.id?.(); if (id == null) return; - // find marker in any group - for (const { layerGroup, markers } of vm.mapVM.jobMarkerGroups.values()) { - const m = markers.get(id); - if (!m) continue; - - // dispose KO subscriptions - (m._subs || []).forEach(s => { try { s.dispose?.(); } catch { /* empty */ } }); - m._subs = []; - - // unbind KO from popup if ever opened - const popupEl = m.getPopup()?.getElement?.(); - if (popupEl && popupEl.__ko_bound__) { try { ko.cleanNode(popupEl); } catch { /* empty */ } delete popupEl.__ko_bound__; } - - if (m._pulseRing) { - m._pulseRing._detach?.(); - (m._pulseSubs || []).forEach(s => { try { s.dispose?.(); } catch { /* empty */ } }); - m._pulseSubs = []; - layerGroup.removeLayer(m._pulseRing); - m._pulseRing = null; - } + const markers = vm.mapVM.jobMarkerIndex; + const clusterGroup = vm.mapVM.jobClusterGroup; + const rescueLayer = vm.mapVM.rescueJobLayer; + const pulseLayer = vm.mapVM.jobPulseLayer; + + const m = markers.get(id); + if (!m) return; + + // dispose KO subscriptions + (m._subs || []).forEach(s => { try { s.dispose?.(); } catch { /* empty */ } }); + m._subs = []; + + // unbind KO from popup if ever opened + const popupEl = m.getPopup()?.getElement?.(); + if (popupEl && popupEl.__ko_bound__) { try { ko.cleanNode(popupEl); } catch { /* empty */ } delete popupEl.__ko_bound__; } - layerGroup.removeLayer(m); - markers.delete(id); - break; + if (m._pulseRing) { + m._pulseRing._detach?.(); + (m._pulseSubs || []).forEach(s => { try { s.dispose?.(); } catch { /* empty */ } }); + m._pulseSubs = []; + pulseLayer.removeLayer(m._pulseRing); + m._pulseRing = null; } + // Remove from whichever layer it's in + if (clusterGroup.hasLayer(m)) clusterGroup.removeLayer(m); + if (rescueLayer.hasLayer(m)) rescueLayer.removeLayer(m); + markers.delete(id); + const job = vm.jobsById?.get?.(id); if (job) job.marker = null; } @@ -131,11 +172,14 @@ function upsertPulseRing(layerGroup, job, marker) { const ringSize = [Math.round(baseSize[0] * k), Math.round(baseSize[1] * k)]; const ringAnchor = [Math.round(baseAnchor[0] * k), Math.round(baseAnchor[1] * k)]; + const shape = styleForJob(job).shape || 'circle'; + const pulseSvg = buildPulseRingSvg(shape, ringSize[0], ringSize[1]); + const ring = L.marker(marker.getLatLng(), { pane: 'pane-tippy-top', icon: L.divIcon({ className: 'pulse-ring-icon', - html: '
    ', + html: pulseSvg, iconSize: ringSize, iconAnchor: ringAnchor }), @@ -160,15 +204,11 @@ function upsertPulseRing(layerGroup, job, marker) { } // --- internals --- -function ensureGroup(vm, map, typeName) { - if (!vm.mapVM.jobMarkerGroups.has(typeName)) { - const group = L.layerGroup().addTo(map); - vm.mapVM.jobMarkerGroups.set(typeName, { layerGroup: group, markers: new Map() }); - } - return vm.mapVM.jobMarkerGroups.get(typeName); -} function safeMove(marker, job) { + // Skip if the marker is currently spiderfied — moving it would break the + // spider layout. The real position is restored on unspiderfy. + if (marker._spiderLeg) return; const lat = +job.address.latitude?.(); const lng = +job.address.longitude?.(); if (Number.isFinite(lat) && Number.isFinite(lng)) marker.setLatLng([lat, lng]); @@ -185,6 +225,26 @@ function wireKoForPopup(ko, marker, job, vm, popupVM) { job.onPopupOpen && job.onPopupOpen(); popupVM.updatePopup?.(); deferPopupUpdate(e.popup); + + // Auto-widen: if the popup overflows the viewport, switch to 2-col + requestAnimationFrame(() => { + const wrapper = e.popup.getElement(); + if (!wrapper) return; + const jp = wrapper.querySelector('.job-popup'); + if (!jp) return; + // reset first so we measure single-col height + jp.classList.remove('job-popup--wide'); + e.popup.update(); + requestAnimationFrame(() => { + const rect = wrapper.getBoundingClientRect(); + const overflows = rect.bottom > window.innerHeight - 8 + || rect.top < 8; + if (overflows) { + jp.classList.add('job-popup--wide'); + e.popup.update(); + } + }); + }); }); marker.on('popupclose', e => { const el = e.popup.getContent(); @@ -193,8 +253,13 @@ function wireKoForPopup(ko, marker, job, vm, popupVM) { unbindKoFromPopup(ko, el); }, 250); // 250ms matches Leaflet's default fade animation job.onPopupClose && job.onPopupClose(); - vm.mapVM.clearCrowFliesLine(); - vm.mapVM.clearRoutes(); + // Don't clear routes/crow-flies if the popup was closed as a + // side-effect of a flyToBounds animation (e.g. spider collapse + // from a zoom change after drawing a route). + if (!vm.mapVM._flyingToBounds) { + vm.mapVM.clearCrowFliesLine(); + vm.mapVM.clearRoutes(); + } vm.mapVM.clearOpen?.(); if (vm?.mapVM?.openPopup()?.ref === job) vm.mapVM.clearOpen(); diff --git a/src/pages/tasking/models/Asset.js b/src/pages/tasking/models/Asset.js index 09fbaf0c..193f48ea 100644 --- a/src/pages/tasking/models/Asset.js +++ b/src/pages/tasking/models/Asset.js @@ -2,7 +2,7 @@ import ko from "knockout"; import { fmtRelative, safeStr } from "../utils/common.js"; -export function Asset(data = {}) { +export function Asset(data = {}, deps = {}) { const self = this; self.id = ko.observable(data.properties.id ?? null); self.name = ko.observable(data.properties.name ?? ""); @@ -30,14 +30,13 @@ export function Asset(data = {}) { // Force updates for computed observables using fmtRelative every 30 seconds self._relativeUpdateTick = ko.observable(0); - - setInterval(() => { - self._relativeUpdateTick(self._relativeUpdateTick() + 1); - }, 1000 * 30); + const sharedRelativeTick = (typeof deps.relativeUpdateTick === "function") + ? deps.relativeUpdateTick + : null; // Patch computeds to depend on _relativeUpdateTick self.lastSeenJustAgoText = ko.pureComputed(() => { - self._relativeUpdateTick(); // dependency + sharedRelativeTick(); // shared dependency const v = safeStr(self.lastSeen?.()); if (!v) return ""; const d = new Date(v); @@ -46,7 +45,7 @@ export function Asset(data = {}) { }); self.lastSeenText = ko.pureComputed(() => { - self._relativeUpdateTick(); // dependency + sharedRelativeTick(); // shared dependency const v = safeStr(self.lastSeen?.()); if (!v) return ""; const d = new Date(v); @@ -55,7 +54,7 @@ export function Asset(data = {}) { }); self.talkgroupLastUpdatedText = ko.pureComputed(() => { - self._relativeUpdateTick(); // dependency + sharedRelativeTick(); // shared dependency const v = safeStr(self.talkgroupLastUpdated?.()); if (!v) return ""; const d = new Date(v); diff --git a/src/pages/tasking/models/HistoryEntry.js b/src/pages/tasking/models/HistoryEntry.js index 80eddfb3..b327d416 100644 --- a/src/pages/tasking/models/HistoryEntry.js +++ b/src/pages/tasking/models/HistoryEntry.js @@ -4,7 +4,7 @@ import ko from "knockout"; import moment from "moment"; -export function HistoryEntry(data = {}) { +export function HistoryEntry(data = {}, deps = {}) { const self = this; // --- core fields --- @@ -27,12 +27,14 @@ export function HistoryEntry(data = {}) { // "time ago" label self.timeStampAgo = ko.pureComputed(() => { + if (deps.relativeUpdateTick) deps.relativeUpdateTick(); const v = self.timeStampRaw(); return v ? moment(v).fromNow() : ""; }); // "time ago" label self.timeLoggedAgo = ko.pureComputed(() => { + if (deps.relativeUpdateTick) deps.relativeUpdateTick(); const v = self.timeLoggedRaw(); return v ? moment(v).fromNow() : ""; }); diff --git a/src/pages/tasking/models/Job.js b/src/pages/tasking/models/Job.js index c960afca..56537c0e 100644 --- a/src/pages/tasking/models/Job.js +++ b/src/pages/tasking/models/Job.js @@ -5,6 +5,7 @@ import { Entity } from "./Entity.js"; import { Address } from "./Address.js"; import { Tag } from "./Tag.js"; import { Sector } from "./Sector.js"; +import { UnacceptedNotification } from "./UnacceptedNotification.js"; import { openURLInBeacon } from '../utils/chromeRunTime.js'; import { jobsToUI } from "../utils/jobTypesToUI.js"; @@ -23,6 +24,10 @@ export function Job(data = {}, deps = {}) { flyToJob = (_job) => {/* noop */ }, attachAndFillTimelineModal = (_job) => { /* noop */ }, fetchUnacknowledgedJobNotifications = async (_job) => ([]), + acknowledgeUnacceptedNotification = async (_notificationId) => ({}), + relativeUpdateTick = null, + notifySuccess = (_message) => undefined, + notifyError = (_message) => undefined, drawJobTargetRing = (_job) => { /* noop */ }, fetchUnresolvedActionsLog = async (_job) => { /* noop */ }, fetchSuppliersForJob = async (_jobId) => ([]), @@ -95,6 +100,10 @@ export function Job(data = {}, deps = {}) { self.unacceptedNotifications = ko.observableArray([]); + self.hasUnacceptedNotifications = ko.pureComputed(() => { + return Array.isArray(self.unacceptedNotifications()) && self.unacceptedNotifications().length > 0; + }); + self.instantTask = new InstantTaskViewModel({ job: self, map: map, filteredTeams: filteredTeams }); @@ -289,13 +298,50 @@ export function Job(data = {}, deps = {}) { self.instantTask.popupActive(isOpen || self.expanded()); }); + self.refreshUnacceptedNotifications = async function () { + if (!self.icemsIncidentIdentifier()) return; + + try { + const data = await fetchUnacknowledgedJobNotifications(self); + + const notificationDeps = { + acknowledgeNotification: async (notificationId) => { + return await acknowledgeUnacceptedNotification(notificationId); + }, + fetchMessageById: async (messageId) => { + return await deps.fetchMessageById(messageId); + }, + acknowledgeIumMessage: async (notificationId, messageData) => { + return await deps.acknowledgeIumMessage(notificationId, messageData); + }, + relativeUpdateTick, + onAcknowledged: (notificationVm) => { + self.unacceptedNotifications.remove((n) => String(n?.id?.()) === String(notificationVm?.id?.())); + notifySuccess('Notification acknowledged.'); + }, + onAcknowledgeError: () => { + notifyError('Failed to acknowledge notification.'); + } + }; + + // Filter to ensure notifications belong to this job + const models = (data || []) + .filter(n => String(n?.JobId) === String(self.id())) + .map(n => new UnacceptedNotification(n, notificationDeps)); + self.unacceptedNotifications(models); + } catch (err) { + console.error("Failed to fetch unacknowledged notifications:", err); + notifyError('Failed to fetch unacknowledged notifications.'); + } + }; + // ---- UNACCEPTED NOTIFICATIONS POLLING ---- const unacceptedNotificationsInterval = makeFilteredInterval(() => { // extra guard: only if ICEMS id exists if (!self.icemsIncidentIdentifier()) return; console.log("Polling unaccepted notifications for job", self.id()); - fetchUnacknowledgedJobNotifications(self); + self.refreshUnacceptedNotifications(); }, 30000, { runImmediately: true }); self.startUnacceptedNotificationsPolling = function () { @@ -306,17 +352,14 @@ export function Job(data = {}, deps = {}) { unacceptedNotificationsInterval.stop(); }; - // Restart / stop polling when identifiers or filters change - self.icemsIncidentIdentifier.subscribe((id) => { - if (id && self.isFilteredIn()) { - self.startUnacceptedNotificationsPolling(); - } else { - self.stopUnacceptedNotificationsPolling(); - } + // Computed that combines both conditions to avoid duplicate polling subscriptions + self.shouldPollUnacceptedNotifications = ko.pureComputed(() => { + return self.icemsIncidentIdentifier() && self.isFilteredIn(); }); - self.isFilteredIn.subscribe((flag) => { - if (flag && self.icemsIncidentIdentifier()) { + // Single subscription to the combined condition prevents duplicate start calls + self.shouldPollUnacceptedNotifications.subscribe((shouldPoll) => { + if (shouldPoll) { self.startUnacceptedNotificationsPolling(); } else { self.stopUnacceptedNotificationsPolling(); @@ -362,14 +405,14 @@ export function Job(data = {}, deps = {}) { } - self.incompleteTaskingsOnly = ko.computed(() => + self.incompleteTaskingsOnly = ko.pureComputed(() => self.taskings().filter(t => { const status = t.currentStatus(); return status !== "Complete" && status !== "CalledOff"; }) ); - self.sortedTaskings = ko.computed(() => + self.sortedTaskings = ko.pureComputed(() => self.taskings().slice().sort((a, b) => new Date(a.currentStatusTime) - new Date(b.currentStatusTime) ) diff --git a/src/pages/tasking/models/OpsLogEntry.js b/src/pages/tasking/models/OpsLogEntry.js index 4da52513..bc52a0ff 100644 --- a/src/pages/tasking/models/OpsLogEntry.js +++ b/src/pages/tasking/models/OpsLogEntry.js @@ -5,7 +5,7 @@ import moment from "moment"; import { Entity } from "./Entity.js"; import { Tag } from "./Tag.js"; -export function OpsLogEntry(data = {}) { +export function OpsLogEntry(data = {}, deps = {}) { const self = this; // --- core ids/links --- @@ -63,8 +63,12 @@ export function OpsLogEntry(data = {}) { self.actionReminder = ko.observable(data.ActionReminder ?? null); // --- quality-of-life computed values --- - const _tick = ko.observable(Date.now()); - setInterval(() => _tick(Date.now()), 60000); // refresh “ago” labels every 60s + // Use shared 30s ticker if provided, otherwise use local 60s timer + const _tick = deps.relativeUpdateTick || (() => { + const localTick = ko.observable(Date.now()); + setInterval(() => localTick(Date.now()), 60000); + return localTick; + })(); self.timeLoggedFormatted = ko.pureComputed(() => { const v = self.timeLogged(); diff --git a/src/pages/tasking/models/Team.js b/src/pages/tasking/models/Team.js index 3e071917..d9129860 100644 --- a/src/pages/tasking/models/Team.js +++ b/src/pages/tasking/models/Team.js @@ -7,6 +7,11 @@ import { openURLInBeacon } from '../utils/chromeRunTime.js'; import { Enum } from '../utils/enum.js'; +// Shared across all Team instances — single localStorage key +const _capKey = 'lh_showCapabilities'; +const _showCapabilities = ko.observable(localStorage.getItem(_capKey) !== 'false'); +_showCapabilities.subscribe(v => localStorage.setItem(_capKey, v ? 'true' : 'false')); + export function Team(data = {}, deps = {}) { // eslint-disable-next-line @typescript-eslint/no-this-alias @@ -33,10 +38,15 @@ export function Team(data = {}, deps = {}) { openSMSTeamModal = () => { }, isTeamPinned = () => false, toggleTeamPinned = () => false, + saveTaskingSequence = () => Promise.resolve(), } = deps; self.isFilteredIn = ko.observable(false); + // capabilities visibility (shared singleton) + self.showCapabilities = _showCapabilities; + self.toggleCapabilities = function () { _showCapabilities(!_showCapabilities()); }; + // pinning self.isPinned = ko.pureComputed(() => { try { return !!isTeamPinned(self.id()); } catch (e) { return false; } @@ -287,9 +297,74 @@ export function Team(data = {}, deps = {}) { return true; } return false; - }).sort((a, b) => new Date(b.currentStatusTime()) - new Date(a.currentStatusTime())); + }).sort((a, b) => { + const seqA = a.sequence(); + const seqB = b.sequence(); + if (seqA !== seqB) return seqA - seqB; + return new Date(b.currentStatusTime()) - new Date(a.currentStatusTime()); + }); }); + // ── Reorder mode ── + self.reorderMode = ko.observable(false); + self.reorderList = ko.observableArray([]); + self.reorderSaving = ko.observable(false); + + self.displayedTaskings = ko.pureComputed(() => + self.reorderMode() ? self.reorderList() : self.filteredTaskings() + ); + + self.enterReorderMode = function () { + // snapshot the current filtered taskings into a mutable array + self.reorderList(self.filteredTaskings().slice()); + self.reorderMode(true); + }; + + self.cancelReorderMode = function () { + self.reorderMode(false); + self.reorderList([]); + }; + + self.moveTaskingUp = function (tasking) { + const arr = self.reorderList(); + const idx = arr.indexOf(tasking); + if (idx <= 0) return; + arr.splice(idx, 1); + arr.splice(idx - 1, 0, tasking); + self.reorderList(arr); + }; + + self.moveTaskingDown = function (tasking) { + const arr = self.reorderList(); + const idx = arr.indexOf(tasking); + if (idx < 0 || idx >= arr.length - 1) return; + arr.splice(idx, 1); + arr.splice(idx + 1, 0, tasking); + self.reorderList(arr); + }; + + self.saveReorder = async function () { + const sequences = self.reorderList().map((ts, i) => ({ + taskingId: ts.id(), + sequence: i + })); + self.reorderSaving(true); + try { + await saveTaskingSequence(sequences); + // Update local sequence values to match new order + sequences.forEach(({ taskingId, sequence }) => { + const ts = self.taskings().find(t => t.id() === taskingId); + if (ts) ts.sequence(sequence); + }); + self.reorderMode(false); + self.reorderList([]); + } catch (e) { + console.error("Failed to save tasking sequence:", e); + } finally { + self.reorderSaving(false); + } + }; + self.taskingRowColour = ko.pureComputed(() => { if (self.taskedJobCount() === 0) { return 'row-team-green'; // light green diff --git a/src/pages/tasking/models/UnacceptedNotification.js b/src/pages/tasking/models/UnacceptedNotification.js new file mode 100644 index 00000000..1f00672b --- /dev/null +++ b/src/pages/tasking/models/UnacceptedNotification.js @@ -0,0 +1,90 @@ +/* eslint-disable @typescript-eslint/no-this-alias */ +import ko from "knockout"; +import moment from "moment"; +import { Entity } from "./Entity.js"; + +export function UnacceptedNotification(data = {}, deps = {}) { + const self = this; + + const { + acknowledgeNotification = async () => { /* shut up linter its not empty */ }, + fetchMessageById = async () => { /* shut up linter its not empty */ }, + acknowledgeIumMessage = async () => { /* shut up linter its not empty */ }, + relativeUpdateTick = null, + onAcknowledged = () => { /* shut up linter its not empty */}, + onAcknowledgeError = () => { /* shut up linter its not empty */ } + } = deps; + + // --- core fields --- + self.id = ko.observable(data.Id ?? null); + self.jobId = ko.observable(data.JobId ?? null); + self.jobIdentifier = ko.observable(data.JobIdentifier ?? ""); + self.icemIncidentIdentifier = ko.observable(data.ICEMSIncidentIdentifier ?? ""); + self.notificationTypeId = ko.observable(data.NotificationTypeId ?? null); + self.notificationType = ko.observable(data.NotificationType ?? ""); + self.text = ko.observable(data.Text ?? ""); + self.externalRefId = ko.observable(data.ExternalRefId ?? null); + + // --- timestamps --- + self.createdOn = ko.observable(data.CreatedOn ?? null); + self.lastModified = ko.observable(data.LastModified ?? null); + + // --- entity/creator info --- + self.entity = new Entity(data.Entity || {}); + self.createdBy = ko.observable(data.CreatedBy ?? ""); + self.lastModifiedBy = ko.observable(data.LastModifiedBy ?? ""); + + // --- location --- + self.jobAddress = ko.observable(data.JobAddress ?? ""); + self.latitude = ko.observable(data.Latitude ?? null); + self.longitude = ko.observable(data.Longitude ?? null); + + // --- acknowledgement state --- + self.acknowledged = ko.observable(data.Acknowledged ?? null); + self.acknowledgedBy = ko.observable(data.AcknowledgedBy ?? null); + + // --- relative time display --- + self.createdOnAgo = ko.pureComputed(() => { + if (relativeUpdateTick) relativeUpdateTick(); + const v = self.createdOn(); + return v ? moment(v).fromNow() : ""; + }); + + self.createdOnFormatted = ko.pureComputed(() => { + const v = self.createdOn(); + return v ? moment(v).format("DD/MM/YYYY HH:mm:ss") : ""; + }); + + // --- actions --- + self.isAcknowledging = ko.observable(false); + + self.isIumMessage = () => { + const typeId = self.notificationTypeId(); + const hasRefId = self.externalRefId(); + return hasRefId && (typeId === 13 || typeId === 14); // 13=IUMReceived, 14=UrgentIUMReceived + }; + + self.acknowledge = async function () { + if (self.isAcknowledging()) return; + self.isAcknowledging(true); + try { + // Always acknowledge the notification first + await acknowledgeNotification(self.id()); + + // If it's an IUM message, also acknowledge via IUM endpoint + if (self.isIumMessage()) { + const messageData = await fetchMessageById(self.externalRefId()); + await acknowledgeIumMessage(self.id(), messageData); + } + + self.acknowledged(new Date()); + onAcknowledged(self); + + } catch (e) { + console.error("Failed to acknowledge notification:", e); + onAcknowledgeError(e, self); + } finally { + self.isAcknowledging(false); + } + }; +} diff --git a/src/pages/tasking/resize.js b/src/pages/tasking/resize.js index 4be3b677..00009058 100644 --- a/src/pages/tasking/resize.js +++ b/src/pages/tasking/resize.js @@ -117,8 +117,8 @@ window.addEventListener('resize', () => { const splitW = vsplitEl.getBoundingClientRect().width; const minSidebar = 260, minMap = 260; const maxSidebar = Math.min(appRect.width - splitW - minMap, appRect.width * 0.7); - const savedW = Number(localStorage.getItem('lh.sidebarWidthPx')); - if (Number.isFinite(savedW)) { + const savedW = Number(localStorage.getItem('lh.sidebarWidthPx')) || sidebarEl.getBoundingClientRect().width; + if (Number.isFinite(savedW) && savedW > 0) { const clamped = Math.max(minSidebar, Math.min(savedW, maxSidebar)); sidebarEl.style.width = clamped + 'px'; appEl.style.setProperty('--sidebar-w', clamped + 'px'); @@ -129,8 +129,8 @@ window.addEventListener('resize', () => { const splitH = hsplitEl.getBoundingClientRect().height; const minTop = 120, minBot = 120; const maxTop = Math.max(minTop, sbRect.height - splitH - minBot); - const savedTop = Number(localStorage.getItem('lh.paneTopHeightPx')); - if (Number.isFinite(savedTop)) { + const savedTop = Number(localStorage.getItem('lh.paneTopHeightPx')) || paneTopEl.getBoundingClientRect().height; + if (Number.isFinite(savedTop) && savedTop > 0) { const clampedTop = Math.max(minTop, Math.min(savedTop, maxTop)); paneTopEl.style.height = clampedTop + 'px'; appEl.style.setProperty('--pane-top-h', clampedTop + 'px'); diff --git a/src/pages/tasking/viewmodels/Config.js b/src/pages/tasking/viewmodels/Config.js index 7701cfd4..ed7b86bd 100644 --- a/src/pages/tasking/viewmodels/Config.js +++ b/src/pages/tasking/viewmodels/Config.js @@ -73,6 +73,7 @@ export function ConfigVM(root, deps) { self.fetchPeriod = ko.observable(7).extend({ min: 0, max: 31, digit: true }); self.fetchForward = ko.observable(0).extend({ min: 0, max: 31, digit: true }); self.showAdvanced = ko.observable(false); + self.darkMode = ko.observable(false); //blown away on load self.teamStatusFilter = ko.observableArray([]); @@ -85,10 +86,23 @@ export function ConfigVM(root, deps) { self.teamTaskStatusFilter = ko.observableArray([]); + // Map clustering + self.clusterEnabled = ko.observable(true); + self.clusterRadius = ko.observable(60); // maxClusterRadius in px (10–80) + self.clusterRescueJobs = ko.observable(true); + // pinned rows self.pinnedTeamIds = ko.observableArray([]); self.pinnedIncidentIds = ko.observableArray([]); + // Dark mode helper (defined early so it can be called in afterConfigLoad) + self._applyDarkMode = () => { + if (self.darkMode()) { + document.body.classList.add('dark-mode'); + } else { + document.body.classList.remove('dark-mode'); + } + }; self.openLoadBox = function () { @@ -145,6 +159,7 @@ export function ConfigVM(root, deps) { fetchPeriod: Number(self.fetchPeriod()), fetchForward: Number(self.fetchForward()), showAdvanced: !!self.showAdvanced(), + darkMode: !!self.darkMode(), locationFilters: { teams: ko.toJS(self.teamFilters), incidents: ko.toJS(self.incidentFilters) @@ -159,6 +174,9 @@ export function ConfigVM(root, deps) { pinnedTeamIds: ko.toJS(self.pinnedTeamIds), pinnedIncidentIds: ko.toJS(self.pinnedIncidentIds), paneOrder: self.paneOrder().map(p => p.id), + clusterEnabled: !!self.clusterEnabled(), + clusterRadius: Number(self.clusterRadius()) || 60, + clusterRescueJobs: !!self.clusterRescueJobs(), }); // Helpers @@ -373,6 +391,19 @@ export function ConfigVM(root, deps) { cfg.pinnedTeamIds = []; cfg.pinnedIncidentIds = []; + // Extract HQ ID from URL if present + const search = window.location?.search || ''; + const hqMatch = search.match(/hq=(\d+)/); + if (hqMatch) { + const hqId = hqMatch[1]; + deps.entity(hqId).then(result => { + if (result) { + const normEntity = norm({ id: result.Id, name: result.Name, entityType: result.EntityTypeId }); + self.incidentFilters([normEntity]); + self.teamFilters([normEntity]); + } + }); + } } console.log('Loaded config:', cfg); // scalar settings @@ -388,6 +419,9 @@ export function ConfigVM(root, deps) { if (typeof cfg.showAdvanced === 'boolean') { self.showAdvanced(cfg.showAdvanced); } + if (typeof cfg.darkMode === 'boolean') { + self.darkMode(cfg.darkMode); + } if (typeof cfg.includeIncidentsWithoutSector === 'boolean') { self.includeIncidentsWithoutSector(cfg.includeIncidentsWithoutSector); } @@ -429,6 +463,16 @@ export function ConfigVM(root, deps) { self.rebuildPaneOrderFromIds(); // defaults } + if (typeof cfg.clusterEnabled === 'boolean') { + self.clusterEnabled(cfg.clusterEnabled); + } + if (typeof cfg.clusterRadius === 'number' && cfg.clusterRadius >= 10 && cfg.clusterRadius <= 80) { + self.clusterRadius(cfg.clusterRadius); + } + if (typeof cfg.clusterRescueJobs === 'boolean') { + self.clusterRescueJobs(cfg.clusterRescueJobs); + } + self.afterConfigLoad() @@ -523,6 +567,14 @@ export function ConfigVM(root, deps) { self.afterConfigLoad = () => { deps.fetchAllSectors(self.incidentFilters().map(i => i.id)); root.mapVM?.applyPaneOrder?.(self.paneOrder().map(p => p.id)); + root.mapVM?.applyClusterRadius?.(Number(self.clusterRadius()) || 60); + root.mapVM?.applyClusterEnabled?.(!!self.clusterEnabled()); + // Apply dark mode + self._applyDarkMode(); + // Apply dark mode basemap if enabled + if (self.darkMode() && root.mapVM?.changeBasemap) { + root.mapVM.changeBasemap("DarkGray"); + } } @@ -541,4 +593,48 @@ export function ConfigVM(root, deps) { root.mapVM?.applyPaneOrder?.(self.paneOrder().map(p => p.id)); }) + self.clusterRadius.subscribe((v) => { + const r = Math.max(10, Math.min(80, Number(v) || 60)); + root.mapVM?.applyClusterRadius?.(r); + self.save(); + }) + + self.clusterEnabled.subscribe((v) => { + root.mapVM?.applyClusterEnabled?.(!!v); + self.save(); + }) + + self.clusterRescueJobs.subscribe((v) => { + root.mapVM?.applyRescueClusterSetting?.(!!v); + self.save(); + }) + + self.darkMode.subscribe((isDark) => { + self._applyDarkMode(); + + // Switch basemap when dark mode changes + if (root.mapVM?.changeBasemap) { + const targetBasemap = isDark ? "DarkGray" : "Topographic"; + root.mapVM.changeBasemap(targetBasemap); + } + + self.save(); + }); + + /** Wipe all Lighthouse localStorage keys and reload the page. */ + self.restoreDefaults = () => { + if (!confirm( + 'This will reset ALL settings (filters, layout, map layers, starred items, etc.) to their defaults and reload the page.\n\nContinue?' + )) return; + + // Remove every key in localStorage (covers all lh-*, ov.*, layers.*, map.*, etc.) + const keys = []; + for (let i = 0; i < localStorage.length; i++) { + keys.push(localStorage.key(i)); + } + keys.forEach(k => localStorage.removeItem(k)); + + location.reload(); + }; + } diff --git a/src/pages/tasking/viewmodels/JobTimeline.js b/src/pages/tasking/viewmodels/JobTimeline.js index ec024a61..2f57381a 100644 --- a/src/pages/tasking/viewmodels/JobTimeline.js +++ b/src/pages/tasking/viewmodels/JobTimeline.js @@ -20,6 +20,8 @@ export function JobTimeline(parentVm) { self.jobIdentifier = ko.observable(); self.selectedTags = ko.observableArray([]); + self._refreshTimer = null; + self._refreshInFlight = false; // lane view mode: "both" | "history" | "ops" self.laneViewMode = ko.observable("both"); @@ -313,7 +315,10 @@ export function JobTimeline(parentVm) { bucket = { key, label: minute.format("DD/MM/YYYY HH:mm"), - rel: minute.fromNow(), + rel: ko.pureComputed(() => { + parentVm.relativeUpdateTick30s(); + return minute.fromNow(); + }), tPlus: tPlus, ops: [], history: [] @@ -351,30 +356,62 @@ export function JobTimeline(parentVm) { return b.ops.length > 0 || b.history.length > 0; }); + // Update each bucket's rel to use the most recent entry's timestamp instead of rounded minute + buckets.forEach(bucket => { + let latestTime = null; + + // Find the latest timestamp among all entries in this bucket + bucket.history.forEach(h => { + const t = h.timeStampRaw?.() || h.timeLoggedRaw?.(); + if (t) { + const m = parseDate(t); + if (m && (!latestTime || m.isAfter(latestTime))) { + latestTime = m; + } + } + }); + + bucket.ops.forEach(o => { + const t = o.timeLogged?.() || o.createdOn?.(); + if (t) { + const m = parseDate(t); + if (m && (!latestTime || m.isAfter(latestTime))) { + latestTime = m; + } + } + }); + + // Replace rel with computed using the actual latest time + if (latestTime) { + bucket.rel = ko.pureComputed(() => { + parentVm.relativeUpdateTick30s(); + return latestTime.fromNow(); + }); + } + }); + // newest minute first buckets.sort(function (a, b) { return b.key - a.key; }); return buckets; }); - self.openForJob = async (job) => { - self.laneViewMode("both"); //this is just better. force people to like it - self.jobIdentifier(job.identifier() || ""); - self.job(job); + self.refreshCurrentJob = async ({ silent = false } = {}) => { + const job = self.job(); + if (!job || typeof job.id !== "function") return; + if (self._refreshInFlight) return; - if (self.job() && self.job().receivedAt) { - self.jobCreated = moment(self.job().jobReceived()); + self._refreshInFlight = true; + if (!silent) { + self.loading(true); } - - self.historyEntries([]); - self.opsLogEntries([]); - self.loading(true); + const deps = { relativeUpdateTick: parentVm.relativeUpdateTick30s }; const historyResults = new Promise((resolve, reject) => { parentVm.fetchHistoryForJob( job.id(), function (res) { - self.historyEntries((res || []).map((e) => new HistoryEntry(e))); + self.historyEntries((res || []).map((e) => new HistoryEntry(e, deps))); resolve(res); }, reject @@ -385,7 +422,7 @@ export function JobTimeline(parentVm) { parentVm.fetchOpsLogForJob( job.id(), function (res) { - self.opsLogEntries((res || []).map((e) => new OpsLogEntry(e))); + self.opsLogEntries((res || []).map((e) => new OpsLogEntry(e, deps))); resolve(res); }, reject @@ -397,9 +434,40 @@ export function JobTimeline(parentVm) { } catch (e) { console.error("Error loading job history or ops log:", e); } finally { - self.loading(false); + if (!silent) { + self.loading(false); + } + self._refreshInFlight = false; + } + }; + + self.startAutoRefresh = function () { + self.stopAutoRefresh(); + self._refreshTimer = setInterval(() => { + self.refreshCurrentJob({ silent: true }); + }, 1000 * 30); + }; + + self.stopAutoRefresh = function () { + if (self._refreshTimer) { + clearInterval(self._refreshTimer); + self._refreshTimer = null; } }; + + self.openForJob = async (job) => { + self.laneViewMode("both"); //this is just better. force people to like it + self.jobIdentifier(job.identifier() || ""); + self.job(job); + + if (self.job() && self.job().receivedAt) { + self.jobCreated = moment(self.job().jobReceived()); + } + + self.historyEntries([]); + self.opsLogEntries([]); + await self.refreshCurrentJob(); + }; } diff --git a/src/pages/tasking/viewmodels/Map.js b/src/pages/tasking/viewmodels/Map.js index ef64441c..7234d81a 100644 --- a/src/pages/tasking/viewmodels/Map.js +++ b/src/pages/tasking/viewmodels/Map.js @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-this-alias */ var ko = require('knockout'); var L = require('leaflet'); +import 'leaflet.markercluster'; import { AssetPopupViewModel } from './AssetPopUp'; import { JobPopupViewModel } from './JobPopUp'; @@ -17,11 +18,347 @@ export function MapVM(Lmap, root) { self.distanceMarker = null; self.crowFliesLine = null; + // Guard flag: true while a flyToBounds animation is in progress. + // popupclose handlers check this to avoid clearing routes/crow-flies + // when the close was merely a side-effect of the zoom change (e.g. + // markercluster collapsing a spider during the animation). + self._flyingToBounds = false; + + // Layers drawer control (for basemap switching) + self.layersDrawer = null; + // layers self.assetLayer = L.layerGroup(); // not added by default – layers drawer handles visibility - self.jobMarkerGroups = new Map(); self.unmatchedAssetLayer = L.layerGroup(); // not added by default + // --- Job marker clustering --- + // Single cluster group for all job markers (replaces per-type layerGroups) + self.jobClusterGroup = L.markerClusterGroup({ + maxClusterRadius: 60, // default; overridden by Config.afterConfigLoad + showCoverageOnHover: true, + zoomToBoundsOnClick: true, + spiderfyOnMaxZoom: true, + spiderfyDistanceMultiplier: 1.8, + animate: true, + clusterPane: 'pane-tippy-top', + spiderLegPolylineOptions: { weight: 1.5, color: '#888', opacity: 0.5, interactive: false }, + iconCreateFunction: function (cluster) { + const children = cluster.getAllChildMarkers(); + const count = children.length; + const hasRescue = children.some(m => m._isRescue); + const hasNew = children.some(m => m._isNew); + + // Size tier based on child count + const tier = count >= 20 ? 'lg' : count >= 6 ? 'md' : 'sm'; + const cls = 'job-cluster-count cluster-' + tier + + (hasRescue ? ' has-rescue' : '') + + (hasNew ? ' has-new' : ''); + + // --- Hexagonal ring dimensions --- + // outerR = circumradius of outer hex, innerR = inner hex + const outerR = tier === 'lg' ? 24 : tier === 'md' ? 21 : 18; + const innerR = tier === 'lg' ? 19 : tier === 'md' ? 17 : 14; + const size = outerR * 2; + const cx = size / 2, cy = size / 2; + + // Helper: hex vertex at angle offset (flat-top: first vertex at 0°) + var hexPt = function(cxx, cyy, r, i) { + var a = Math.PI / 3 * i - Math.PI / 6; // flat-top hex + return [cxx + r * Math.cos(a), cyy + r * Math.sin(a)]; + }; + var hexPoints = function(cxx, cyy, r) { + var pts = []; + for (var i = 0; i < 6; i++) pts.push(hexPt(cxx, cyy, r, i)); + return pts; + }; + + // tally colours + const colorCounts = new Map(); + for (const m of children) { + const c = m._priorityColor || '#6b7280'; + colorCounts.set(c, (colorCounts.get(c) || 0) + 1); + } + + let ringPaths = ''; + if (colorCounts.size === 1) { + // single colour – full outer hex + const col = colorCounts.keys().next().value; + var op = hexPoints(cx, cy, outerR).map(function(p){ return p[0]+','+p[1]; }).join(' '); + ringPaths = ''; + } else { + // multiple colours – walk outer hex perimeter, cut back along inner + // Total outer perimeter length + var outerPts = hexPoints(cx, cy, outerR); + var innerPts = hexPoints(cx, cy, innerR); + var segLen = Math.sqrt(Math.pow(outerPts[1][0]-outerPts[0][0],2) + Math.pow(outerPts[1][1]-outerPts[0][1],2)); + var totalPerim = segLen * 6; + + // Build perimeter as sequence of points with cumulative distance + var perimPts = []; // [{x,y,d}] + var cumD = 0; + for (var si = 0; si < 6; si++) { + perimPts.push({ x: outerPts[si][0], y: outerPts[si][1], d: cumD }); + cumD += segLen; + } + perimPts.push({ x: outerPts[0][0], y: outerPts[0][1], d: cumD }); // close + + var innerPerimPts = []; + var cumD2 = 0; + var innerSegLen = Math.sqrt(Math.pow(innerPts[1][0]-innerPts[0][0],2) + Math.pow(innerPts[1][1]-innerPts[0][1],2)); + for (var si2 = 0; si2 < 6; si2++) { + innerPerimPts.push({ x: innerPts[si2][0], y: innerPts[si2][1], d: cumD2 }); + cumD2 += innerSegLen; + } + innerPerimPts.push({ x: innerPts[0][0], y: innerPts[0][1], d: cumD2 }); + var totalInnerPerim = innerSegLen * 6; + + var interpPerim = function(pts, total, frac) { + var target = frac * total; + for (var k = 0; k < pts.length - 1; k++) { + if (target >= pts[k].d && target <= pts[k+1].d) { + var seg = pts[k+1].d - pts[k].d; + var t = seg > 0 ? (target - pts[k].d) / seg : 0; + return { x: pts[k].x + (pts[k+1].x - pts[k].x) * t, y: pts[k].y + (pts[k+1].y - pts[k].y) * t }; + } + } + return { x: pts[pts.length-1].x, y: pts[pts.length-1].y }; + }; + + // Collect all outer & inner points within each segment's fraction range + var perimPointsBetween = function(pts, total, f1, f2) { + var result = []; + for (var k = 0; k < pts.length - 1; k++) { + var fk = pts[k].d / total; + if (fk > f1 && fk < f2) result.push(pts[k].x + ',' + pts[k].y); + } + return result; + }; + + var frac = 0; + for (var entry of colorCounts) { + var col = entry[0], n = entry[1]; + var segFrac = n / count; + var f1 = frac; + var f2 = frac + segFrac; + + // outer: start point, vertices in range, end point + var oStart = interpPerim(perimPts, totalPerim, f1); + var oEnd = interpPerim(perimPts, totalPerim, f2); + var oMid = perimPointsBetween(perimPts, totalPerim, f1, f2); + + // inner: same fractions, reversed + var iStart = interpPerim(innerPerimPts, totalInnerPerim, f2); + var iEnd = interpPerim(innerPerimPts, totalInnerPerim, f1); + var iMid = perimPointsBetween(innerPerimPts, totalInnerPerim, f1, f2).reverse(); + + var pts2 = [oStart.x+','+oStart.y] + .concat(oMid) + .concat([oEnd.x+','+oEnd.y]) + .concat([iStart.x+','+iStart.y]) + .concat(iMid) + .concat([iEnd.x+','+iEnd.y]); + ringPaths += ''; + frac = f2; + } + } + + // Always draw inner hex fill (badge background) in SVG so it + // perfectly matches the ring geometry. + var innerHexPts = hexPoints(cx, cy, innerR).map(function(p){ return p[0]+','+p[1]; }).join(' '); + ringPaths += ''; + + // Count text rendered in SVG directly so it always paints on top + var textColor = hasRescue ? '#dc3545' : '#fff'; + var fontSize = tier === 'lg' ? 15 : tier === 'md' ? 14 : 13; + ringPaths += '' + + count + ''; + + const ringSvg = '' + + ringPaths + ''; + + // Pulse ring: an SVG hex outline that scales+fades + var pulseSvg = ''; + if (hasNew) { + var pulsePts = hexPoints(cx, cy, outerR).map(function(p){ return p[0]+','+p[1]; }).join(' '); + pulseSvg = '' + + '' + + ''; + } + + return L.divIcon({ + className: 'job-cluster-icon', + html: '
    ' + ringSvg + pulseSvg + '
    ', + iconSize: [size, size], + iconAnchor: [size / 2, size / 2] + }); + } + }); + // Only add to map if incidents are visible (checked in main.js VM initialization) + if (localStorage.getItem('map.incidentsVisible') !== 'false') { + self.jobClusterGroup.addTo(self.map); + } + + // ── Fix: prevent spider collapse when clicking a spiderfied marker ── + // When a spiderfied child marker is clicked, Leaflet's event propagation + // carries the 'click' up through _featureGroup → clusterGroup → map + // BEFORE _fireDOMEvent can check _stopped. This triggers _unspiderfyWrapper + // on the map, which collapses the spider and immediately closes the popup + // that just opened. Fix: replace the default _unspiderfyWrapper with one + // that checks whether a popup from a spiderfied child is currently open. + (function () { + var cg = self.jobClusterGroup; + // Remove the default wrapper that markercluster registered in _spiderfierOnAdd + self.map.off('click', cg._unspiderfyWrapper, cg); + // Replace with guarded version + cg._unspiderfyWrapper = function () { + if (!cg._spiderfied) return; + // If a popup belonging to a spiderfied child is currently open on the + // map, don't collapse. The popup's autoClose (via preclick) will close + // it on the next outside click, and THAT click will then collapse the + // spider normally. We must check map.hasLayer() because map._popup is + // never cleared — it always references the last-opened popup. + var popup = self.map._popup; + if (popup && self.map.hasLayer(popup) && popup._source && popup._source._spiderLeg) { + return; // popup is open on a spider child – leave the spider alone + } + cg._unspiderfy(); + }; + self.map.on('click', cg._unspiderfyWrapper, cg); + })(); + + // Plain layer for rescue markers when clustering is disabled for them + self.rescueJobLayer = L.layerGroup().addTo(self.map); + + // Plain layer for ALL job markers when clustering is entirely disabled + self.unclusteredJobLayer = L.layerGroup(); // not added to map by default + + // Track whether clustering is currently active + self.clusteringEnabled = true; + + // Separate plain layer for pulse rings – not clustered + self.jobPulseLayer = L.layerGroup().addTo(self.map); + + // id → marker lookup (flat, no per-type groups) + self.jobMarkerIndex = new Map(); + + // Legacy compat: jobMarkerGroups iterator for tryInitialFit etc. + // Now wraps both the cluster group and the rescue layer + self.jobMarkerGroups = { + values: function () { + return [ + { layerGroup: self.clusteringEnabled ? self.jobClusterGroup : self.unclusteredJobLayer, markers: self.jobMarkerIndex }, + { layerGroup: self.rescueJobLayer, markers: new Map() } + ][Symbol.iterator](); + } + }; + + /** + * Move rescue markers between the cluster group and the standalone + * rescue layer based on the clusterRescueJobs setting. + */ + self.applyRescueClusterSetting = function (clusterThem) { + self.jobMarkerIndex.forEach((marker) => { + if (!marker._isRescue) return; + if (clusterThem) { + // move into cluster group + if (self.rescueJobLayer.hasLayer(marker)) { + self.rescueJobLayer.removeLayer(marker); + self.jobClusterGroup.addLayer(marker); + } + } else { + // move out of cluster group into plain layer + if (self.jobClusterGroup.hasLayer(marker)) { + self.jobClusterGroup.removeLayer(marker); + self.rescueJobLayer.addLayer(marker); + } + } + }); + self._syncPulseRings(); + }; + + /** + * Change the clustering aggressiveness by updating maxClusterRadius. + * markercluster doesn't support changing this dynamically, so we + * collect all markers, update the option, then re-add them which + * forces the internal grids to rebuild. + */ + self.applyClusterRadius = function (radius) { + radius = Number(radius) || 60; + if (!self.clusteringEnabled) { + // Just store for when clustering is re-enabled + self.jobClusterGroup.options.maxClusterRadius = radius; + return; + } + if (self.jobClusterGroup.options.maxClusterRadius === radius) return; + + // Collect current markers from the cluster group + const markers = []; + self.jobClusterGroup.eachLayer(m => markers.push(m)); + + // Update the option BEFORE clearLayers — clearLayers internally calls + // _generateInitialClusters which rebuilds the DistanceGrid structures + // using options.maxClusterRadius. Setting after would leave stale grids. + self.jobClusterGroup.options.maxClusterRadius = radius; + self.jobClusterGroup.clearLayers(); + + // Re-add — clearLayers rebuilt grid structures with the new radius so + // addLayers will cluster correctly. + if (markers.length) self.jobClusterGroup.addLayers(markers); + self._syncPulseRings(); + }; + + /** + * Enable or disable marker clustering entirely. + * When disabled, all markers are moved from jobClusterGroup to a plain + * layerGroup so they display individually without clustering behaviour. + */ + self.applyClusterEnabled = function (enabled) { + if (enabled === self.clusteringEnabled) return; + self.clusteringEnabled = enabled; + + const incidentsVisible = localStorage.getItem('map.incidentsVisible') !== 'false'; + + if (enabled) { + // Move all markers from the plain layer back into the cluster group + // (rescue markers go back to rescueJobLayer or clusterGroup per the + // clusterRescueJobs setting — we re-apply that afterwards). + self.map.removeLayer(self.unclusteredJobLayer); + const markers = []; + self.unclusteredJobLayer.eachLayer(m => markers.push(m)); + self.unclusteredJobLayer.clearLayers(); + // Also grab any rescue markers sitting on the rescue layer + self.rescueJobLayer.eachLayer(m => markers.push(m)); + self.rescueJobLayer.clearLayers(); + self.jobClusterGroup.addLayers(markers); + if (incidentsVisible && !self.map.hasLayer(self.jobClusterGroup)) { + self.jobClusterGroup.addTo(self.map); + } + // Now re-sort rescue markers per the rescue clustering setting + const clusterRescue = !!root.config?.clusterRescueJobs?.(); + self.applyRescueClusterSetting(clusterRescue); + } else { + // Move all markers out of the cluster group (and rescue layer) + // into a single plain layer. + const markers = []; + self.jobClusterGroup.eachLayer(m => markers.push(m)); + self.jobClusterGroup.removeLayers(markers); + self.map.removeLayer(self.jobClusterGroup); + self.rescueJobLayer.eachLayer(m => markers.push(m)); + self.rescueJobLayer.clearLayers(); + markers.forEach(m => self.unclusteredJobLayer.addLayer(m)); + if (incidentsVisible) { + self.unclusteredJobLayer.addTo(self.map); + } + } + + self._syncPulseRings(); + }; + self.applyPaneOrder = function (paneOrderTopToBottom) { if (!Array.isArray(paneOrderTopToBottom) || paneOrderTopToBottom.length === 0) return; @@ -42,6 +379,46 @@ export function MapVM(Lmap, root) { }); }; + self.changeBasemap = function (basemapKey) { + if (!self.layersDrawer || !self.layersDrawer._setBasemap) return; + self.layersDrawer._setBasemap(basemapKey, self.map); + self.layersDrawer._baseKey = basemapKey; + localStorage.setItem("map.base", basemapKey); + + // Basemap definitions + const basemapNames = [ + { name: "Esri Topographic", key: "Topographic" }, + { name: "Esri Streets", key: "Streets" }, + { name: "Esri Imagery", key: "Imagery" }, + { name: "Esri Dark", key: "DarkGray" }, + { name: "Spatial NSW", key: "nsw-vector" }, + { name: "SIX Maps Base Map", key: "nsw-base" }, + { name: "SIX Maps Imagery", key: "nsw-imagery" } + ]; + + // Update the UI label if the drawer is rendered + const label = document.querySelector(".ld-basemap-label"); + if (label) { + const basemapName = basemapNames.find(b => b.key === basemapKey)?.name || "Basemap"; + label.textContent = basemapName; + } + + // Update active state in dropdown menu + const menu = document.querySelector(".ld-basemap-menu"); + if (menu) { + menu.querySelectorAll(".dropdown-item").forEach(item => { + item.classList.remove("active"); + }); + // Find and activate the matching button + const buttons = menu.querySelectorAll(".dropdown-item"); + basemapNames.forEach(({ key }, index) => { + if (key === basemapKey && buttons[index]) { + buttons[index].classList.add("active"); + } + }); + } + }; + // --- online/polling overlay layers registry --- // key -> { key, label, layerGroup, refreshMs, timerId, visibleByDefault, fetchFn, drawFn } @@ -155,9 +532,9 @@ export function MapVM(Lmap, root) { if (self.assetLayer) { defs.push({ key: 'matched-assets', - label: 'Matched against Teams', + label: 'Assets Matched against Teams', layer: self.assetLayer, - group: 'Assets', + group: 'Visibility', visibleByDefault: true, }); } @@ -167,9 +544,9 @@ export function MapVM(Lmap, root) { if (self.unmatchedAssetLayer) { defs.push({ key: 'unmatched-assets', - label: 'Unmatched against Teams', + label: 'Assets Unmatched against Teams', layer: self.unmatchedAssetLayer, - group: 'Assets', + group: 'Visibility', visibleByDefault: false, }); } @@ -453,14 +830,22 @@ export function MapVM(Lmap, root) { } }; - // Clear rings whenever the map is clicked + // Clear overlays whenever the map is clicked self.map.on('click', () => { self.clearJobAssetBullseye(); + self.clearCrowFliesLine(); + self.clearRoutes(); }); const PopupStuff = { flyToBounds: (bounds, { opts }) => { + self._flyingToBounds = true; self.map.flyToBounds(bounds, opts); + self.map.once('moveend zoomend', () => { + // Small delay so the popupclose that fires synchronously during + // the same tick as the final moveend is still covered. + setTimeout(() => { self._flyingToBounds = false; }, 100); + }); }, clearRoutes: self.clearRoutes, @@ -510,14 +895,50 @@ export function MapVM(Lmap, root) { self.clearJobAssetBullseye(); }; - self.ensureJobGroup = (typeName) => { - if (!self.jobMarkerGroups.has(typeName)) { - const group = L.layerGroup().addTo(self.map); - self.jobMarkerGroups.set(typeName, { layerGroup: group, markers: new Map() }); - } - return self.jobMarkerGroups.get(typeName); + // Pulse ring visibility management for clustering + // When markers get clustered, hide their pulse rings. + // When unclustered or spiderfied, show them again. + self._syncPulseRings = function () { + self.jobMarkerIndex.forEach((marker) => { + if (!marker._pulseRing) return; + + // Markers on the standalone rescue layer (not in the cluster group) + // are always individually visible – skip cluster logic for them. + if (self.rescueJobLayer.hasLayer(marker)) { + if (!self.jobPulseLayer.hasLayer(marker._pulseRing)) { + self.jobPulseLayer.addLayer(marker._pulseRing); + } + return; + } + + // When clustering is disabled, all markers are individually visible. + if (!self.clusteringEnabled || self.unclusteredJobLayer.hasLayer(marker)) { + if (!self.jobPulseLayer.hasLayer(marker._pulseRing)) { + self.jobPulseLayer.addLayer(marker._pulseRing); + } + return; + } + + const visibleParent = self.jobClusterGroup.getVisibleParent(marker); + if (visibleParent === marker) { + // Marker is individually visible – show pulse ring + if (!self.jobPulseLayer.hasLayer(marker._pulseRing)) { + self.jobPulseLayer.addLayer(marker._pulseRing); + } + } else { + // Marker is inside a cluster – hide pulse ring + if (self.jobPulseLayer.hasLayer(marker._pulseRing)) { + self.jobPulseLayer.removeLayer(marker._pulseRing); + } + } + }); }; + self.jobClusterGroup.on('animationend', self._syncPulseRings); + self.jobClusterGroup.on('spiderfied', self._syncPulseRings); + self.jobClusterGroup.on('unspiderfied', self._syncPulseRings); + self.map.on('zoomend', self._syncPulseRings); + self.map.on('layeradd', (ev) => { // find which polling layer this corresponds to // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/src/shared/BeaconClient.js b/src/shared/BeaconClient.js index e5074b0f..60260ba3 100644 --- a/src/shared/BeaconClient.js +++ b/src/shared/BeaconClient.js @@ -20,12 +20,12 @@ import * as contacts from './BeaconClient/contacts.js'; import * as messages from './BeaconClient/messages.js'; import * as suppliers from './BeaconClient/suppliers.js'; import * as images from './BeaconClient/images.js'; +import * as icems from './BeaconClient/icems.js'; -export { job, asset, nitc, operationslog, resources, team, unit, entities, tasking, notifications, geoservices, tags, sectors, frao, contacts, messages, suppliers, images }; +export { job, asset, nitc, operationslog, resources, team, unit, entities, tasking, notifications, geoservices, tags, sectors, frao, contacts, messages, suppliers, images, icems }; // re-export functions -export default { job, asset, nitc, operationslog, resources, team, unit, entities, tasking, notifications, geoservices, tags, sectors, frao, contacts, messages, suppliers, images, toFormUrlEncoded }; - +export default { job, asset, nitc, operationslog, resources, team, unit, entities, tasking, notifications, geoservices, tags, sectors, frao, contacts, messages, suppliers, images, icems, toFormUrlEncoded }; export function toFormUrlEncoded(obj) { const params = []; for (const key in obj) { diff --git a/src/shared/BeaconClient/entities.js b/src/shared/BeaconClient/entities.js index 0f179a34..d23657c0 100644 --- a/src/shared/BeaconClient/entities.js +++ b/src/shared/BeaconClient/entities.js @@ -52,4 +52,31 @@ export function children(parent, host, userId = 'notPassed', token, callback) { } } }) +} + +export function fetch(id, host, userId = 'notPassed', token, callback) { + console.log("entities.fetch called with:" + id + ", " + host); + $.ajax({ + type: 'GET', + url: host + "/Api/v1/Entities/" + id + "?LighthouseFunction=EntitiesFetch&userId=" + userId, + beforeSend: function(n) { + n.setRequestHeader("Authorization", "Bearer " + token) + }, + cache: false, + dataType: 'json', + complete: function(response, textStatus) { + if (textStatus == 'success') { + let results = response.responseJSON; + if (typeof callback === "function") { + console.log("entities.children call back"); + callback(results); + } + } else { + if (typeof callback === "function") { + console.log("entities.children errored out"); + callback('', textStatus); + } + } + } + }) } \ No newline at end of file diff --git a/src/shared/BeaconClient/icems.js b/src/shared/BeaconClient/icems.js new file mode 100644 index 00000000..76f371df --- /dev/null +++ b/src/shared/BeaconClient/icems.js @@ -0,0 +1,50 @@ +import $ from 'jquery'; + +export function getMessageById(id, host, userId = 'notPassed', token, callback, errorCallback) { + $.ajax({ + type: 'GET', + url: host + '/Api/v1/Icems/messages/' + id + '?LighthouseFunction=GetMessageById&userId=' + userId, + beforeSend: function (n) { + n.setRequestHeader('Authorization', 'Bearer ' + token); + }, + cache: false, + dataType: 'json', + complete: function (response, textStatus) { + if (textStatus == 'success') { + if (typeof callback === 'function') { + callback(response.responseJSON); + } + } else { + if (typeof errorCallback === 'function') { + errorCallback(response); + } + } + } + }); +} + +export function acknowledgeIum(id, vm, host, userId = 'notPassed', token, callback, errorCallback) { + $.ajax({ + type: 'POST', + url: host + '/Api/v1/Icems/messages/' + id + '/acknowledgeIum?LighthouseFunction=AcknowledgeIum&userId=' + userId, + beforeSend: function (n) { + n.setRequestHeader('Authorization', 'Bearer ' + token); + }, + data: $.param(vm), + cache: false, + contentType: 'application/x-www-form-urlencoded; charset=UTF-8', + dataType: 'json', + complete: function (response, textStatus) { + if (textStatus == 'success') { + if (typeof callback === 'function') { + callback(response.responseJSON); + } + } else { + if (typeof errorCallback === 'function') { + errorCallback(response); + } + } + } + }); +} + diff --git a/src/shared/BeaconClient/messages.js b/src/shared/BeaconClient/messages.js index 21111fa8..1b86afff 100644 --- a/src/shared/BeaconClient/messages.js +++ b/src/shared/BeaconClient/messages.js @@ -33,4 +33,27 @@ recipients.forEach((recipient, index) => { } }, }); +} + +export function getMessageById(id, host, userId = 'notPassed', token, callback, errorCallback) { + $.ajax({ + type: 'GET', + url: host + '/Api/v1/Messages/' + id + '?LighthouseFunction=GetMessageById&userId=' + userId, + beforeSend: function (n) { + n.setRequestHeader('Authorization', 'Bearer ' + token); + }, + cache: false, + dataType: 'json', + complete: function (response, textStatus) { + if (textStatus == 'success') { + if (typeof callback === 'function') { + callback(response.responseJSON); + } + } else { + if (typeof errorCallback === 'function') { + errorCallback(response); + } + } + } + }); } \ No newline at end of file diff --git a/src/shared/BeaconClient/notifications.js b/src/shared/BeaconClient/notifications.js index 66679a0c..e1fbaebc 100644 --- a/src/shared/BeaconClient/notifications.js +++ b/src/shared/BeaconClient/notifications.js @@ -1,6 +1,6 @@ import $ from 'jquery'; -export function unaccepted(jobId, host, userId = 'notPassed', token, callback) { +export function unaccepted(jobId, host, userId = 'notPassed', token, callback, errorCallback) { $.ajax({ type: 'GET', url: host + "/Api/v1/Jobs/" + jobId + "/unacceptednotifications?LighthouseFunction=GetUnacceptedNotifications&userId=" + userId, @@ -15,7 +15,39 @@ export function unaccepted(jobId, host, userId = 'notPassed', token, callback) { if (typeof callback === "function") { callback(results); } + } else { + if (typeof errorCallback === "function") { + errorCallback(response); + } } } }) +} + +export function acknowledge(notificationId, host, userId = 'notPassed', token, callback, errorCallback) { + return new Promise((resolve, reject) => { + $.ajax({ + type: 'POST', + url: host + "/Api/v1/Notifications/" + notificationId + "/acknowledge?LighthouseFunction=AcknowledgeNotification&userId=" + userId, + beforeSend: function(n) { + n.setRequestHeader("Authorization", "Bearer " + token) + }, + cache: false, + dataType: 'json', + complete: function(response, textStatus) { + if (textStatus == 'success') { + const result = response.responseJSON; + if (typeof callback === "function") { + callback(result); + } + resolve(result); + } else { + if (typeof errorCallback === "function") { + errorCallback(response); + } + reject(response); + } + } + }); + }); } \ No newline at end of file diff --git a/src/shared/BeaconClient/sectors.js b/src/shared/BeaconClient/sectors.js index caf099dd..758f071e 100644 --- a/src/shared/BeaconClient/sectors.js +++ b/src/shared/BeaconClient/sectors.js @@ -24,7 +24,7 @@ export function search(unit, host, userId = 'notPassed', token, callback, progre var lastDisplayedVal = 0; getJsonPaginated( - url, token, 0, 100, + url, token, 0, 300, function (count, total) { if (count > lastDisplayedVal) { //buffer the output to that the progress alway moves forwards (sync loads suck) lastDisplayedVal = count; diff --git a/src/shared/BeaconClient/tasking.js b/src/shared/BeaconClient/tasking.js index 8110e0fa..6dad3bad 100644 --- a/src/shared/BeaconClient/tasking.js +++ b/src/shared/BeaconClient/tasking.js @@ -85,4 +85,26 @@ export function untaskTeam(host, taskingID, payload, token, callback) { } }, }); -} \ No newline at end of file +} + + +export function sequence(sequence, host, token, callback) { + $.ajax({ + type: 'PUT', + url: host + '/Api/v1/Tasking/Sequences', + beforeSend: function (n) { + n.setRequestHeader('Authorization', 'Bearer ' + token); + }, + data: JSON.stringify(sequence), + cache: false, + dataType: 'json', + contentType: 'application/json; charset=utf-8', + complete: function (response) { + if (response.status === 200) { + callback(true); + } else { + callback(false); + } + } + }); +} diff --git a/static/icons/ausgrid.png b/static/icons/ausgrid.png index 980e6c42..292f8030 100644 Binary files a/static/icons/ausgrid.png and b/static/icons/ausgrid.png differ diff --git a/static/icons/endeavour.png b/static/icons/endeavour.png index 36697758..52026081 100644 Binary files a/static/icons/endeavour.png and b/static/icons/endeavour.png differ diff --git a/static/icons/essential.png b/static/icons/essential.png index 62572ac9..27ba8968 100644 Binary files a/static/icons/essential.png and b/static/icons/essential.png differ diff --git a/static/icons/evo.png b/static/icons/evo.png index 3cd71c11..1e65c1f9 100644 Binary files a/static/icons/evo.png and b/static/icons/evo.png differ diff --git a/static/manifest.json b/static/manifest.json index 39a05801..198e9076 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -17,10 +17,6 @@ "64":"icons/lighthouse64_dev.png" }, "host_permissions": [ - "https://identitypreview.ses.nsw.gov.au/core/login", - "https://identitytrain.ses.nsw.gov.au/core/login", - "https://identitytest.ses.nsw.gov.au/core/login", - "https://identity.ses.nsw.gov.au/core/login", "https://beacon.ses.nsw.gov.au/*", "https://trainbeacon.ses.nsw.gov.au/*", "https://previewbeacon.ses.nsw.gov.au/*", @@ -43,7 +39,8 @@ "https://api.adsb.lol/v2/*", "https://nula.waternsw.com.au/*", "https://services1.arcgis.com/*", - "https://api.rainviewer.com/*" + "https://api.rainviewer.com/*", + "https://portal.spatial.nsw.gov.au/*" ], "permissions": [ "storage", @@ -180,16 +177,6 @@ ], "js": ["contentscripts/account/manage.js"] }, - { - "matches": ["https://identity.ses.nsw.gov.au/core/login*", - "https://identitytrain.ses.nsw.gov.au/core/login*", - "https://identitytest.ses.nsw.gov.au/core/login*", - "https://identitydev.ses.nsw.gov.au/core/login*", - "https://identitypreview.ses.nsw.gov.au/core/login*"], - "js": [ - "contentscripts/identity.js" - ] - }, { "matches": [ "https://myavailability.ses.nsw.gov.au/requests/out-of-area-activations/*", diff --git a/static/pages/tasking.html b/static/pages/tasking.html index d206e6ea..28cb0a8a 100644 --- a/static/pages/tasking.html +++ b/static/pages/tasking.html @@ -290,11 +290,12 @@
    - Team Members + class="d-flex justify-content-between align-items-center flex-nowrap mb-2"> + Team Members () +
      @@ -307,17 +308,15 @@ data-bind="visible: m.TeamLeader === true">TL
    - - - +
    +
  • @@ -374,14 +373,30 @@ Taskings -
    - +
    + +
    + + +
    + + +
    + + +
    +
    @@ -391,16 +406,27 @@ + - - + + + + + + + + + + + + + data-bind="foreach: { data: t.displayedTaskings, as: 'ts' }"> + + + + + +
    # TypeAddressStatusAddressStatusOrder
    #TypeAddressStatus Actions
    @@ -411,6 +437,7 @@ +
    + + +
    +
    @@ -682,7 +725,7 @@ @@ -1053,6 +1096,18 @@
    + + +
    + + This incident has unacknowledged ICEMS notifications +
    + +
    LAD - Configuration

    -

    How many days into the future to fetch.
    +
    + +
    + + +
    +
    Reduces eye strain in low light environments.
    +
    @@ -1895,10 +1964,11 @@

    -

    -

    -

    @@ -2340,10 +2413,11 @@

    -

    @@ -2411,10 +2485,11 @@

    -

    @@ -2422,21 +2497,88 @@

    aria-labelledby="headingMapLayers" data-bs-parent="#configOtherSettingsAccordion">
    -
    -
    - -
    -
      -
    • - - -
    • -
    -
    -
    +
    + + +
    +
    + + Marker Clustering + + +
    + + +
    +
    + Groups nearby incident markers into numbered + clusters that expand on click. +
    + +
    +
    + +
    + Tight + + Aggressive +
    +
    + How close markers must be (in pixels) to be + grouped into a cluster. Lower values keep + markers separate; higher values merge more. +
    +
    + +
    + + +
    +
    + When enabled, Rescue-priority jobs cluster with + other markers. +
    +
    +
    +
    + + +
    +
    + + Map Icon Layer Order + (top → bottom) + +
    +
      +
    • + + +
    • +
    +
    +
    +
    +
    @@ -2450,7 +2592,13 @@

    -

    @@ -2619,6 +2767,40 @@