diff --git a/src/pages/tasking/bindings/sortableArray.js b/src/pages/tasking/bindings/sortableArray.js new file mode 100644 index 00000000..0082146c --- /dev/null +++ b/src/pages/tasking/bindings/sortableArray.js @@ -0,0 +1,78 @@ +import ko from "knockout"; + + +export function installSortableArrayBindings() { + + ko.bindingHandlers.sortableRow = { + init: function (el, valueAccessor, allBindings, viewModel, bindingContext) { + const opts = ko.unwrap(valueAccessor()) || {}; + const arr = opts.array; // observableArray + const handleSel = opts.handle; // optional CSS selector for handle inside el + + if (!arr || typeof arr.remove !== "function" || typeof arr.splice !== "function") { + console.warn("sortableRow: opts.array must be an observableArray"); + return; + } + + // allow dragging from handle only if provided + const getHandle = () => (handleSel ? (el.querySelector(handleSel) || el) : el); + + // mark draggable + el.setAttribute("draggable", "true"); + + // KSB-safe: item is from bindingContext.$data + const getIndex = () => { + const a = arr(); + const item = bindingContext.$data; + return a.indexOf(item); + }; + + el.addEventListener("dragstart", (e) => { + const h = getHandle(); + // if handle selector is used, only allow drag when starting on/within handle + if (handleSel && e.target && !h.contains(e.target)) { + e.preventDefault(); + return; + } + + const idx = getIndex(); + if (idx < 0) return; + + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", String(idx)); + el.classList.add("is-dragging"); + }); + + el.addEventListener("dragend", () => { + el.classList.remove("is-dragging"); + }); + + el.addEventListener("dragover", (e) => { + e.preventDefault(); // required to allow drop + e.dataTransfer.dropEffect = "move"; + el.classList.add("is-dragover"); + }); + + el.addEventListener("dragleave", () => { + el.classList.remove("is-dragover"); + }); + + el.addEventListener("drop", (e) => { + e.preventDefault(); + el.classList.remove("is-dragover"); + + const from = parseInt(e.dataTransfer.getData("text/plain"), 10); + const to = getIndex(); + if (!Number.isFinite(from) || from < 0 || to < 0 || from === to) return; + + const a = arr(); + const item = a[from]; + if (!item) return; + + // move item within array + arr.splice(from, 1); + arr.splice(to, 0, item); + }); + } + }; +} diff --git a/src/pages/tasking/main.js b/src/pages/tasking/main.js index 2a90a9ab..037d0c80 100644 --- a/src/pages/tasking/main.js +++ b/src/pages/tasking/main.js @@ -44,6 +44,7 @@ import { installSlideVisibleBinding } from "./bindings/slideVisible.js"; import { installStatusFilterBindings } from "./bindings/statusFilters.js"; import { installRowVisibilityBindings } from "./bindings/rowVisibility.js"; import { installDragDropRowBindings } from "./bindings/dragDropRows.js"; +import { installSortableArrayBindings } from "./bindings/sortableArray.js"; import { noBubbleFromDisabledButtonsBindings } from "./bindings/noBubble.js" import { registerTransportCamerasLayer } from "./mapLayers/transport.js"; @@ -144,8 +145,19 @@ const map = L.map('map', { }).setView([-33.8688, 151.2093], 11); map.createPane('pane-lowest'); map.getPane('pane-lowest').style.zIndex = 300; +map.createPane('pane-lowest-plus'); map.getPane('pane-lowest-plus').style.zIndex = 301; + map.createPane('pane-middle'); map.getPane('pane-middle').style.zIndex = 400; +map.createPane('pane-middle-plus'); map.getPane('pane-middle-plus').style.zIndex = 401; + + map.createPane('pane-top'); map.getPane('pane-top').style.zIndex = 600; +map.createPane('pane-top-plus'); map.getPane('pane-top-plus').style.zIndex = 601; + + +map.createPane('pane-tippy-top'); map.getPane('pane-tippy-top').style.zIndex = 700; +map.createPane('pane-tippy-top-plus'); map.getPane('pane-tippy-top-plus').style.zIndex = 701; + var osm2 = new L.TileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { minZoom: 0, maxZoom: 13 }); new MiniMap(osm2, { toggleDisplay: true }).addTo(map); @@ -294,18 +306,38 @@ function VM() { }; // --- sorted arrays --- - self.sortedTeams = ko.pureComputed(function () { - var key = self.teamSortKey(), asc = self.teamSortAsc(); - var arr = self.filteredTeams(); // existing - return arr.slice().sort(function (a, b) { - var av = ko.unwrap(a[key]), bv = ko.unwrap(b[key]); + let teamLastKey, teamLastAsc, teamLastInput, teamLastOutput; + + self.sortedTeams = ko.pureComputed(function () { //heavy caching to reduce the slice/sort load + const key = self.teamSortKey(); + const asc = self.teamSortAsc(); + const input = self.filteredTeams(); + + if (input === teamLastInput && key === teamLastKey && asc === teamLastAsc) { + return teamLastOutput; + } + + teamLastInput = input; + teamLastKey = key; + teamLastAsc = asc; + teamLastOutput = input.slice().sort(function (a, b) { + // Support nested keys like "entityAssignedTo.code" + var av = key.includes('.') + ? key.split('.').reduce((obj, k) => ko.unwrap(obj?.[k]), a) + : ko.unwrap(a[key]); + + var bv = key.includes('.') + ? key.split('.').reduce((obj, k) => ko.unwrap(obj?.[k]), b) + : ko.unwrap(b[key]); + var an = typeof av === 'number' || /^\d+(\.\d+)?$/.test(av); var bn = typeof bv === 'number' || /^\d+(\.\d+)?$/.test(bv); var cmp = (an && bn) ? (Number(av) - Number(bv)) : String(av || '').localeCompare(String(bv || ''), undefined, { numeric: true }); return asc ? cmp : -cmp; }); - }); + return teamLastOutput; + }).extend({ rateLimit: { timeout: 100, method: 'notifyWhenChangesStop' } }); const JOB_STATUS_ORDER = [ 'New', @@ -323,13 +355,30 @@ function VM() { return m; }, Object.create(null)); + let jobLastKey, jobLastAsc, jobLastInput, jobLastOutput; + self.sortedJobs = ko.pureComputed(function () { - var key = self.jobSortKey(), asc = self.jobSortAsc(); - var arr = self.filteredJobs(); + const key = self.jobSortKey() + const asc = self.jobSortAsc(); + const input = self.filteredJobs(); - return arr.slice().sort(function (a, b) { - var av = ko.unwrap(a[key]); - var bv = ko.unwrap(b[key]); + if (input === jobLastInput && key === jobLastKey && asc === jobLastAsc) { + return jobLastOutput; + } + + jobLastInput = input; + jobLastKey = key; + jobLastAsc = asc; + + jobLastOutput = input.slice().sort(function (a, b) { + // Support nested keys like "entityAssignedTo.code" + var av = key.includes('.') + ? key.split('.').reduce((obj, k) => ko.unwrap(obj?.[k]), a) + : ko.unwrap(a[key]); + + var bv = key.includes('.') + ? key.split('.').reduce((obj, k) => ko.unwrap(obj?.[k]), b) + : ko.unwrap(b[key]); // --- custom status order --- if (key === 'statusName') { @@ -347,6 +396,7 @@ function VM() { return asc ? cmp : -cmp; }); + return jobLastOutput; }); // --- UI updater shared helper --- @@ -413,7 +463,7 @@ function VM() { const term = self.jobSearch().toLowerCase(); const allowedStatus = self.config.jobStatusFilter(); // allow-list - const incidentTypeAllowedById = self.config.incidentTypeFilter().map(type => Enum.IncidentType[type]?.Id).filter(id => id !== undefined); + const incidentTypeAllowedById = self.config.allowedIncidentTypeIds(); // allow-list var start = new Date(); var end = new Date(); @@ -478,37 +528,7 @@ function VM() { return true; }) - }).extend({ trackArrayChanges: true, rateLimit: 50 }); - - //ignores the word filering. Maybe dont need this anymore. - self.filteredJobsIgnoreSearch = ko.pureComputed(() => { - - const hqsFilter = self.config.incidentFilters().map(f => ({ Id: f.id })); - const allowedStatus = self.config.jobStatusFilter(); // allow-list - - const incidentTypeAllowedById = self.config.incidentTypeFilter().map(type => Enum.IncidentType[type]?.Id).filter(id => id !== undefined); - - return ko.utils.arrayFilter(this.jobs(), jb => { - - const statusName = jb.statusName(); - - // If allow-list non-empty, only show jobs whose status is in it - if (allowedStatus.length > 0 && !allowedStatus.includes(statusName)) { - return false; - } - - // If incident type filter non-empty, only show jobs whose type is in it - if (incidentTypeAllowedById.length > 0 && !incidentTypeAllowedById.includes(jb.typeId())) { - return false; - } - - // If no HQ filters are active, skip HQ filtering - const hqMatch = hqsFilter.length === 0 || hqsFilter.some(f => f.Id === jb.entityAssignedTo.id()); - if (!hqMatch) return false; - - return true - }); - }).extend({ trackArrayChanges: true }); + }).extend({ trackArrayChanges: true, rateLimit: { timeout: 100, method: 'notifyWhenChangesStop' } }); // Team filtering/searching self.teamSearch = ko.observable(''); @@ -559,7 +579,7 @@ function VM() { return false; }); - }).extend({ trackArrayChanges: true, rateLimit: 50 }); + }).extend({ trackArrayChanges: true, rateLimit: { timeout: 100, method: 'notifyWhenChangesStop' } }); @@ -570,7 +590,7 @@ function VM() { // for each filtered team, get their trackable assets and flatten to single array return self.filteredTeams().flatMap(t => t.trackableAssets() || []); - }).extend({ trackArrayChanges: true, rateLimit: 50 }); + }).extend({ trackArrayChanges: true, rateLimit: { timeout: 100, method: 'notifyWhenChangesStop' } }); self.unmatchedTrackableAssets = ko.pureComputed(() => { @@ -579,7 +599,7 @@ function VM() { const mt = (typeof a.matchingTeams === 'function') ? a.matchingTeams() : []; return !mt || mt.length === 0; }); - }).extend({ trackArrayChanges: true, rateLimit: 50 }); + }).extend({ trackArrayChanges: true, rateLimit: { timeout: 100, method: 'notifyWhenChangesStop' } }); self.sectorsLoading = ko.observable(false); @@ -1399,6 +1419,7 @@ function VM() { self.filteredTeams.subscribe((changes) => { changes.forEach(ch => { if (ch.status === 'added') { + console.log("Team filtered in, fetching tasking:", ch.value.callsign()); ch.value.isFilteredIn(true); ch.value.fetchTasking(); } else if (ch.status === 'deleted') { @@ -1445,6 +1466,10 @@ function VM() { }, null, "arrayChange"); self.unmatchedTrackableAssets.subscribe((changes) => { + // bail fast if the layer is not currently visible + if (!self.mapVM || !map.hasLayer(self.mapVM.unmatchedAssetLayer)) { + return; + } changes.forEach(ch => { const a = ch.value; if (ch.status === 'added') { @@ -1455,6 +1480,24 @@ function VM() { }); }, null, "arrayChange"); + map.on('layeradd', (ev) => { + if (ev.layer !== self.mapVM.unmatchedAssetLayer) return; + // initial populate unmatchedTrackableAssets only when layer becomes visible + const assets = self.unmatchedTrackableAssets?.() || []; + assets.forEach(a => { + attachUnmatchedAssetMarker(ko, self.map, self, a); + }); + }); + + // catch the unmatchedTrackableAssets layer being turned off to remove markers + map.on('layerremove', (ev) => { + if (ev.layer !== self.mapVM.unmatchedAssetLayer) return; + const assets = self.unmatchedTrackableAssets?.() || []; + assets.forEach(a => { + attachUnmatchedAssetMarker(ko, self.map, self, a); + }); + }); + self.showConfirmTaskingModal = function (jobVm, teamVm) { if (!jobVm || !teamVm) return; @@ -1664,11 +1707,9 @@ function VM() { // Remove from observable array and registry self.trackableAssets.remove(asset); self.assetsById.delete(id); - - //Update Asset/Team mappings only once after all changes - self._attachAssetsToMatchingTeams(); - }); + //Update Asset/Team mappings only once after all changes + self._attachAssetsToMatchingTeams(); myViewModel._markInitialFetchDone(); assetDataRefreshInterlock = false; }, function (err) { @@ -2349,6 +2390,7 @@ document.addEventListener('DOMContentLoaded', function () { installRowVisibilityBindings(); installDragDropRowBindings(); noBubbleFromDisabledButtonsBindings(); + installSortableArrayBindings(); ko.bindingProvider.instance = new ksb(options); window.ko = ko; diff --git a/src/pages/tasking/mapLayers/frao.js b/src/pages/tasking/mapLayers/frao.js index 5bbcf791..83ee9c94 100644 --- a/src/pages/tasking/mapLayers/frao.js +++ b/src/pages/tasking/mapLayers/frao.js @@ -134,6 +134,7 @@ export function renderFRAOSLayer(vm, map, getToken, apiHost, params) { if (!polygonGeoJson || polygonGeoJson.message) return; const polygon = L.geoJSON(polygonGeoJson, { + pane: 'pane-lowest', style: { color, weight: 2, @@ -151,6 +152,7 @@ export function renderFRAOSLayer(vm, map, getToken, apiHost, params) { const labelMarker = L.marker(center, { interactive: false, + pane: 'pane-lowest-plus', icon: L.divIcon({ className: "frao-label", html: labelText, diff --git a/src/pages/tasking/mapLayers/geoservices.js b/src/pages/tasking/mapLayers/geoservices.js index d9ed42a6..67dfce1e 100644 --- a/src/pages/tasking/mapLayers/geoservices.js +++ b/src/pages/tasking/mapLayers/geoservices.js @@ -157,7 +157,7 @@ export function registerSESUnitsZonesHybridGridLayer(vm, map) { const vectorGrid = L.vectorGrid.protobuf( `https://map.lighthouse-extension.com/sesunits/tiles/{z}/{x}/{y}.pbf`, // Replace with actual path { - + pane: 'pane-lowest', vectorTileLayerStyles: { 'SESUnit_JSON': (props, zoom) => { const zoneColor = zoneFillColor(props.ZONECODE); // shared per zone @@ -167,7 +167,6 @@ export function registerSESUnitsZonesHybridGridLayer(vm, map) { const fill = useUnitColors ? unitColor : zoneColor; return { - pane: 'pane-middle', weight: useUnitColors ? 1 : 1.2, color: '#333', opacity: 1, @@ -188,6 +187,7 @@ export function registerSESUnitsZonesHybridGridLayer(vm, map) { const name = feature.properties.ZONENAME; return L.marker(latlng, { interactive: false, + pane: 'pane-middle-plus', icon: L.divIcon({ className: 'zone-label', html: name, @@ -203,7 +203,7 @@ export function registerSESUnitsZonesHybridGridLayer(vm, map) { return L.marker(latlng, { interactive: false, - pane: 'pane-middle', + pane: 'pane-middle-plus', icon: L.divIcon({ className: 'unit-label', html: name, diff --git a/src/pages/tasking/mapLayers/hazardwatch.js b/src/pages/tasking/mapLayers/hazardwatch.js index 7e034a67..cd872baa 100644 --- a/src/pages/tasking/mapLayers/hazardwatch.js +++ b/src/pages/tasking/mapLayers/hazardwatch.js @@ -193,7 +193,7 @@ export function registerHazardWatchWarningsLayer(vm, apiHost) { const marker = L.marker(center, { icon, - pane: "pane-lowest", + pane: "pane-middle", interactive: true, }); diff --git a/src/pages/tasking/markers/assetMarker.js b/src/pages/tasking/markers/assetMarker.js index a4a5f5b6..3f908bed 100644 --- a/src/pages/tasking/markers/assetMarker.js +++ b/src/pages/tasking/markers/assetMarker.js @@ -4,42 +4,61 @@ import { makePopupNode, bindKoToPopup, unbindKoFromPopup, deferPopupUpdate } fro import { buildAssetPopupKO } from '../components/asset_popup.js'; import { buildIcon } from '../components/asset_icon.js'; +function refreshAssetMarkerIcons(asset) { + //check the html of a new icon vs the current icon to avoid unnecessary updates + + // matched marker + if (asset.marker) { + const newIcon = buildIcon(asset, 'matched'); + if (asset.marker.options.icon.options.html !== newIcon.options.html) { + asset.marker.setIcon(newIcon); + } + } + + // unmatched marker + if (asset.unmatchedMarker) { + const newIcon = buildIcon(asset, 'unmatched'); + if (asset.unmatchedMarker.options.icon.options.html !== newIcon.options.html) { + asset.unmatchedMarker.setIcon(newIcon); + } + } +} /** * Smoothly move (or just set) the marker to a position. */ function moveMarker(marker, lat, lng, { duration = 700, fps = 60 } = {}) { - if (!marker) return; - const to = L.latLng(lat, lng); - const from = marker.getLatLng?.() || to; - const dist = from.distanceTo ? from.distanceTo(to) : 0; - - // small move -> no animation - if (dist < 1) { - marker.setLatLng(to); - return; - } + if (!marker) return; + const to = L.latLng(lat, lng); + const from = marker.getLatLng?.() || to; + const dist = from.distanceTo ? from.distanceTo(to) : 0; + + // small move -> no animation + if (dist < 1) { + marker.setLatLng(to); + return; + } - // cancel previous animation - if (marker._moveAnimCancel) marker._moveAnimCancel(); - - const frames = Math.max(1, Math.round((duration / 1000) * fps)); - let f = 0; - let rafId = null; - - const step = () => { - f += 1; - const t = f / frames; - const latS = from.lat + (to.lat - from.lat) * t; - const lngS = from.lng + (to.lng - from.lng) * t; - marker.setLatLng([latS, lngS]); - if (f < frames) rafId = requestAnimationFrame(step); - else marker._moveAnimCancel = null; - }; - - marker._moveAnimCancel = () => { if (rafId) cancelAnimationFrame(rafId); }; - rafId = requestAnimationFrame(step); + // cancel previous animation + if (marker._moveAnimCancel) marker._moveAnimCancel(); + + const frames = Math.max(1, Math.round((duration / 1000) * fps)); + let f = 0; + let rafId = null; + + const step = () => { + f += 1; + const t = f / frames; + const latS = from.lat + (to.lat - from.lat) * t; + const lngS = from.lng + (to.lng - from.lng) * t; + marker.setLatLng([latS, lngS]); + if (f < frames) rafId = requestAnimationFrame(step); + else marker._moveAnimCancel = null; + }; + + marker._moveAnimCancel = () => { if (rafId) cancelAnimationFrame(rafId); }; + rafId = requestAnimationFrame(step); } /** @@ -47,66 +66,71 @@ function moveMarker(marker, lat, lng, { duration = 700, fps = 60 } = {}) { * marker updated without walking the whole asset list. */ export function attachAssetMarker(ko, map, viewModel, asset) { - if (!asset) return; - - // Ensure layer exists - const layer = viewModel.mapVM.assetLayer; - - // Marker create (once) - const lat = +asset.latitude?.(); - const lng = +asset.longitude?.(); - if (Number.isFinite(lat) && Number.isFinite(lng) && !asset.marker) { - const icon = buildIcon(asset, 'matched'); - const m = L.marker([lat, lng], { icon, pane: 'pane-top' }); - m._assetId = asset.id?.(); - const html = buildAssetPopupKO(); - const contentEl = makePopupNode(html, 'veh-pop-root'); // stable node - const popup = L.popup({ - minWidth: 360, - maxWidth: 360, - maxHeight: 360, - autoPan: true, - autoPanPadding: [16, 16], - }).setContent(contentEl); - - - m.bindPopup(popup) - m.addTo(layer); - asset.marker = m; - - // Ask MapVM to create the AssetPopupViewModel for this asset: - const popupVm = viewModel.mapVM.makeAssetPopupVM(asset); - bindPopupWithKO(ko, asset.marker, viewModel, asset, popupVm); - - // Track what's open - asset.marker.on('popupopen', () => { - viewModel.mapVM.setOpen('asset', asset); - }); - } + if (!asset) return; - // Already wired? Done. - if (asset._markerSubs && asset._markerSubs.length) return; + // Ensure layer exists + const layer = viewModel.mapVM.assetLayer; - // Per-asset subscriptions (store so we can dispose later) - const subs = []; + // Marker create (once) + const lat = +asset.latitude?.(); + const lng = +asset.longitude?.(); + if (Number.isFinite(lat) && Number.isFinite(lng) && !asset.marker) { + const icon = buildIcon(asset, 'matched'); + const m = L.marker([lat, lng], { icon, pane: 'pane-top' }); + m._assetId = asset.id?.(); + const html = buildAssetPopupKO(); + const contentEl = makePopupNode(html, 'veh-pop-root'); // stable node + const popup = L.popup({ + minWidth: 360, + maxWidth: 360, + maxHeight: 360, + autoPan: true, + autoPanPadding: [16, 16], + }).setContent(contentEl); - // Position changes -> smooth move - subs.push(asset.latitude.subscribe(v => { - const latNow = +v, lngNow = +asset.longitude(); - if (asset.marker && Number.isFinite(latNow) && Number.isFinite(lngNow)) { - moveMarker(asset.marker, latNow, lngNow); - } - })); - subs.push(asset.longitude.subscribe(v => { - const latNow = +asset.latitude(), lngNow = +v; - if (asset.marker && Number.isFinite(latNow) && Number.isFinite(lngNow)) { - moveMarker(asset.marker, latNow, lngNow); - } - })); + m.bindPopup(popup) + m.addTo(layer); + asset.marker = m; + + // Ask MapVM to create the AssetPopupViewModel for this asset: + const popupVm = viewModel.mapVM.makeAssetPopupVM(asset); + bindPopupWithKO(ko, asset.marker, viewModel, asset, popupVm); + + // Track what's open + asset.marker.on('popupopen', () => { + viewModel.mapVM.setOpen('asset', asset); + }); + } + // Already wired? Done. + if (asset._markerSubs && asset._markerSubs.length) return; - asset._markerSubs = subs; + // Per-asset subscriptions (store so we can dispose later) + const subs = []; + + // Position changes -> smooth move + subs.push(asset.latitude.subscribe(v => { + const latNow = +v, lngNow = +asset.longitude(); + if (asset.marker && Number.isFinite(latNow) && Number.isFinite(lngNow)) { + moveMarker(asset.marker, latNow, lngNow); + } + })); + subs.push(asset.longitude.subscribe(v => { + const latNow = +asset.latitude(), lngNow = +v; + if (asset.marker && Number.isFinite(latNow) && Number.isFinite(lngNow)) { + moveMarker(asset.marker, latNow, lngNow); + } + })); + + // lastSeen updated -> rebuild icon so dull/contrast updates + subs.push(asset.lastSeen.subscribe(() => { + refreshAssetMarkerIcons(asset); + })); + + + + asset._markerSubs = subs; } @@ -116,16 +140,16 @@ export function attachAssetMarker(ko, map, viewModel, asset) { * Detach subscriptions and remove the marker for an asset. */ export function detachAssetMarker(ko, map, viewModel, asset) { - if (!asset) return; - if (asset._markerSubs) { - asset._markerSubs.forEach(s => s.dispose?.()); - asset._markerSubs = []; - } - if (asset.marker) { - viewModel.mapVM.assetLayer.removeLayer(asset.marker); - asset.marker = null; - } - viewModel.mapVM.destroyAssetPopupVM(asset); + if (!asset) return; + if (asset._markerSubs) { + asset._markerSubs.forEach(s => s.dispose?.()); + asset._markerSubs = []; + } + if (asset.marker) { + viewModel.mapVM.assetLayer.removeLayer(asset.marker); + asset.marker = null; + } + viewModel.mapVM.destroyAssetPopupVM(asset); } export function attachUnmatchedAssetMarker(ko, map, viewModel, asset) { @@ -138,7 +162,7 @@ export function attachUnmatchedAssetMarker(ko, map, viewModel, asset) { if (Number.isFinite(lat) && Number.isFinite(lng) && !asset.unmatchedMarker) { const icon = buildIcon(asset, 'unmatched'); - const m = L.marker([lat, lng], { icon, pane: 'pane-middle' }); + const m = L.marker([lat, lng], { icon, pane: 'pane-top' }); m._assetId = asset.id?.(); const html = buildAssetPopupKO(); @@ -178,6 +202,10 @@ export function attachUnmatchedAssetMarker(ko, map, viewModel, asset) { if (asset.unmatchedMarker && Number.isFinite(latNow) && Number.isFinite(lngNow)) { moveMarker(asset.unmatchedMarker, latNow, lngNow); } + //last seen changes + subs.push(asset.lastSeen.subscribe(() => { + refreshAssetMarkerIcons(asset); + })); })); asset._unmatchedMarkerSubs = subs; @@ -194,36 +222,40 @@ export function detachUnmatchedAssetMarker(ko, map, viewModel, asset) { viewModel.mapVM.unmatchedAssetLayer.removeLayer(asset.unmatchedMarker); asset.unmatchedMarker = null; } - - // keep popup VM (shared) alive; OR destroy if you prefer symmetry: - // viewModel.mapVM.destroyAssetPopupVM(asset); } function bindPopupWithKO(ko, marker, vm, asset, popupVm) { - const openHandler = (e) => { - const el = e.popup.getContent(); // our stable node - vm.mapVM.setOpen?.('asset', asset); - bindKoToPopup(ko, popupVm, el); - // If no team row has focus, open the first team's popup. stops fucky UI jumps when theres multiple teams bound to one asset - // where it would jump to a different row when you cycle the assets - if (!asset.matchingTeamsInView()?.some(team => team.rowHasFocus && team.rowHasFocus())) { - asset.matchingTeamsInView()?.length !== 0 && asset.matchingTeamsInView()[0].onPopupOpen(); - } - popupVm.updatePopup?.(); - deferPopupUpdate(e.popup); - }; - const closeHandler = (e) => { - const el = e.popup?.getContent(); - vm.mapVM.clearCrowFliesLine(); - vm.mapVM.clearRoutes?.(); - vm.mapVM.clearOpen?.(); - unbindKoFromPopup(ko, el); - asset.matchingTeamsInView()?.length !==0 && asset.matchingTeamsInView()[0].onPopupClose() - }; - marker._koWired = true; - marker.on('popupopen', openHandler); - marker.on('popupclose', closeHandler); - marker.on('remove', closeHandler); // safety + const openHandler = (e) => { + const el = e.popup.getContent(); // our stable node + vm.mapVM.setOpen?.('asset', asset); + bindKoToPopup(ko, popupVm, el); + + // If no team row has focus, open the first team's popup. + if (!asset.matchingTeamsInView()?.some(team => team.rowHasFocus && team.rowHasFocus())) { + asset.matchingTeamsInView()?.length !== 0 && asset.matchingTeamsInView()[0].onPopupOpen(); + } + popupVm.updatePopup?.(); + deferPopupUpdate(e.popup); + }; + + // Unbind after popup is fully closed for visual cleanliness + const closeHandler = (e) => { + const el = e.popup?.getContent(); + vm.mapVM.clearCrowFliesLine(); + vm.mapVM.clearRoutes?.(); + vm.mapVM.clearOpen?.(); + asset.matchingTeamsInView()?.length !== 0 && asset.matchingTeamsInView()[0].onPopupClose(); + + // Defer unbinding to after the close animation completes + setTimeout(() => { + unbindKoFromPopup(ko, el); + }, 250); // 250ms matches Leaflet's default fade animation + }; + + marker._koWired = true; + marker.on('popupopen', openHandler); + marker.on('popupclose', closeHandler); + marker.on('remove', closeHandler); // safety } \ No newline at end of file diff --git a/src/pages/tasking/markers/jobMarker.js b/src/pages/tasking/markers/jobMarker.js index 996a9ef3..ad5ed062 100644 --- a/src/pages/tasking/markers/jobMarker.js +++ b/src/pages/tasking/markers/jobMarker.js @@ -31,6 +31,7 @@ export function addOrUpdateJobMarker(ko, map, vm, job) { const marker = L.marker([lat, lng], { + pane: 'pane-tippy-top', icon: makeShapeIcon(style), title: job.identifier?.() }).bindPopup(popup); @@ -120,42 +121,42 @@ export function removeJobMarker(vm, jobOrId) { //complicated for some reason. has to support different icons sizes and anchors function upsertPulseRing(layerGroup, job, marker) { - const isNew = (job.statusName?.() || '').toLowerCase() === 'new'; - const base = marker.options.icon?.options || {}; - const baseSize = base.iconSize || [14, 14]; - const baseAnchor = base.iconAnchor|| [baseSize[0]/2, baseSize[1]/2]; - - if (isNew && !marker._pulseRing) { - const k = 4; - 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 ring = L.marker(marker.getLatLng(), { - pane: 'pane-top', - icon: L.divIcon({ - className: 'pulse-ring-icon', - html: '
', - iconSize: ringSize, - iconAnchor: ringAnchor - }), - interactive: false, - keyboard: false - }); - - const follow = () => ring.setLatLng(marker.getLatLng()); - marker.on('move', follow); - ring._detach = () => marker.off('move', follow); - - ring.setZIndexOffset((marker.options?.zIndexOffset||0)+1); - ring.addTo(layerGroup); - marker._pulseRing = ring; - } + const isNew = (job.statusName?.() || '').toLowerCase() === 'new'; + const base = marker.options.icon?.options || {}; + const baseSize = base.iconSize || [14, 14]; + const baseAnchor = base.iconAnchor || [baseSize[0] / 2, baseSize[1] / 2]; + + if (isNew && !marker._pulseRing) { + const k = 3; + 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 ring = L.marker(marker.getLatLng(), { + pane: 'pane-tippy-top', + icon: L.divIcon({ + className: 'pulse-ring-icon', + html: '', + iconSize: ringSize, + iconAnchor: ringAnchor + }), + interactive: false, + keyboard: false + }); + + const follow = () => ring.setLatLng(marker.getLatLng()); + marker.on('move', follow); + ring._detach = () => marker.off('move', follow); + + ring.setZIndexOffset((marker.options?.zIndexOffset || 0) + 1); + ring.addTo(layerGroup); + marker._pulseRing = ring; + } - if (!isNew && marker._pulseRing) { - marker._pulseRing._detach?.(); - layerGroup.removeLayer(marker._pulseRing); - marker._pulseRing = null; - } + if (!isNew && marker._pulseRing) { + marker._pulseRing._detach?.(); + layerGroup.removeLayer(marker._pulseRing); + marker._pulseRing = null; + } } // --- internals --- @@ -187,7 +188,10 @@ function wireKoForPopup(ko, marker, job, vm, popupVM) { }); marker.on('popupclose', e => { const el = e.popup.getContent(); - unbindKoFromPopup(ko, el); // clean -> reset + // Defer unbinding to after the close animation completes + setTimeout(() => { + unbindKoFromPopup(ko, el); + }, 250); // 250ms matches Leaflet's default fade animation job.onPopupClose && job.onPopupClose(); vm.mapVM.clearCrowFliesLine(); vm.mapVM.clearRoutes(); diff --git a/src/pages/tasking/models/Asset.js b/src/pages/tasking/models/Asset.js index d74a11cf..e4eb5fe0 100644 --- a/src/pages/tasking/models/Asset.js +++ b/src/pages/tasking/models/Asset.js @@ -24,8 +24,17 @@ export function Asset(data = {}) { self.matchingTeamsInView = ko.pureComputed(() => { return self.matchingTeams().filter(t => t.isFilteredIn()); }); + + // Force updates for computed observables using fmtRelative every 30 seconds + self._relativeUpdateTick = ko.observable(0); + + setInterval(() => { + self._relativeUpdateTick(self._relativeUpdateTick() + 1); + }, 1000 * 30); + // Patch computeds to depend on _relativeUpdateTick self.lastSeenJustAgoText = ko.pureComputed(() => { + self._relativeUpdateTick(); // dependency const v = safeStr(self.lastSeen?.()); if (!v) return ""; const d = new Date(v); @@ -34,6 +43,7 @@ export function Asset(data = {}) { }); self.lastSeenText = ko.pureComputed(() => { + self._relativeUpdateTick(); // dependency const v = safeStr(self.lastSeen?.()); if (!v) return ""; const d = new Date(v); @@ -42,6 +52,7 @@ export function Asset(data = {}) { }); self.talkgroupLastUpdatedText = ko.pureComputed(() => { + self._relativeUpdateTick(); // dependency const v = safeStr(self.talkgroupLastUpdated?.()); if (!v) return ""; const d = new Date(v); @@ -49,6 +60,7 @@ export function Asset(data = {}) { return fmtRelative(d); }); + self.latLngText = ko.pureComputed(() => { const lat = self.latitude?.(); const lng = self.longitude?.(); diff --git a/src/pages/tasking/models/Tasking.js b/src/pages/tasking/models/Tasking.js index cdea8f02..c61eb7e2 100644 --- a/src/pages/tasking/models/Tasking.js +++ b/src/pages/tasking/models/Tasking.js @@ -61,7 +61,7 @@ export function Tasking(data = {}) { self.statusSetAt = ko.pureComputed(() => (self.currentStatusTime() ? moment(self.currentStatusTime()).format("DD/MM/YYYY HH:mm:ss") : null)); self.statusTimeAgo = ko.pureComputed(() => { - // read _tick so this recomputes every second + // read _tick so this recomputes every 30 seconds _tick(); const time = self.currentStatusTime(); diff --git a/src/pages/tasking/viewmodels/AssetPopUp.js b/src/pages/tasking/viewmodels/AssetPopUp.js index 39a83e20..d56ede43 100644 --- a/src/pages/tasking/viewmodels/AssetPopUp.js +++ b/src/pages/tasking/viewmodels/AssetPopUp.js @@ -132,12 +132,6 @@ export class AssetPopupViewModel { this.api.clearRoutes(); } - // Example hook – wire this to your app-level handler if needed. - assignTeam() { - const id = this.asset.id?.() ?? this.asset.Identifier ?? this.asset.ID; - window.dispatchEvent(new CustomEvent('assignTeamToAsset', { detail: { assetId: id } })); - } - dispose = () => { // clean up any subscriptions or resources here this.removeRouteToJob(); diff --git a/src/pages/tasking/viewmodels/Config.js b/src/pages/tasking/viewmodels/Config.js index 98855f60..609ef341 100644 --- a/src/pages/tasking/viewmodels/Config.js +++ b/src/pages/tasking/viewmodels/Config.js @@ -1,8 +1,9 @@ /* eslint-disable @typescript-eslint/no-this-alias */ import ko from 'knockout'; -import * as bootstrap from 'bootstrap5'; // gives you Modal, Tooltip, etc. -// +import * as bootstrap from 'bootstrap5'; // Modal, Tooltip, etc. +import { Enum } from '../utils/enum.js'; + const FUNCTION_URL = "https://lambda.lighthouse-extension.com/lad/share"; @@ -30,11 +31,43 @@ export function ConfigVM(root, deps) { // Selected location filters self.teamFilters = ko.observableArray([]); // [{id, name, entityType}] self.incidentFilters = ko.observableArray([]); // [{id, name, entityType}] + self.allowedIncidentTypeIds = ko.pureComputed(() => + new Set(self.incidentTypeFilter() + .map(t => Enum.IncidentType[t]?.Id) + .filter(Boolean)) + ); //Sectors self.sectorFilters = ko.observableArray([]); // [{id, name}] self.includeIncidentsWithoutSector = ko.observable(true); + //Map layer order + + self.paneDefs = [ + { id: 'pane-tippy-top', name: 'Incident markers' }, + { id: 'pane-top', name: 'Asset markers' }, + { id: 'pane-middle', name: 'Map overlays icons' }, + { id: 'pane-lowest', name: 'Map overlay polygons' } + ]; + + // UI list (objects so bindings are property-only) + self.paneOrder = ko.observableArray(self.paneDefs.map(p => ({ id: p.id, name: p.name }))); + + self.rebuildPaneOrderFromIds = function (ids) { + const byId = new Map(self.paneDefs.map(p => [p.id, p])); + const list = (ids || []) + .map(id => byId.get(id)) + .filter(Boolean) + .map(p => ({ id: p.id, name: p.name })); + + // ensure all panes exist (append any missing) + self.paneDefs.forEach(p => { + if (!list.some(x => x.id === p.id)) list.push({ id: p.id, name: p.name }); + }); + + self.paneOrder(list); + } + // Other settings self.refreshInterval = ko.observable(60); self.fetchPeriod = ko.observable(7); @@ -55,6 +88,8 @@ export function ConfigVM(root, deps) { self.pinnedTeamIds = ko.observableArray([]); self.pinnedIncidentIds = ko.observableArray([]); + + self.openLoadBox = function () { self.loadExpanded(true); }; @@ -121,6 +156,7 @@ export function ConfigVM(root, deps) { includeIncidentsWithoutSector: !!self.includeIncidentsWithoutSector(), pinnedTeamIds: ko.toJS(self.pinnedTeamIds), pinnedIncidentIds: ko.toJS(self.pinnedIncidentIds), + paneOrder: self.paneOrder().map(p => p.id), }); // Helpers @@ -153,43 +189,43 @@ export function ConfigVM(root, deps) { self.clearIncidents = () => self.incidentFilters.removeAll(); self.clearSectors = () => self.sectorFilters.removeAll(); -// Search (debounced) -let searchSeq = 0; - -self.query - .extend({ rateLimit: { timeout: 300, method: 'notifyWhenChangesStop' } }) - .subscribe(q => { - const t = (q || '').trim(); - if (!t) { - searchSeq++; - self.searching(false); - self.results([]); - self.dropdownOpen(false); - return; - } - - const mySeq = ++searchSeq; - - // show dropdown immediately with loading state + clear old results - self.results([]); - self.dropdownOpen(true); - self.searching(true); + // Search (debounced) + let searchSeq = 0; + + self.query + .extend({ rateLimit: { timeout: 300, method: 'notifyWhenChangesStop' } }) + .subscribe(q => { + const t = (q || '').trim(); + if (!t) { + searchSeq++; + self.searching(false); + self.results([]); + self.dropdownOpen(false); + return; + } - deps.entitiesSearch(t) - .then(list => { - if (mySeq !== searchSeq) return; // ignore stale responses - self.results(list.map(e => makeResultVM({ id: e.Id, name: e.Name, entityType: e.EntityTypeId }))); - self.dropdownOpen(true); - }) - .catch(() => { - if (mySeq !== searchSeq) return; - self.results([]); // triggers "No results found." - self.dropdownOpen(true); - }) - .finally(() => { - if (mySeq === searchSeq) self.searching(false); - }); - }); + const mySeq = ++searchSeq; + + // show dropdown immediately with loading state + clear old results + self.results([]); + self.dropdownOpen(true); + self.searching(true); + + deps.entitiesSearch(t) + .then(list => { + if (mySeq !== searchSeq) return; // ignore stale responses + self.results(list.map(e => makeResultVM({ id: e.Id, name: e.Name, entityType: e.EntityTypeId }))); + self.dropdownOpen(true); + }) + .catch(() => { + if (mySeq !== searchSeq) return; + self.results([]); // triggers "No results found." + self.dropdownOpen(true); + }) + .finally(() => { + if (mySeq === searchSeq) self.searching(false); + }); + }); // Actions from results @@ -242,20 +278,20 @@ self.query self.clearPinnedTeams = () => { - self.pinnedTeamIds.removeAll(); - self.save(); -}; + self.pinnedTeamIds.removeAll(); + self.save(); + }; -self.clearPinnedIncidents = () => { - self.pinnedIncidentIds.removeAll(); - self.save(); -}; + self.clearPinnedIncidents = () => { + self.pinnedIncidentIds.removeAll(); + self.save(); + }; -self.clearAllPinned = () => { - self.pinnedTeamIds.removeAll(); - self.pinnedIncidentIds.removeAll(); - self.save(); -}; + self.clearAllPinned = () => { + self.pinnedTeamIds.removeAll(); + self.pinnedIncidentIds.removeAll(); + self.save(); + }; // Only close if focus moved *outside* the dropdown @@ -345,7 +381,7 @@ self.clearAllPinned = () => { if (typeof cfg.showAdvanced === 'boolean') { self.showAdvanced(cfg.showAdvanced); } - if (typeof cfg.includeIncidentsWithoutSector === 'boolean') { + if (typeof cfg.includeIncidentsWithoutSector === 'boolean') { self.includeIncidentsWithoutSector(cfg.includeIncidentsWithoutSector); } @@ -380,7 +416,11 @@ self.clearAllPinned = () => { self.pinnedIncidentIds(cfg.pinnedIncidentIds.map(x => String(x))); } - + if (Array.isArray(cfg.paneOrder)) { + self.rebuildPaneOrderFromIds(cfg.paneOrder); + } else { + self.rebuildPaneOrderFromIds(); // defaults + } self.afterConfigLoad() @@ -475,6 +515,7 @@ self.clearAllPinned = () => { self.afterConfigLoad = () => { deps.fetchAllSectors(self.incidentFilters().map(i => i.id)); + root.mapVM?.applyPaneOrder?.(self.paneOrder().map(p => p.id)); } @@ -489,4 +530,8 @@ self.clearAllPinned = () => { root.fetchAllJobsData(); }) + self.paneOrder.subscribe(() => { + root.mapVM?.applyPaneOrder?.(self.paneOrder().map(p => p.id)); + }) + } diff --git a/src/pages/tasking/viewmodels/Map.js b/src/pages/tasking/viewmodels/Map.js index 2aed3739..af1f78ce 100644 --- a/src/pages/tasking/viewmodels/Map.js +++ b/src/pages/tasking/viewmodels/Map.js @@ -22,6 +22,26 @@ export function MapVM(Lmap, root) { self.jobMarkerGroups = new Map(); self.unmatchedAssetLayer = L.layerGroup(); // not added by default + self.applyPaneOrder = function (paneOrderTopToBottom) { + if (!Array.isArray(paneOrderTopToBottom) || paneOrderTopToBottom.length === 0) return; + + // Keep big gaps so you can still place other UI panes/overlays between if needed + const base = 300; + const step = 100; + + paneOrderTopToBottom.forEach((paneName, idx) => { + const pane = self.map.getPane(paneName); + const panePlus = self.map.getPane(`${paneName}-plus`); + + if (!pane || !panePlus) return; + // topmost gets highest zIndex + const z = base + (step * (paneOrderTopToBottom.length - 1 - idx)); + pane.style.zIndex = String(z); + panePlus.style.zIndex = String(z + 1); + + }); + }; + // --- online/polling overlay layers registry --- // key -> { key, label, layerGroup, refreshMs, timerId, visibleByDefault, fetchFn, drawFn } @@ -131,28 +151,28 @@ export function MapVM(Lmap, root) { const defs = []; - // matched assets layer + // matched assets layer if (self.assetLayer) { - defs.push({ - key: 'matched-assets', - label: 'Matched against Teams', - layer: self.assetLayer, - group: 'Assets', - visibleByDefault: true, - }); - } + defs.push({ + key: 'matched-assets', + label: 'Matched against Teams', + layer: self.assetLayer, + group: 'Assets', + visibleByDefault: true, + }); + } - // unmatched assets layer + // unmatched assets layer if (self.unmatchedAssetLayer) { - defs.push({ - key: 'unmatched-assets', - label: 'Unmatched against Teams', - layer: self.unmatchedAssetLayer, - group: 'Assets', - visibleByDefault: false, - }); - } + defs.push({ + key: 'unmatched-assets', + label: 'Unmatched against Teams', + layer: self.unmatchedAssetLayer, + group: 'Assets', + visibleByDefault: false, + }); + } // Online/polling overlays @@ -224,12 +244,12 @@ export function MapVM(Lmap, root) { } }; - self.clearCrowFliesLine = () => { - if (self.crowFliesLine) { - self.map.removeLayer(self.crowFliesLine); - self.crowFliesLine = null; - } - }; + self.clearCrowFliesLine = () => { + if (self.crowFliesLine) { + self.map.removeLayer(self.crowFliesLine); + self.crowFliesLine = null; + } + }; self.registerCrowFliesLine = (line) => { self.crowFliesLine = line; diff --git a/static/pages/tasking.html b/static/pages/tasking.html index 317d2efc..4b8e7b82 100644 --- a/static/pages/tasking.html +++ b/static/pages/tasking.html @@ -14,20 +14,13 @@