Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions src/pages/tasking/bindings/sortableArray.js
Original file line number Diff line number Diff line change
@@ -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);
});
}
};
}
142 changes: 92 additions & 50 deletions src/pages/tasking/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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',
Expand All @@ -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') {
Expand All @@ -347,6 +396,7 @@ function VM() {

return asc ? cmp : -cmp;
});
return jobLastOutput;
});

// --- UI updater shared helper ---
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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('');
Expand Down Expand Up @@ -559,7 +579,7 @@ function VM() {

return false;
});
}).extend({ trackArrayChanges: true, rateLimit: 50 });
}).extend({ trackArrayChanges: true, rateLimit: { timeout: 100, method: 'notifyWhenChangesStop' } });



Expand All @@ -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(() => {
Expand All @@ -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);

Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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') {
Expand All @@ -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;

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -2349,6 +2390,7 @@ document.addEventListener('DOMContentLoaded', function () {
installRowVisibilityBindings();
installDragDropRowBindings();
noBubbleFromDisabledButtonsBindings();
installSortableArrayBindings();

ko.bindingProvider.instance = new ksb(options);
window.ko = ko;
Expand Down
2 changes: 2 additions & 0 deletions src/pages/tasking/mapLayers/frao.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions src/pages/tasking/mapLayers/geoservices.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/pages/tasking/mapLayers/hazardwatch.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ export function registerHazardWatchWarningsLayer(vm, apiHost) {

const marker = L.marker(center, {
icon,
pane: "pane-lowest",
pane: "pane-middle",
interactive: true,
});

Expand Down
Loading
Loading