diff --git a/src/pages/tasking/components/alerts.js b/src/pages/tasking/components/alerts.js index 43d615a0..e1911143 100644 --- a/src/pages/tasking/components/alerts.js +++ b/src/pages/tasking/components/alerts.js @@ -31,11 +31,14 @@ function createLeafletControl(L) { /** * Render a list of active rules into the container. - RAW html no KO here yet - * Each rule: { id, level, title, items:[{id,label}], count, onClick? } - */ -/** - * Render a list of active rules into the container. - RAW html no KO here yet - * Each rule: { id, level, title, items:[{id,label}], count, onClick? } + * Each rule: { id, level, title, items:[{id,label}], count, onClick?, prominent? } + * + * prominent (boolean, optional) — when true the alert box draws extra attention: + * • opaque (rather than translucent) background for the level colour + * • 2 px solid border instead of 1 px semi-transparent + * • repeating pulse-glow box-shadow animation + * • enlarged hazard icon with a continuous ping ring + * • count wrapped in a solid filled badge */ function renderRules(container, rules, opts = {}) { const allowCollapse = opts.allowCollapse !== false; @@ -72,6 +75,8 @@ function renderRules(container, rules, opts = {}) { // --- IN-PLACE UPDATE: only patch count + items, preserve all user state --- const countEl = div.querySelector('.alerts__count'); if (countEl) countEl.textContent = rule.count; + // keep prominent class in sync + div.classList.toggle('alerts--prominent', !!rule.prominent); const ul = div.querySelector('.alerts__list'); if (ul) { @@ -94,9 +99,12 @@ function renderRules(container, rules, opts = {}) { div.setAttribute('data-rule-id', rule.id); var width = '280px' div.className = `leaflet-control alerts alerts--${rule.level}`; + if (rule.prominent) { + div.classList.add('alerts--prominent'); + } if (state.collapsed) { div.classList.add('alerts--collapsed'); - width = "24px" + width = "30px" } if (!state.collapsed && state.open) { div.classList.add('alerts--open'); @@ -160,10 +168,10 @@ function renderRules(container, rules, opts = {}) { btn.setAttribute('aria-expanded', 'false'); div.querySelector('.alerts').animate( - [{ width: '280px' }, { width: '24px' }], + [{ width: '280px' }, { width: '30px' }], { duration: 300, easing: 'ease-in-out' } ).onfinish = () => { - div.querySelector('.alerts').style.width = '24px'; + div.querySelector('.alerts').style.width = '30px'; }; div.classList.add('alerts--collapsed'); }); @@ -298,6 +306,7 @@ function buildDefaultRules(vm) { id: 'new-jobs', level: 'warning', title: 'Unacknowledged incidents', + prominent: true, active: newJobs.length > 0, items: newJobs.slice(0, 10).map(asItem), count: newJobs.length, diff --git a/src/pages/tasking/main.js b/src/pages/tasking/main.js index a56729a1..8a350e3c 100644 --- a/src/pages/tasking/main.js +++ b/src/pages/tasking/main.js @@ -90,6 +90,7 @@ import { registerBOMFloodWarningBoundariesLayer, registerBOMFireWeatherDistrictsLayer } from "./mapLayers/weather.js"; +import { registerNswMeshNodesLayer } from "./mapLayers/civilian.js" import { fetchHqDetailsSummary } from './utils/hqSummary.js'; @@ -715,8 +716,8 @@ function VM() { if (!lastToken) { self.jobSearchSuggestions([]); return; } const escapeRx = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const rx = /^\d+$/.test(lastToken) - ? null // numeric — use includes + const rx = (/^\d+$/.test(lastToken) || /[^a-z0-9]/i.test(lastToken)) + ? null // numeric or contains non-alphanumeric (e.g. "14-7570") — use includes : new RegExp('\\b' + escapeRx(lastToken), 'i'); // Helper: escape HTML entities so label text is safe for innerHTML @@ -911,8 +912,9 @@ function VM() { // surprising (e.g. "123" inside "J-00123" should still match). const escapeRx = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const termMatchers = terms.map(t => { - if (/^\d+$/.test(t)) { - // Numeric token — plain substring is more intuitive + if (/^\d+$/.test(t) || /[^a-z0-9]/i.test(t)) { + // Numeric token or token containing non-alphanumeric chars (e.g. "14-7570") + // — plain substring is more intuitive and avoids \b boundary failures return (blob) => blob.includes(t); } const rx = new RegExp('\\b' + escapeRx(t), 'i'); @@ -2970,6 +2972,7 @@ function VM() { registerBOMFloodWarningBoundariesLayer(self, sourceUrl); registerBOMFireWeatherDistrictsLayer(self, sourceUrl); registerRainRadarLayer(self, map); + registerNswMeshNodesLayer(self) // --- Layers Drawer (under zoom) const LayersDrawer = L.Control.extend({ diff --git a/src/pages/tasking/mapLayers/civilian.js b/src/pages/tasking/mapLayers/civilian.js new file mode 100644 index 00000000..bc1a4323 --- /dev/null +++ b/src/pages/tasking/mapLayers/civilian.js @@ -0,0 +1,46 @@ +import L from "leaflet"; + + + +export function registerNswMeshNodesLayer(vm) { + vm.mapVM.registerPollingLayer("nswMeshNodes", { + label: "NSW Mesh Nodes", + menuGroup: "Civilian", + visibleByDefault: localStorage.getItem(`ov.nswMeshNodes`) || false, + fetchFn: async () => { + const response = await fetch("https://corescope.nswmesh.au/api/nodes?limit=10000&lastHeard=30d") + if (!response.ok) { throw new Error(`Failed to get MeshCore nodes: ${response.status}`); } + const result = await response.json(); + return result; + }, + drawFn: (layerGroup, data) => { + + if (!data || !Array.isArray(data.nodes)) return; + + + data.nodes.forEach((f) => { + if (!Number.isFinite(f.lat) || !Number.isFinite(f.lon)) return; + + const roleToIconMap = { + repeater : "🛜", + companion : "📟", + observer : "🖥️" + } + + const roleRmoji = roleToIconMap[f.role] ?? "📻" + + const marker = L.marker([f.lat, f.lon], { + icon: L.divIcon({ + html: roleRmoji, + iconSize: [0, 0] + })}); + layerGroup.addLayer(marker); + }); + + + + return; + + }, + }); +} \ No newline at end of file diff --git a/src/pages/tasking/models/Job.js b/src/pages/tasking/models/Job.js index 985b4fdc..6f2fc13c 100644 --- a/src/pages/tasking/models/Job.js +++ b/src/pages/tasking/models/Job.js @@ -198,8 +198,15 @@ export function Job(data = {}, deps = {}) { self.statusNameAndCount = ko.pureComputed(() => { const statusName = self.statusName(); if (statusName === "Active" || statusName === "Tasked") { - const taskingCount = self.taskings().length; - return `${statusName} (${taskingCount})`; + const activeOnly = deps.config?.taskingCountActiveOnly?.(); + const taskings = self.taskings(); + const count = activeOnly + ? taskings.filter(t => { + const s = t.currentStatus?.(); + return s === "Tasked" || s === "Enroute" || s === "Onsite"; + }).length + : taskings.length; + return `${statusName} (${count})`; } return statusName; }); diff --git a/src/pages/tasking/viewmodels/Config.js b/src/pages/tasking/viewmodels/Config.js index 5655f782..91fb4831 100644 --- a/src/pages/tasking/viewmodels/Config.js +++ b/src/pages/tasking/viewmodels/Config.js @@ -267,6 +267,7 @@ export function ConfigVM(root, deps) { self.clusterRadius = ko.observable(60); // maxClusterRadius in px (10–80) self.clusterRescueJobs = ko.observable(true); self.alertsCollapsibleRules = ko.observable(true); + self.taskingCountActiveOnly = ko.observable(false); // pinned rows self.pinnedTeamIds = ko.observableArray([]); @@ -399,6 +400,7 @@ export function ConfigVM(root, deps) { clusterRadius: Number(self.clusterRadius()) || 60, clusterRescueJobs: !!self.clusterRescueJobs(), alertsCollapsibleRules: !!self.alertsCollapsibleRules(), + taskingCountActiveOnly: !!self.taskingCountActiveOnly(), suggestionEnabled: !!self.suggestionEnabled(), rescueDistanceWeight: Number(self.rescueDistanceWeight()) || 0, rescueTaskingWeight: Number(self.rescueTaskingWeight()) || 0, @@ -713,6 +715,9 @@ export function ConfigVM(root, deps) { if (typeof cfg.alertsCollapsibleRules === 'boolean') { self.alertsCollapsibleRules(cfg.alertsCollapsibleRules); } + if (typeof cfg.taskingCountActiveOnly === 'boolean') { + self.taskingCountActiveOnly(cfg.taskingCountActiveOnly); + } // Instant Task Suggestion Engine weights if (typeof cfg.suggestionEnabled === 'boolean') { @@ -905,6 +910,10 @@ export function ConfigVM(root, deps) { self.save(); }) + self.taskingCountActiveOnly.subscribe(() => { + self.save(); + }) + // Auto-save suggestion engine settings self.suggestionEnabled.subscribe(() => { self.save(); }); self.rescueDistanceWeight.subscribe(() => { self.save(); }); diff --git a/static/manifest.json b/static/manifest.json index 198e9076..d2c30235 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -40,7 +40,8 @@ "https://nula.waternsw.com.au/*", "https://services1.arcgis.com/*", "https://api.rainviewer.com/*", - "https://portal.spatial.nsw.gov.au/*" + "https://portal.spatial.nsw.gov.au/*", + "https://corescope.nswmesh.au/*" ], "permissions": [ "storage", diff --git a/static/pages/tasking.html b/static/pages/tasking.html index 32997755..106bb642 100644 --- a/static/pages/tasking.html +++ b/static/pages/tasking.html @@ -2088,6 +2088,19 @@