-
- Basemap
-
-
+
+
+
-
-
-
-
`;
- // --- 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 = '
';
+
+ // 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 @@
-
-
-
+
+