diff --git a/src/pages/tasking/components/acronymText.js b/src/pages/tasking/components/acronymText.js new file mode 100644 index 00000000..34fdbc76 --- /dev/null +++ b/src/pages/tasking/components/acronymText.js @@ -0,0 +1,163 @@ +import ko from 'knockout'; +import $ from 'jquery'; + +/** + * Acronym dictionary for emergency services and common abbreviations + */ +export const acronymDictionary = [ + { regex: 'AA', meaning: 'As Above' }, + { regex: 'ACK', meaning: 'Acknowledge' }, + { regex: 'ASNSW', meaning: 'NSW Ambulance' }, + { regex: 'ATTD', meaning: 'Attend' }, + { regex: 'B4', meaning: 'Before' }, + { regex: 'BET', meaning: 'Between' }, + { regex: 'BTW', meaning: 'Between OR By the Way' }, + { regex: 'C[4F]W', meaning: 'Concern for Welfare' }, + { regex: 'CNCLD', meaning: 'Cancelled' }, + { regex: 'CNR', meaning: 'Corner' }, + { regex: 'DEC', meaning: 'Deceased' }, + { regex: 'ETA', meaning: 'Estimated Time of Arrival' }, + { regex: 'ETC', meaning: 'Estimated Time of Completion' }, + { regex: 'FRNSW', meaning: 'NSW Fire & Rescue' }, + { regex: 'FB', meaning: 'Fire Brigade' }, + { regex: 'ICEMS', meaning: 'Inter CAD Electronic Messaging System' }, + { regex: 'LOC', meaning: 'Location' }, + { regex: 'LS', meaning: 'Last Seen' }, + { regex: 'MP', meaning: 'Missing Person(s)' }, + { regex: 'NESB', meaning: 'Non-English Speaking Background' }, + { regex: 'NFA', meaning: 'No Further Action' }, + { regex: 'NK', meaning: "Not Known" }, + { regex: 'NPI', meaning: 'No Person(s) Injured' }, + { regex: 'NPT', meaning: 'No Person(s) Trapped' }, + { regex: 'NFI', meaning: 'No Further Information' }, + { regex: 'NN[2T]A', meaning: 'No Need to Attend' }, + { regex: 'NSWPF', meaning: 'NSW Police Force' }, + { regex: 'POL', meaning: 'Police' }, + { regex: 'NVS', meaning: 'No Vehicle(s) Sighted' }, + { regex: 'OPP', meaning: 'Opposite' }, + { regex: 'OTW', meaning: 'On the Way' }, + { regex: 'P[2T]P', meaning: 'Pole to Pole (Powerlines)' }, + { regex: 'P[2T]H', meaning: 'Pole to House (Powerlines)' }, + { regex: '(\\d+)?PAX', meaning: 'Passenger' }, + { regex: 'PBY', meaning: 'Passer By' }, + { regex: 'POIS?', meaning: 'Person(s) Of Interest' }, + { regex: 'RCO', meaning: 'Police Radio Rescue Coordinator' }, + { regex: 'REQ', meaning: 'Require' }, + { regex: 'VEHS?', meaning: 'Vehicle(s)' }, + { regex: 'VOIS?', meaning: 'Vehicle(s) Of Interest' }, + { regex: 'TMC', meaning: 'Transport Management Center' }, + { regex: 'NSWTMC', meaning: 'NSW Transport Management Center' }, + { regex: 'KLO4', meaning: 'Keep a Look Out For' }, + { regex: 'INFTS?', meaning: 'Informant/Caller(s)' }, + { regex: '(\\d+)?POBS?', meaning: 'Person(s) On Board' }, + { regex: 'POSS', meaning: 'Possible' }, + { regex: 'PTS?', meaning: 'Patient(s) OR Person(s) Trapped' }, + { regex: 'VICT?', meaning: 'Victim(s)' }, + { regex: 'YR', meaning: 'Years Old' }, + { regex: 'YO', meaning: 'Years Old' }, + { regex: 'YOM', meaning: 'Year Old Male' }, + { regex: 'YOF', meaning: 'Year Old Female' }, + { regex: 'THX', meaning: 'Thanks' }, +]; + +/** + * Register the acronymText Knockout binding handler + * Replaces matching acronyms with tags showing their meanings on hover + */ +export function registerAcronymTextBinding() { + if (ko.bindingHandlers.acronymText) { + return; // Already registered + } + + /** + * Attach custom tooltips to abbr elements (similar to rainviewer legend) + */ + function attachAbbreviationTooltips(element) { + const abbrs = element.querySelectorAll('abbr[data-meaning]'); + abbrs.forEach((abbr) => { + const meaning = abbr.getAttribute('data-meaning'); + if (!meaning) return; + + // Position: relative is needed for absolute tooltip positioning + abbr.style.position = 'relative'; + abbr.style.cursor = 'help'; + abbr.style.textDecoration = 'underline'; + abbr.style.textDecorationStyle = 'dotted'; + + // Create tooltip div + 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 = meaning; + abbr.appendChild(tooltip); + + // Hover handlers + abbr.addEventListener('mouseenter', () => { + tooltip.style.opacity = '1'; + }); + abbr.addEventListener('mouseleave', () => { + tooltip.style.opacity = '0'; + }); + }); + } + + ko.bindingHandlers.acronymText = { + init: function(element, valueAccessor) { + ko.unwrap(valueAccessor()); // Trigger dependency tracking + var $element = $(element); + + var contentOrig = $element.html(); + var contentRepl = contentOrig; + + // Apply acronym dictionary + acronymDictionary.forEach(function(item) { + contentRepl = contentRepl.replace( + new RegExp('\\b(' + item.regex + ')\\b', 'gi'), + '$1' + ); + }); + + if (contentRepl != contentOrig) { + $element.html(contentRepl); + attachAbbreviationTooltips(element); + } + }, + update: function(element, valueAccessor) { + var value = ko.unwrap(valueAccessor()); + var $element = $(element); + + // Reset to plain text first + $element.text(value); + + var contentOrig = $element.html(); + var contentRepl = contentOrig; + + // Apply acronym dictionary + acronymDictionary.forEach(function(item) { + contentRepl = contentRepl.replace( + new RegExp('\\b(' + item.regex + ')\\b', 'gi'), + '$1' + ); + }); + + if (contentRepl != contentOrig) { + $element.html(contentRepl); + attachAbbreviationTooltips(element); + } + } + }; +} diff --git a/src/pages/tasking/components/alerts.js b/src/pages/tasking/components/alerts.js index 9c90ead1..d7945c9c 100644 --- a/src/pages/tasking/components/alerts.js +++ b/src/pages/tasking/components/alerts.js @@ -125,6 +125,8 @@ function renderRules(container, rules) { if (typeof rule.onClick === 'function') { div.querySelectorAll('li[data-id]').forEach(li => { li.style.cursor = 'pointer'; + li.addEventListener('mouseenter', () => li.style.textDecoration = 'underline'); + li.addEventListener('mouseleave', () => li.style.textDecoration = ''); li.addEventListener('click', () => rule.onClick(li.getAttribute('data-id'))); }); } diff --git a/src/pages/tasking/components/job_popup.js b/src/pages/tasking/components/job_popup.js index e36f9ab7..71c30060 100644 --- a/src/pages/tasking/components/job_popup.js +++ b/src/pages/tasking/components/job_popup.js @@ -107,6 +107,9 @@ export function buildJobPopupKO() {
+ +
+
diff --git a/src/pages/tasking/main.js b/src/pages/tasking/main.js index 6a1b09ac..ecca7c41 100644 --- a/src/pages/tasking/main.js +++ b/src/pages/tasking/main.js @@ -28,6 +28,7 @@ import IncidentImagesModalVM from "./viewmodels/IncidentImagesModalVM"; import { installAlerts } from './components/alerts.js'; import { LegendControl } from './components/legend.js'; import { SpotlightSearchVM } from "./components/spotlightSearch.js"; +import { registerAcronymTextBinding } from "./components/acronymText.js"; import { Asset } from './models/Asset.js'; @@ -2946,8 +2947,6 @@ function VM() { }); })(); - - } window.addEventListener('resize', () => map.invalidateSize()); @@ -3003,6 +3002,7 @@ document.addEventListener('DOMContentLoaded', function () { installDragDropRowBindings(); noBubbleFromDisabledButtonsBindings(); installSortableArrayBindings(); + registerAcronymTextBinding(); ko.bindingProvider.instance = new ksb(options); window.ko = ko; diff --git a/src/pages/tasking/mapLayers/common.js b/src/pages/tasking/mapLayers/common.js deleted file mode 100644 index 07012419..00000000 --- a/src/pages/tasking/mapLayers/common.js +++ /dev/null @@ -1,16 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -import BeaconClient from '../../../shared/BeaconClient.js'; - -function getTransportApiKeyOpsLog(origin) { - switch (origin) { - case 'https://previewbeacon.ses.nsw.gov.au': - return '46273'; - case 'https://beacon.ses.nsw.gov.au': - return '515514'; - case 'https://trainbeacon.ses.nsw.gov.au': - return '36753'; - default: - return '0'; - } -} - diff --git a/src/pages/tasking/mapLayers/frao.js b/src/pages/tasking/mapLayers/frao.js index 83ee9c94..59a86740 100644 --- a/src/pages/tasking/mapLayers/frao.js +++ b/src/pages/tasking/mapLayers/frao.js @@ -76,7 +76,7 @@ export function renderFRAOSLayer(vm, map, getToken, apiHost, params) { vm.mapVM.registerPollingLayer("fraos", { label: "Active FRAOs", menuGroup: "NSW SES Geoservices", - refreshMs: 0, // 0 refresh. they dont change. only redraw on filter change + refreshMs: 600000, // 10 min visibleByDefault: localStorage.getItem(`ov.fraos`) || false, fetchFn: async () => { const start = new Date(); diff --git a/src/pages/tasking/mapLayers/rainviewer.js b/src/pages/tasking/mapLayers/rainviewer.js index aba015b6..ed595099 100644 --- a/src/pages/tasking/mapLayers/rainviewer.js +++ b/src/pages/tasking/mapLayers/rainviewer.js @@ -150,7 +150,9 @@ export function registerRainRadarLayer(vm, map) { /* ── data loading ──────────────────────────────────────────── */ async function loadFrames(layerGroup) { try { - const res = await fetch(RAINVIEWER_API); + const res = await fetch(`${RAINVIEWER_API}?_cb=${Date.now()}`, { + cache: "no-store", + }); if (!res.ok) throw new Error(`RainViewer API ${res.status}`); const json = await res.json(); const past = json.radar?.past; diff --git a/src/pages/tasking/mapLayers/weather.js b/src/pages/tasking/mapLayers/weather.js index 0189459d..1d5fd37a 100644 --- a/src/pages/tasking/mapLayers/weather.js +++ b/src/pages/tasking/mapLayers/weather.js @@ -10,10 +10,8 @@ export function registerBOMAllFloodLevelsLayer(vm, sourceUrl) { menuGroup: "BOM Observations", refreshMs: 600000, // 10 min visibleByDefault: localStorage.getItem(`ov.bomAllFlood`) || false, - fetchFn: async () => { - return {}; - }, - drawFn: (layerGroup, _data) => { + fetchFn: async () => ({ cacheBuster: Date.now() }), + drawFn: (layerGroup, data) => { const wmsLayer = L.tileLayer.wms(allUrl, { layers: "IDN62011_all", styles: "default", @@ -23,6 +21,7 @@ export function registerBOMAllFloodLevelsLayer(vm, sourceUrl) { version: "1.3.0", crs: L.CRS.EPSG4326, attribution: "Bureau of Meteorology", + _cb: data?.cacheBuster, }); layerGroup.addLayer(wmsLayer); }, @@ -39,10 +38,8 @@ export function registerBOMRainfallLayer(vm, sourceUrl) { menuGroup: "BOM Observations", refreshMs: 600000, // 10 min visibleByDefault: localStorage.getItem(`ov.bomRainfall`) || false, - fetchFn: async () => { - return {}; - }, - drawFn: (layerGroup, _data) => { + fetchFn: async () => ({ cacheBuster: Date.now() }), + drawFn: (layerGroup, data) => { const wmsLayer = L.tileLayer.wms(rainfallUrl, { layers: "IDZ20010_rainfall_9am", styles: "default", @@ -52,6 +49,7 @@ export function registerBOMRainfallLayer(vm, sourceUrl) { version: "1.3.0", crs: L.CRS.EPSG4326, attribution: "Bureau of Meteorology", + _cb: data?.cacheBuster, // Default NSW bounding box, but map will handle view }); layerGroup.addLayer(wmsLayer); @@ -70,10 +68,8 @@ export function registerBOMRadarLayer(vm, sourceUrl) { menuGroup: "BOM Radar & Satellite", refreshMs: 600000, // 10 min visibleByDefault: localStorage.getItem(`ov.bomRadar`) || false, - fetchFn: async () => { - return {}; - }, - drawFn: (layerGroup, _data) => { + fetchFn: async () => ({ cacheBuster: Date.now() }), + drawFn: (layerGroup, data) => { const wmsLayer = L.tileLayer.wms(radarUrl, { layers: "IDR00010", styles: "default", @@ -83,6 +79,7 @@ export function registerBOMRadarLayer(vm, sourceUrl) { version: "1.3.0", crs: L.CRS.EPSG4326, attribution: "Bureau of Meteorology", + _cb: data?.cacheBuster, }); layerGroup.addLayer(wmsLayer); }, @@ -100,10 +97,8 @@ export function registerBOMSatTrueColorLayer(vm, sourceUrl) { menuGroup: "BOM Radar & Satellite", refreshMs: 600000, // 10 min visibleByDefault: localStorage.getItem(`ov.bomSatTrueColor`) || false, - fetchFn: async () => { - return {}; - }, - drawFn: (layerGroup, _data) => { + fetchFn: async () => ({ cacheBuster: Date.now() }), + drawFn: (layerGroup, data) => { const wmsLayer = L.tileLayer.wms(radarUrl, { layers: "IDE00435", styles: "default", @@ -112,7 +107,8 @@ export function registerBOMSatTrueColorLayer(vm, sourceUrl) { bgcolor: "0xFFFFFF", version: "1.3.0", crs: L.CRS.EPSG4326, - attribution: "Japan Meteorological Agency via Bureau of Meteorology" + attribution: "Japan Meteorological Agency via Bureau of Meteorology", + _cb: data?.cacheBuster, }); layerGroup.addLayer(wmsLayer); }, @@ -131,11 +127,11 @@ export function registerBOMThunderstormTrackingLayer(vm, sourceUrl) { refreshMs: 600000, // 10 min visibleByDefault: localStorage.getItem(`ov.bomThunderstormTracking`) || false, fetchFn: async () => { - return {}; + return { cacheBuster: Date.now() }; }, - drawFn: (layerGroup, _data) => { + drawFn: (layerGroup, data) => { const wmsLayer = L.tileLayer.wms(radarUrl, { - layers: ["IDR00011","IDR00011_track"], + layers: "IDR00011,IDR00011_track", styles: "default", format: "image/png", transparent: true, @@ -143,7 +139,8 @@ export function registerBOMThunderstormTrackingLayer(vm, sourceUrl) { version: "1.3.0", crs: L.CRS.EPSG4326, attribution: "Bureau of Meteorology", - opacity: 0.7 + opacity: 0.7, + _cb: data?.cacheBuster }); layerGroup.addLayer(wmsLayer); }, @@ -158,13 +155,10 @@ export function registerBOMWindLayer(vm, sourceUrl) { 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) => { + fetchFn: async () => ({ cacheBuster: Date.now() }), + drawFn: (layerGroup, data) => { const wmsLayer = L.tileLayer.wms(radarUrl, { layers: ["IDY25026_windpt","IDY25026_windrast"], styles: "default", @@ -174,7 +168,8 @@ export function registerBOMWindLayer(vm, sourceUrl) { opacity: 0.5, // Adjust opacity for more transparency version: "1.3.0", crs: L.CRS.EPSG4326, - attribution: "Bureau of Meteorology" + attribution: "Bureau of Meteorology", + _cb: data?.cacheBuster, }); layerGroup.addLayer(wmsLayer); }, @@ -191,10 +186,8 @@ export function registerBOMMSLPLayer(vm, sourceUrl) { menuGroup: "BOM Forecasts", refreshMs: 600000, // 10 min visibleByDefault: localStorage.getItem(`ov.bomMSLP`) || false, - fetchFn: async () => { - return {}; - }, - drawFn: (layerGroup, _data) => { + fetchFn: async () => ({ cacheBuster: Date.now() }), + drawFn: (layerGroup, data) => { const wmsLayer = L.tileLayer.wms(radarUrl, { layers: ["IDY25026_mslp"], styles: "default", @@ -203,7 +196,8 @@ export function registerBOMMSLPLayer(vm, sourceUrl) { bgcolor: "0xFFFFFF", version: "1.3.0", crs: L.CRS.EPSG4326, - attribution: "Bureau of Meteorology" + attribution: "Bureau of Meteorology", + _cb: data?.cacheBuster, }); layerGroup.addLayer(wmsLayer); }, @@ -221,10 +215,8 @@ export function registerBOMLightningLayer(vm, sourceUrl) { menuGroup: "BOM Radar & Satellite", refreshMs: 600000, // 10 min visibleByDefault: localStorage.getItem(`ov.bomLightning`) || false, - fetchFn: async () => { - return {}; - }, - drawFn: (layerGroup, _data) => { + fetchFn: async () => ({ cacheBuster: Date.now() }), + drawFn: (layerGroup, data) => { const wmsLayer = L.tileLayer.wms(radarUrl, { layers: ["IDZ20019_c2g_2h"], styles: "default", @@ -234,6 +226,7 @@ export function registerBOMLightningLayer(vm, sourceUrl) { version: "1.3.0", crs: L.CRS.EPSG4326, attribution: "Bureau of Meteorology", + _cb: data?.cacheBuster, }); layerGroup.addLayer(wmsLayer); }, @@ -251,8 +244,8 @@ export function registerBOMLightning24hLayer(vm, sourceUrl) { menuGroup: "BOM Radar & Satellite", refreshMs: 600000, visibleByDefault: localStorage.getItem(`ov.bomLightning24h`) || false, - fetchFn: async () => ({}), - drawFn: (layerGroup, _data) => { + fetchFn: async () => ({ cacheBuster: Date.now() }), + drawFn: (layerGroup, data) => { layerGroup.addLayer( L.tileLayer.wms(wmsUrl, { layers: "IDZ20019_c2g_2-24h", @@ -262,6 +255,7 @@ export function registerBOMLightning24hLayer(vm, sourceUrl) { version: "1.3.0", crs: L.CRS.EPSG4326, attribution: "Bureau of Meteorology", + _cb: data?.cacheBuster, }) ); }, @@ -279,8 +273,8 @@ export function registerBOMTsunamiLayer(vm, sourceUrl) { menuGroup: "BOM Warnings", refreshMs: 300000, // 5 min – critical warning visibleByDefault: localStorage.getItem(`ov.bomTsunami`) || false, - fetchFn: async () => ({}), - drawFn: (layerGroup, _data) => { + fetchFn: async () => ({ cacheBuster: Date.now() }), + drawFn: (layerGroup, data) => { layerGroup.addLayer( L.tileLayer.wms(wmsUrl, { layers: ["IDZ20002", "IDZ20002_info"], @@ -290,6 +284,7 @@ export function registerBOMTsunamiLayer(vm, sourceUrl) { version: "1.3.0", crs: L.CRS.EPSG4326, attribution: "Bureau of Meteorology", + _cb: data?.cacheBuster, }) ); }, @@ -307,8 +302,8 @@ export function registerBOMTropicalCycloneLayer(vm, sourceUrl) { menuGroup: "BOM Warnings", refreshMs: 600000, visibleByDefault: localStorage.getItem(`ov.bomTropicalCyclone`) || false, - fetchFn: async () => ({}), - drawFn: (layerGroup, _data) => { + fetchFn: async () => ({ cacheBuster: Date.now() }), + drawFn: (layerGroup, data) => { layerGroup.addLayer( L.tileLayer.wms(wmsUrl, { layers: [ @@ -325,6 +320,7 @@ export function registerBOMTropicalCycloneLayer(vm, sourceUrl) { version: "1.3.0", crs: L.CRS.EPSG4326, attribution: "Bureau of Meteorology", + _cb: data?.cacheBuster, }) ); }, @@ -342,8 +338,8 @@ export function registerBOMFireDangerRatingLayer(vm, sourceUrl) { menuGroup: "BOM Forecasts", refreshMs: 600000, visibleByDefault: localStorage.getItem(`ov.bomFireDangerRating`) || false, - fetchFn: async () => ({}), - drawFn: (layerGroup, _data) => { + fetchFn: async () => ({ cacheBuster: Date.now() }), + drawFn: (layerGroup, data) => { layerGroup.addLayer( L.tileLayer.wms(wmsUrl, { layers: "IDZ20022000", @@ -354,6 +350,7 @@ export function registerBOMFireDangerRatingLayer(vm, sourceUrl) { crs: L.CRS.EPSG4326, opacity: 0.6, attribution: "Bureau of Meteorology", + _cb: data?.cacheBuster, }) ); }, @@ -371,8 +368,8 @@ export function registerBOMHeatwaveLayer(vm, sourceUrl) { menuGroup: "BOM Forecasts", refreshMs: 600000, visibleByDefault: localStorage.getItem(`ov.bomHeatwave`) || false, - fetchFn: async () => ({}), - drawFn: (layerGroup, _data) => { + fetchFn: async () => ({ cacheBuster: Date.now() }), + drawFn: (layerGroup, data) => { layerGroup.addLayer( L.tileLayer.wms(wmsUrl, { layers: "IDY10012_day0", @@ -383,6 +380,7 @@ export function registerBOMHeatwaveLayer(vm, sourceUrl) { crs: L.CRS.EPSG4326, opacity: 0.6, attribution: "Bureau of Meteorology", + _cb: data?.cacheBuster, }) ); }, @@ -400,8 +398,8 @@ export function registerBOMHazardousSurfLayer(vm, sourceUrl) { menuGroup: "BOM Warnings", refreshMs: 600000, visibleByDefault: localStorage.getItem(`ov.bomHazardousSurf`) || false, - fetchFn: async () => ({}), - drawFn: (layerGroup, _data) => { + fetchFn: async () => ({ cacheBuster: Date.now() }), + drawFn: (layerGroup, data) => { layerGroup.addLayer( L.tileLayer.wms(wmsUrl, { layers: "IDZ20017000", @@ -411,6 +409,7 @@ export function registerBOMHazardousSurfLayer(vm, sourceUrl) { version: "1.3.0", crs: L.CRS.EPSG4326, attribution: "Bureau of Meteorology", + _cb: data?.cacheBuster, }) ); }, @@ -428,8 +427,8 @@ export function registerBOMCoastalHazardLayer(vm, sourceUrl) { menuGroup: "BOM Warnings", refreshMs: 600000, visibleByDefault: localStorage.getItem(`ov.bomCoastalHazard`) || false, - fetchFn: async () => ({}), - drawFn: (layerGroup, _data) => { + fetchFn: async () => ({ cacheBuster: Date.now() }), + drawFn: (layerGroup, data) => { layerGroup.addLayer( L.tileLayer.wms(wmsUrl, { layers: "IDZ20023", @@ -439,6 +438,7 @@ export function registerBOMCoastalHazardLayer(vm, sourceUrl) { version: "1.3.0", crs: L.CRS.EPSG4326, attribution: "Bureau of Meteorology", + _cb: data?.cacheBuster, }) ); }, @@ -456,8 +456,8 @@ export function registerBOMRoadWeatherLayer(vm, sourceUrl) { menuGroup: "BOM Warnings", refreshMs: 600000, visibleByDefault: localStorage.getItem(`ov.bomRoadWeather`) || false, - fetchFn: async () => ({}), - drawFn: (layerGroup, _data) => { + fetchFn: async () => ({ cacheBuster: Date.now() }), + drawFn: (layerGroup, data) => { layerGroup.addLayer( L.tileLayer.wms(wmsUrl, { layers: "IDZ20014", @@ -467,6 +467,7 @@ export function registerBOMRoadWeatherLayer(vm, sourceUrl) { version: "1.3.0", crs: L.CRS.EPSG4326, attribution: "Bureau of Meteorology", + _cb: data?.cacheBuster, }) ); }, @@ -484,8 +485,8 @@ export function registerBOMSurfaceGustLayer(vm, sourceUrl) { menuGroup: "BOM Observations", refreshMs: 600000, visibleByDefault: localStorage.getItem(`ov.bomSurfaceGust`) || false, - fetchFn: async () => ({}), - drawFn: (layerGroup, _data) => { + fetchFn: async () => ({ cacheBuster: Date.now() }), + drawFn: (layerGroup, data) => { layerGroup.addLayer( L.tileLayer.wms(wmsUrl, { layers: "IDZ20010_gustkmh", @@ -495,6 +496,7 @@ export function registerBOMSurfaceGustLayer(vm, sourceUrl) { version: "1.3.0", crs: L.CRS.EPSG4326, attribution: "Bureau of Meteorology", + _cb: data?.cacheBuster, }) ); }, @@ -512,8 +514,8 @@ export function registerBOMSurfaceTempLayer(vm, sourceUrl) { menuGroup: "BOM Observations", refreshMs: 600000, visibleByDefault: localStorage.getItem(`ov.bomSurfaceTemp`) || false, - fetchFn: async () => ({}), - drawFn: (layerGroup, _data) => { + fetchFn: async () => ({ cacheBuster: Date.now() }), + drawFn: (layerGroup, data) => { layerGroup.addLayer( L.tileLayer.wms(wmsUrl, { layers: "IDZ20010_air_temperature", @@ -523,6 +525,7 @@ export function registerBOMSurfaceTempLayer(vm, sourceUrl) { version: "1.3.0", crs: L.CRS.EPSG4326, attribution: "Bureau of Meteorology", + _cb: data?.cacheBuster, }) ); }, @@ -540,8 +543,8 @@ export function registerBOMFireBehaviourIndexLayer(vm, sourceUrl) { menuGroup: "BOM Forecasts", refreshMs: 600000, visibleByDefault: localStorage.getItem(`ov.bomFireBehaviourIndex`) || false, - fetchFn: async () => ({}), - drawFn: (layerGroup, _data) => { + fetchFn: async () => ({ cacheBuster: Date.now() }), + drawFn: (layerGroup, data) => { layerGroup.addLayer( L.tileLayer.wms(wmsUrl, { layers: "IDZ10135", @@ -552,6 +555,7 @@ export function registerBOMFireBehaviourIndexLayer(vm, sourceUrl) { crs: L.CRS.EPSG4326, opacity: 0.6, attribution: "Bureau of Meteorology", + _cb: data?.cacheBuster, }) ); }, @@ -569,8 +573,8 @@ export function registerBOMHazardousWindLayer(vm, sourceUrl) { menuGroup: "BOM Forecasts", refreshMs: 600000, visibleByDefault: localStorage.getItem(`ov.bomHazardousWind`) || false, - fetchFn: async () => ({}), - drawFn: (layerGroup, _data) => { + fetchFn: async () => ({ cacheBuster: Date.now() }), + drawFn: (layerGroup, data) => { layerGroup.addLayer( L.tileLayer.wms(wmsUrl, { layers: "IDZ71153", @@ -581,6 +585,7 @@ export function registerBOMHazardousWindLayer(vm, sourceUrl) { crs: L.CRS.EPSG4326, opacity: 0.6, attribution: "Bureau of Meteorology", + _cb: data?.cacheBuster, }) ); }, @@ -598,8 +603,8 @@ export function registerBOMFloodWarningBoundariesLayer(vm, sourceUrl) { menuGroup: "BOM Observations", refreshMs: 3600000, // 1 hr – reference data visibleByDefault: localStorage.getItem(`ov.bomFloodWarningBoundaries`) || false, - fetchFn: async () => ({}), - drawFn: (layerGroup, _data) => { + fetchFn: async () => ({ cacheBuster: Date.now() }), + drawFn: (layerGroup, data) => { layerGroup.addLayer( L.tileLayer.wms(wmsUrl, { layers: "IDM00017", @@ -609,6 +614,7 @@ export function registerBOMFloodWarningBoundariesLayer(vm, sourceUrl) { version: "1.3.0", crs: L.CRS.EPSG4326, attribution: "Bureau of Meteorology", + _cb: data?.cacheBuster, }) ); }, @@ -626,8 +632,8 @@ export function registerBOMFireWeatherDistrictsLayer(vm, sourceUrl) { menuGroup: "BOM Observations", refreshMs: 3600000, // 1 hr – reference data visibleByDefault: localStorage.getItem(`ov.bomFireWeatherDistricts`) || false, - fetchFn: async () => ({}), - drawFn: (layerGroup, _data) => { + fetchFn: async () => ({ cacheBuster: Date.now() }), + drawFn: (layerGroup, data) => { layerGroup.addLayer( L.tileLayer.wms(wmsUrl, { layers: ["IDM00007", "IDM00021"], @@ -637,6 +643,7 @@ export function registerBOMFireWeatherDistrictsLayer(vm, sourceUrl) { version: "1.3.0", crs: L.CRS.EPSG4326, attribution: "Bureau of Meteorology", + _cb: data?.cacheBuster, }) ); }, diff --git a/src/pages/tasking/markers/jobMarker.js b/src/pages/tasking/markers/jobMarker.js index cc764e3c..8d37f3e8 100644 --- a/src/pages/tasking/markers/jobMarker.js +++ b/src/pages/tasking/markers/jobMarker.js @@ -90,7 +90,12 @@ export function addOrUpdateJobMarker(ko, map, vm, job) { marker._isRescue = isRescue; marker._isNew = (job.statusName?.() || '').toLowerCase() === 'new'; marker._priorityColor = style.fill || '#6b7280'; - marker.addTo(targetLayer); + if (targetLayer === vm.mapVM.jobClusterGroup) { + marker.addTo(targetLayer); + //vm.mapVM.safeAddToClusterGroup?.(marker); + } else { + marker.addTo(targetLayer); + } markers.set(id, marker); job.marker = marker; diff --git a/src/pages/tasking/models/Address.js b/src/pages/tasking/models/Address.js index 82f3a5a2..c479ce8a 100644 --- a/src/pages/tasking/models/Address.js +++ b/src/pages/tasking/models/Address.js @@ -10,6 +10,7 @@ export function Address(data = {}) { this.locality = ko.observable(data.Locality ?? ""); this.postCode = ko.observable(data.PostCode ?? ""); this.prettyAddress = ko.observable(data.PrettyAddress ?? ""); + this.additionalAddressInfo = ko.observable(data.AdditionalAddressInfo ?? null); this.latLng = ko.pureComputed(() => { const lat = +this.latitude(), lng = +this.longitude(); diff --git a/src/pages/tasking/models/Job.js b/src/pages/tasking/models/Job.js index 56537c0e..456e659e 100644 --- a/src/pages/tasking/models/Job.js +++ b/src/pages/tasking/models/Job.js @@ -602,6 +602,7 @@ export function Job(data = {}, deps = {}) { this.address.locality(d.Address?.Locality ?? ""); this.address.postCode(d.Address?.PostCode ?? ""); this.address.prettyAddress(d.Address?.PrettyAddress ?? ""); + this.address.additionalAddressInfo(d.Address?.AdditionalAddressInfo ?? null); } if (Array.isArray(d.Tags)) { diff --git a/src/pages/tasking/viewmodels/Config.js b/src/pages/tasking/viewmodels/Config.js index ed7b86bd..d4a60a97 100644 --- a/src/pages/tasking/viewmodels/Config.js +++ b/src/pages/tasking/viewmodels/Config.js @@ -46,8 +46,8 @@ export function ConfigVM(root, deps) { self.paneDefs = [ { id: 'pane-tippy-top', name: 'Incident markers' }, { id: 'pane-top', name: 'Asset markers' }, - { id: 'pane-middle', name: 'Map overlays icons' }, - { id: 'pane-lowest', name: 'Map overlay polygons' } + { id: 'pane-middle', name: 'Map overlays icons & labels' }, + { id: 'pane-lowest', name: 'Map overlay polygons & drawings' } ]; // UI list (objects so bindings are property-only) diff --git a/src/pages/tasking/viewmodels/Map.js b/src/pages/tasking/viewmodels/Map.js index 7234d81a..e9dbf27b 100644 --- a/src/pages/tasking/viewmodels/Map.js +++ b/src/pages/tasking/viewmodels/Map.js @@ -359,6 +359,58 @@ export function MapVM(Lmap, root) { self._syncPulseRings(); }; + /** + * Ensure markercluster internal distance grids are compatible with the + * map's current min/max zoom constraints. + * + * Some overlay layers can change map zoom levels (`zoomlevelschange`). + * markercluster keeps prebuilt grids keyed by zoom level; if map minZoom + * drops below those keys, `addLayer` can hit an undefined grid entry and + * throw in `_addLayer`. + */ + self.ensureClusterGridCompatibility = function () { + const cg = self.jobClusterGroup; + if (!cg || !cg._map) return; + + const minZoom = Math.floor(self.map.getMinZoom()); + const expectedTopZoom = minZoom - 1; + const disableAt = cg.options?.disableClusteringAtZoom; + const expectedMaxZoom = Number.isFinite(disableAt) + ? (disableAt - 1) + : Math.ceil(self.map.getMaxZoom()); + + const grids = cg._gridClusters; + const unclustered = cg._gridUnclustered; + const gridsMissingForMinZoom = !grids || !unclustered || !grids[minZoom] || !unclustered[minZoom]; + const maxZoomMismatch = !Number.isFinite(cg._maxZoom) || cg._maxZoom !== expectedMaxZoom; + const topZoomMismatch = !cg._topClusterLevel || !Number.isFinite(cg._topClusterLevel._zoom) + || cg._topClusterLevel._zoom !== expectedTopZoom; + + const safeCurrentZoom = Number.isFinite(self.map.getZoom()) + ? Math.round(self.map.getZoom()) + : (expectedTopZoom + 1); + + if (!gridsMissingForMinZoom && !maxZoomMismatch && !topZoomMismatch) { + const minClusterZoom = (cg._topClusterLevel?._zoom ?? expectedTopZoom) + 1; + if (!Number.isFinite(cg._zoom) || cg._zoom < minClusterZoom) { + cg._zoom = Math.max(safeCurrentZoom, minClusterZoom); + } + return; + } + + const markers = []; + cg.eachLayer((m) => markers.push(m)); + + // clearLayers() on an attached cluster group rebuilds internal grids. + cg.clearLayers(); + const minClusterZoom = (cg._topClusterLevel?._zoom ?? expectedTopZoom) + 1; + cg._zoom = Math.max(safeCurrentZoom, minClusterZoom); + if (markers.length) cg.addLayers(markers); + }; + + // Reconcile cluster grids whenever any layer changes map min/max zooms. + self.map.on('zoomlevelschange', self.ensureClusterGridCompatibility); + self.applyPaneOrder = function (paneOrderTopToBottom) { if (!Array.isArray(paneOrderTopToBottom) || paneOrderTopToBottom.length === 0) return; @@ -919,7 +971,17 @@ export function MapVM(Lmap, root) { return; } - const visibleParent = self.jobClusterGroup.getVisibleParent(marker); + let visibleParent; + try { + visibleParent = self.jobClusterGroup.getVisibleParent(marker); + } catch (err) { + // On error, hide the pulse ring to be safe + if (self.jobPulseLayer.hasLayer(marker._pulseRing)) { + self.jobPulseLayer.removeLayer(marker._pulseRing); + } + return; + } + if (visibleParent === marker) { // Marker is individually visible – show pulse ring if (!self.jobPulseLayer.hasLayer(marker._pulseRing)) { diff --git a/static/pages/tasking.html b/static/pages/tasking.html index 28cb0a8a..aa2468df 100644 --- a/static/pages/tasking.html +++ b/static/pages/tasking.html @@ -1316,6 +1316,20 @@
+ + +
+ +
+

+

+
+
+ +
- When enabled, Rescue-priority jobs cluster with + When enabled, Rescue priority incidents cluster with other markers.
@@ -2832,7 +2846,7 @@
- +
@@ -2847,7 +2861,7 @@
-
+