signalk-ais-target-prioritizer
Version:
A SignalK plugin that priorizes AIS targets according to guard and CPA criteria.
1,400 lines (1,184 loc) • 52.4 kB
JavaScript
"use strict";
// FIXME: need map rotation option to toggle between north-up and cog-up
// FIXME need to investigate node OOM issues - leak?
// FIXME look at natural earth maps for better map data - https://www.naturalearthdata.com/downloads/10m-raster-data/10m-physical-map/
const DEFAULT_MAP_ZOOM = 14; // 14 gives us 2+ NM
const METERS_PER_NM = 1852;
const COURSE_PROJECTION_MINUTES = 10;
const AGE_OUT_OLD_TARGETS = true;
const TARGET_MAX_AGE = 30 * 60; // max age in seconds - 30 minutes
const SHOW_ALARMS_INTERVAL = 60 * 1000; // show alarms every 60 seconds
const PLUGIN_ID = 'signalk-ais-target-prioritizer';
import { updateDerivedData, toRadians, toDegrees } from "./ais-utils.mjs";
import { biCursorFill, biVolumeUpFill, biVolumeMuteFill, biLayersFill, biListOl, biGearFill } from "./bi-icons.mjs";
import { getClassBIcon, getClassAIcon, getBlueBoxIcon, getAtonIcon, getBaseIcon, getSartIcon, getSelfIcon } from "./ais-icons.mjs"
var noSleep = new NoSleep();
var collisionProfiles;
var selfMmsi;
var selfTarget;
var offsetLatitude = 0;
var offsetLongitude = 0;
var disableMoveend = false;
var disableMapPanTo = false;
var targets = new Map();
var pluginTargets;
var boatMarkers = new Map();
var boatProjectedCourseLines = new Map();
var rangeRings = L.layerGroup();
var selectedVesselMmsi;
var blueBoxIcon;
var blueCircle1;
var blueCircle2;
var validTargetCount;
var filteredTargetCount;
var alarmTargetCount;
var lastAlarmTime;
var tooltipList;
var blueLayerGroup = L.layerGroup();
//blueLayerGroup.className = 'blueStuff';
const bsModalAlert = new bootstrap.Modal('#modalAlert');
const bsModalAlarm = new bootstrap.Modal('#modalAlarm');
const bsModalClosebyBoats = new bootstrap.Modal('#modalClosebyBoats');
const bsModalSelectedVesselProperties = new bootstrap.Modal('#modalSelectedVesselProperties');
const bsOffcanvasSettings = new bootstrap.Offcanvas('#offcanvasSettings');
const bsOffcanvasEditProfiles = new bootstrap.Offcanvas('#offcanvasEditProfiles');
const bsOffcanvasTargetList = new bootstrap.Offcanvas('#offcanvasTargetList');
const defaultCollisionProfiles = await getHttpResponse("./assets/js/defaultCollisionProfiles.json", { throwErrors: true });
// load collisionProfiles
// /plugins/${PLUGIN_ID}/getCollisionProfiles
collisionProfiles = await getHttpResponse(`/plugins/${PLUGIN_ID}/getCollisionProfiles`, { throwErrors: true });
console.log('collisionProfiles', collisionProfiles);
if (!collisionProfiles.current) {
console.log('using default collisionProfiles');
collisionProfiles = structuredClone(defaultCollisionProfiles);
saveCollisionProfiles();
}
document.getElementById("selectActiveProfile").value = collisionProfiles.current;
document.getElementById("checkNoSleep").checked = (localStorage.getItem("checkNoSleep") == "true");
configureNoSleep();
var charts = await getHttpResponse("/signalk/v1/api/resources/charts", {
throwErrors: false,
ignore404: true,
ignoreEmptyResponse: true
});
var data = await getHttpResponse("/signalk/v1/api/vessels/self", { throwErrors: true });
selfMmsi = data.mmsi;
pluginTargets = await getHttpResponse(`/plugins/${PLUGIN_ID}/getTargets`);
const map = L.map('map', {
zoom: DEFAULT_MAP_ZOOM,
minZoom: 9,
maxZoom: 18,
});
L.easyButton(biCursorFill, function (btn, map) {
if (selfTarget.isValid) {
map.panTo([selfTarget.latitude, selfTarget.longitude]);
offsetLatitude = 0;
offsetLongitude = 0;
}
}).addTo(map);
// protomaps color flavors: light dark white grayscale black
// make water transparent so that bootstrap light/dark mode backgroud comes through
var paintRules = protomapsL.paintRules({ ...basemaps.namedFlavor("light"), water: "rgba(0,0,0,0)" });
var labelRules = protomapsL.labelRules(basemaps.namedFlavor("light"));
var osm = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap'
});
var openTopoMap = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: 'Map data: © OpenStreetMap contributors, SRTM | Map style: © OpenTopoMap (CC-BY-SA)'
});
var naturalEarth10m = protomapsL.leafletLayer({
url: "assets/pmtiles/ne_10m_land.pmtiles",
maxDataZoom: 5,
paintRules: paintRules,
labelRules: labelRules
});
var baseMaps = {
"Empty": L.tileLayer(''),
"OpenStreetMap": osm,
"OpenTopoMap": openTopoMap,
"NaturalEarth (offline)": naturalEarth10m
};
for (let key in charts) {
var chart = charts[key];
var layer;
if (chart.format == "mvt") {
layer = protomapsL.leafletLayer({
url: chart.tilemapUrl,
maxDataZoom: chart.maxzoom,
paintRules: paintRules,
labelRules: labelRules,
});
} else {
layer = L.tileLayer(chart.tilemapUrl, {
maxZoom: chart.maxzoom,
attribution: ''
});
}
baseMaps[chart.name] = layer;
}
var OpenSeaMap = L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: ''
});
var overlayMaps = {
"OpenSeaMap": OpenSeaMap
};
L.control.layers(baseMaps, overlayMaps, { position: 'topleft' }).addTo(map);
var layerControlLayersToggleEl = document.getElementsByClassName("leaflet-control-layers-toggle")[0];
layerControlLayersToggleEl.innerHTML = biLayersFill;
L.easyButton(biListOl, function (btn, map) {
bsOffcanvasTargetList.show();
}).addTo(map);
// '<span data-bs-toggle="offcanvas" href="#offcanvasSettings">Settings</span>'
L.easyButton(biGearFill, function (btn, map) {
bsOffcanvasSettings.show();
}).addTo(map);
// reload last used baselayer/overlay
var baselayer = localStorage.getItem("baselayer");
var overlay = localStorage.getItem("overlay");
if (!baseMaps[baselayer]) {
baselayer = "OpenStreetMap";
}
baseMaps[baselayer].addTo(map);
if (overlay && overlayMaps[overlay]) {
overlayMaps[overlay].addTo(map);
}
blueBoxIcon = L.marker([], {
icon: getBlueBoxIcon(),
});
blueCircle1 = L.circleMarker([], {
radius: 6,
color: 'blue',
opacity: 1.0,
fillOpacity: 1.0,
interactive: false,
className: 'blueStuff'
});
blueCircle2 = L.circleMarker([], {
radius: 6,
color: 'blue',
opacity: 1.0,
fillOpacity: 1.0,
interactive: false,
className: 'blueStuff'
});
// we're adding all the blue stuff to a layer group so that we can raise the z index of the whole group
// and turn visibility on/off in one shot
blueLayerGroup.addLayer(blueBoxIcon);
blueLayerGroup.addLayer(blueCircle1);
blueLayerGroup.addLayer(blueCircle2);
// setup vessel label collision avoidance
var hideLabel = function (label) { label.labelObject.style.opacity = 0; };
var showLabel = function (label) { label.labelObject.style.opacity = 1; };
var labelToCollisionController = new labelgun.default(hideLabel, showLabel);
const alertPlaceholder = document.getElementById('alertPlaceholder');
// *********************************************************************************************************
// ** REGISTER EVENT LISTENERS
map.on('baselayerchange', handleBaseLayerChange);
map.on('overlayadd', handleOverlayAdd);
map.on('overlayremove', handleOverlayRemove);
document.getElementById("tableOfTargetsBody").addEventListener("click", handleTableOfTargetsBodyClick);
document.getElementById("listOfClosebyBoats").addEventListener("click", handleListOfClosebyBoatsClick);
document.getElementById("selectActiveProfile").addEventListener("input", (ev) => {
collisionProfiles.current = ev.target.value;
saveCollisionProfiles();
});
document.getElementById("buttonEditProfiles").addEventListener("click", () => {
bsOffcanvasSettings.hide();
selectProfileToEdit.value = selectActiveProfile.value;
setupProfileEditView(selectProfileToEdit.value);
bsOffcanvasEditProfiles.show();
});
document.getElementById("checkNoSleep").addEventListener("change", (event) => {
configureNoSleep();
});
document.getElementById("checkDarkMode").addEventListener("change", applyColorMode);
document.getElementById("checkFullScreen").addEventListener("change", (event) => {
if (checkFullScreen.checked) {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else if (!document.webkitFullscreenElement) {
document.documentElement.webkitRequestFullscreen();
}
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
}
}
});
document.addEventListener("fullscreenchange", fullscreenchangeHandler);
//document.addEventListener("webkitfullscreenchange", fullscreenchangeHandler);
document.getElementById("selectProfileToEdit").addEventListener("input", (ev) => {
setupProfileEditView(ev.target.value);
});
document.getElementById("buttonRestoreDefaults").addEventListener("click", () => {
collisionProfiles = structuredClone(defaultCollisionProfiles);
setupProfileEditView(selectProfileToEdit.value);
saveCollisionProfiles();
});
document.getElementById("buttonMuteAllAlarms").addEventListener("click", muteAllAlarms);
document.getElementById("buttonMuteAllAlarms2").addEventListener("click", muteAllAlarms);
document.getElementById("buttonMuteToggle").addEventListener("click", handleButtonMuteToggle);
document.getElementsByClassName("")
// save config when offcanvasEditProfiles is closed
offcanvasEditProfiles.addEventListener('hide.bs.offcanvas', () => {
saveCollisionProfiles();
})
// show modalSelectedVesselProperties when modalClosebyBoats is closed
modalClosebyBoats.addEventListener('hidden.bs.modal', () => {
var boatMarker = boatMarkers.get(selectedVesselMmsi);
positionModalWindow(boatMarker.getLatLng(), "modalSelectedVesselProperties");
showModalSelectVesselProperties(targets.get(selectedVesselMmsi));
})
configWarningCpaRange.addEventListener("input", processDistanceRangeControl);
configWarningTcpaRange.addEventListener("input", processTcpaRangeControl);
configWarningSogRange.addEventListener("input", processSpeedRangeControl);
configAlarmCpaRange.addEventListener("input", processDistanceRangeControl);
configAlarmTcpaRange.addEventListener("input", processTcpaRangeControl);
configAlarmSogRange.addEventListener("input", processSpeedRangeControl);
configGuardRangeRange.addEventListener("input", processDistanceRangeControl);
configGuardSogRange.addEventListener("input", processSpeedRangeControl);
map.on('movestart', function (e) {
disableMapPanTo = true;
});
map.on('moveend', function (e) {
disableMapPanTo = false;
if (disableMoveend) {
return;
}
// if the map was panned, store the offsets from selfTarget
if (selfTarget.isValid) {
var mapCenter = map.getCenter();
offsetLatitude = mapCenter.lat - selfTarget.latitude;
offsetLongitude = mapCenter.lng - selfTarget.longitude;
}
});
map.on("zoomend", function (e) {
drawRangeRings();
labelToCollisionController.update();
});
map.on('click', handleMapClick);
// ** END REGISTER EVENT LISTENERS
// *********************************************************************************************************
function fullscreenchangeHandler() {
if (document.fullscreenElement) {
checkFullScreen.checked = true;
} else {
checkFullScreen.checked = false;
}
}
function applyColorMode() {
if (checkDarkMode.checked) {
// dark mode
document.documentElement.setAttribute('data-bs-theme', "dark");
var elements = document.querySelectorAll('.leaflet-layer');
for (var i = 0; i < elements.length; i++) {
elements[i].style.filter = "invert(1) hue-rotate(180deg) brightness(0.8) contrast(1.2)";
}
} else {
// light mode
document.documentElement.setAttribute('data-bs-theme', "light");
var elements = document.querySelectorAll('.leaflet-layer');
for (var i = 0; i < elements.length; i++) {
elements[i].style.filter = "none";
}
}
}
function configureNoSleep() {
if (checkNoSleep.checked) {
noSleep.enable();
} else {
noSleep.disable();
}
localStorage.setItem("checkNoSleep", checkNoSleep.checked);
}
function handleBaseLayerChange(event) {
localStorage.setItem("baselayer", event.name);
applyColorMode();
}
function handleOverlayAdd(event) {
localStorage.setItem("overlay", event.name);
applyColorMode();
}
function handleOverlayRemove(event) {
localStorage.removeItem("overlay");
}
// initialize profile edit screen on startup
setupProfileEditView('anchor');
refresh();
var refreshInterval = setInterval(refresh, 1000);
function setupProfileEditView(profile) {
configWarningCpaRange.value = distanceToTick(collisionProfiles[profile].warning.cpa);
configWarningTcpaRange.value = timeToTick(collisionProfiles[profile].warning.tcpa / 60);
configWarningSogRange.value = speedToTick(collisionProfiles[profile].warning.speed);
configAlarmCpaRange.value = distanceToTick(collisionProfiles[profile].danger.cpa);
configAlarmTcpaRange.value = timeToTick(collisionProfiles[profile].danger.tcpa / 60);
configAlarmSogRange.value = speedToTick(collisionProfiles[profile].danger.speed);
configGuardRangeRange.value = distanceToTick(collisionProfiles[profile].guard.range);
configGuardSogRange.value = speedToTick(collisionProfiles[profile].guard.speed);
var inputEvent = new Event('input');
configWarningCpaRange.dispatchEvent(inputEvent);
configWarningTcpaRange.dispatchEvent(inputEvent);
configWarningSogRange.dispatchEvent(inputEvent);
configAlarmCpaRange.dispatchEvent(inputEvent);
configAlarmTcpaRange.dispatchEvent(inputEvent);
configAlarmSogRange.dispatchEvent(inputEvent);
configGuardRangeRange.dispatchEvent(inputEvent);
configGuardSogRange.dispatchEvent(inputEvent);
}
async function saveCollisionProfiles() {
console.log('*** save collisionProfiles to server', collisionProfiles);
// /plugins/${PLUGIN_ID}/setCollisionProfiles
var response = await fetch(`/plugins/${PLUGIN_ID}/setCollisionProfiles`, {
credentials: 'include',
method: 'PUT',
body: JSON.stringify(collisionProfiles),
headers: {
"Content-Type": "application/json",
}
});
if (response.status == 401) {
location.href = "/admin/#/login";
}
if (!response.ok) {
throw new Error(`Error saving collisionProfiles. Response status: ${response.status} from ${response.url}`);
}
console.log('successfully saved config', collisionProfiles);
}
function showError(message) {
document.getElementById("errorMessage").textContent = message;
bsModalAlert.show();
}
function handleTableOfTargetsBodyClick(ev) {
bsOffcanvasTargetList.hide();
var mmsi = ev.target.parentNode.dataset.mmsi;
var boatMarker = boatMarkers.get(mmsi);
selectBoatMarker(boatMarker);
// FIXME: maybe use blueLayerGroup here. looks like L.featureGroup would be what we need.
map.fitBounds([
boatMarker.getLatLng(),
boatMarkers.get(selfMmsi).getLatLng()
]);
positionModalWindow(boatMarker.getLatLng(), "modalSelectedVesselProperties");
showModalSelectVesselProperties(targets.get(boatMarker.mmsi));
}
function distanceToTick(distance) {
return distance <= 1 ? Math.floor(distance * 10) : Math.floor(distance + 9);
}
function tickToDistance(tick) {
return tick <= 10 ? tick / 10 : tick - 9;
}
function processDistanceRangeControl(ev) {
var tick = ev.target.value;
var dataset = ev.target.dataset;
var valueStorageElement = document.getElementById(dataset.target);
// 0,0.1,0.2 ... 1.0,2,3,4,5,6,7,8,9,10
// 20 values
// tick distance
// 0 - 10 correspond to 0 - 1.0
// 11 - 19 correspond to 2 - 10
var distance = tickToDistance(tick);
var unitsSpan = document.getElementById(dataset.target + 'Units');
if (distance == 0) {
unitsSpan.hidden = true;
} else {
unitsSpan.hidden = false;
}
valueStorageElement.textContent = distance || 'OFF';
collisionProfiles[selectProfileToEdit.value][dataset.alarmType][dataset.alarmCriteria] = distance;
}
function timeToTick(time) {
if (time <= 5) {
return Math.floor(time);
} else if (time <= 20) {
return Math.floor((time - 10) / 5 + 6);
} else {
return Math.floor((time - 30) / 10 + 9);
}
}
function tickToTime(tick) {
if (tick <= 5) {
return tick;
} else if (tick <= 8) {
return (tick - 6) * 5 + 10;
} else {
return (tick - 9) * 10 + 30;
}
}
function processTcpaRangeControl(ev) {
var tick = ev.target.value;
var dataset = ev.target.dataset;
var valueStorageElement = document.getElementById(dataset.target);
// 1,2,3,4, 5, 10,15, 20, 30,40,50,60
// 12 values
// tick time (min)
// 1 - 5 correspond to 1 - 5
// 6 - 8 correspond to 10 - 20
// 9 - 12 correspond to 30 - 60
var time = tickToTime(tick)
valueStorageElement.textContent = time;
collisionProfiles[selectProfileToEdit.value][dataset.alarmType][dataset.alarmCriteria] = time * 60;
}
function speedToTick(speed) {
if (speed <= 0.5) {
return Math.floor(speed * 10);
} else if (speed <= 3) {
return Math.floor(speed + 5);
} else {
return Math.floor(speed / 5 + 8);
}
}
function tickToSpeed(tick) {
if (tick <= 5) {
return tick / 10;
} else if (tick <= 8) {
return tick - 5;
} else {
return (tick - 8) * 5;
}
}
function processSpeedRangeControl(ev) {
var tick = ev.target.value;
var dataset = ev.target.dataset;
var valueStorageElement = document.getElementById(dataset.target);
// 0,0.1,0,2 ... 0.5,1,2,3,5,10
// 11 values
// tick speed (knots)
// 0 - 5 correspond to 0.0 - 0.5
// 6 correspond to 1
// 7 correspond to 2
// 8 correspond to 3
// 9 correspond to 5
// 10 correspond to 10
var speed = tickToSpeed(tick);
valueStorageElement.textContent = speed;
collisionProfiles[selectProfileToEdit.value][dataset.alarmType][dataset.alarmCriteria] = speed;
}
function drawRangeRings() {
if (!selfTarget.isValid) {
return;
}
rangeRings.removeFrom(map)
rangeRings.clearLayers();
var mapHeightInNauticalMiles = 60 * Math.abs(map.getBounds().getNorth() - map.getBounds().getSouth());
// aiming for 3 visible range rings
var step = mapHeightInNauticalMiles / 6;
if (step < 0.125) {
step = 0.125;
} else if (step < 0.25) {
step = 0.25;
} else if (step < 0.5) {
step = 0.5;
} else if (step < 1) {
step = 1;
} else {
step = 2 * Math.round(step / 2);
}
for (var i = 1; i <= 6; i++) {
rangeRings.addLayer(
L.circle(
[selfTarget.latitude, selfTarget.longitude],
{
radius: i * step * METERS_PER_NM,
color: 'gray',
weight: 1,
opacity: 0.7,
fill: false,
interactive: false,
zIndexOffset: -999
}
)
);
rangeRings.addLayer(
L.tooltip(
[selfTarget.latitude + (i * step) / 60, selfTarget.longitude],
{
content: i * step + ' NM',
permanent: true,
direction: 'center',
opacity: 0.7,
offset: [0, 15],
className: 'map-labels',
interactive: false,
zIndexOffset: -999
}
)
);
rangeRings.addLayer(
L.tooltip(
[selfTarget.latitude - (i * step) / 60, selfTarget.longitude],
{
content: i * step + ' NM',
permanent: true,
direction: 'center',
opacity: 0.7,
offset: [0, -15],
className: 'map-labels',
interactive: false,
zIndexOffset: -999
}
)
);
}
rangeRings.addTo(map)
}
async function refresh() {
try {
var startTime = new Date();
// FIXME switch to the streaming api? does it send atons?
var vessels = await getHttpResponse("/signalk/v1/api/vessels", { throwErrors: true });
//console.log(vessels);
// we expect 404s from this when there are no atons:
var atons = await getHttpResponse("/signalk/v1/api/atons", { ignore404: true });
vessels = Object.assign(vessels, atons);
validTargetCount = 0;
filteredTargetCount = 0;
alarmTargetCount = 0;
ingestRawVesselData(vessels);
selfTarget = targets.get(selfMmsi);
updateDerivedData(targets, selfTarget, collisionProfiles, TARGET_MAX_AGE);
UpdateTargetsWithMuteDataFromPlugin();
updateUI();
if (AGE_OUT_OLD_TARGETS) {
ageOutOldTargets();
}
if (alarmTargetCount > 0 && (lastAlarmTime == null || Date.now() > lastAlarmTime + SHOW_ALARMS_INTERVAL)) {
lastAlarmTime = Date.now();
showAlarms();
}
// display performance metrics
let layers = 0;
map.eachLayer(function () { layers++ });
var updateTimeInMillisecs = (new Date()).getTime() - startTime.getTime();
//console.log(updateTimeInMillisecs + ' msecs');
// FIXME this does not work in ios: performance.memory.usedJSHeapSize
// map.attributionControl.setPrefix(`${updateTimeInMillisecs} msecs / ${layers} layers / ${(performance.memory.usedJSHeapSize / Math.pow(1000, 2)).toFixed(1)} MB`);
map.attributionControl.setPrefix(`${updateTimeInMillisecs} msecs / ${layers} layers`);
} catch (error) {
console.error('Error in refresh:', error);
//showError(`Encountered an error while refreshing: ${error}`);
}
}
function UpdateTargetsWithMuteDataFromPlugin() {
if (!pluginTargets) {
return;
}
var pluginTarget;
targets.forEach((target, mmsi) => {
pluginTarget = pluginTargets[mmsi];
if (pluginTarget && pluginTarget.alarmIsMuted) {
console.log(`setting target ${mmsi} ${target.name} to muted because it is muted in the plugin`);
target.alarmIsMuted = true;
}
});
pluginTargets = null;
}
function showAlarms() {
var targetsWithAlarms = [];
targets.forEach((target, mmsi) => {
//console.log(target.mmsi, target.name, target.alarmState, target.alarmIsMuted);
if (target.isValid && target.alarmState && !target.alarmIsMuted) {
targetsWithAlarms.push(target);
}
});
if (targetsWithAlarms.length > 0) {
document.getElementById("alarmDiv").innerHTML = " ";
targetsWithAlarms.forEach((target) => {
var message = `${target.name} - ${target.alarmType.toUpperCase()} - `;
if (target.alarmType.includes("cpa")) {
message += `${target.cpaFormatted} ${target.tcpaFormatted}`;
} else {
message += `${target.rangeFormatted}`;
}
document.getElementById("alarmDiv").innerHTML += `<div class="alert alert-danger" role="alert">${message}</div>`;
});
bsModalAlarm.show();
new Audio('./assets/audio/horn.mp3').play();
}
}
async function muteAllAlarms() {
console.log('muting all alarms');
targets.forEach((target, mmsi) => {
if (target.alarmState == "danger" && !target.alarmIsMuted) {
console.log('muting alarm for target', mmsi, target.name, target.alarmType, target.alarmState);
target.alarmIsMuted = true;
}
});
// mute alarms in the plugin as well
// /plugins/${PLUGIN_ID}/muteAllAlarms
await getHttpResponse(`/plugins/${PLUGIN_ID}/muteAllAlarms`, { throwErrors: true, ignoreEmptyResponse: true });
}
async function handleButtonMuteToggle() {
var target = targets.get(selectedVesselMmsi);
target.alarmIsMuted = !target.alarmIsMuted;
updateButtonMuteToggleIcon(target);
showAlert(`Target ${target.alarmIsMuted ? "" : "un"}muted`, "success");
console.log("setting alarmIsMuted", target.mmsi, target.name, target.alarmIsMuted);
// GET /plugins/${plugin.id}/setAlarmIsMuted/:mmsi/:alarmIsMuted
await getHttpResponse(`/plugins/${PLUGIN_ID}/setAlarmIsMuted/${target.mmsi}/${target.alarmIsMuted}`, { throwErrors: true, ignoreEmptyResponse: true });
}
function updateButtonMuteToggleIcon(target) {
if (target.alarmIsMuted) {
document.getElementById("buttonMuteToggle").innerHTML = biVolumeMuteFill;
} else {
document.getElementById("buttonMuteToggle").innerHTML = biVolumeUpFill;
}
}
function showAlert(message, type) {
alertPlaceholder.innerHTML = [
`<div class="alert alert-${type} alert-dismissible" role="alert">`,
` <div>${message}</div>`,
' <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>',
'</div>'
].join('')
}
// get vessel data into an easier to access data model
// values in their original data types - no text formatting of numeric values here
function ingestRawVesselData(vessels) {
for (var vesselId in vessels) {
var vessel = vessels[vesselId];
var target = targets.get(vessel.mmsi);
if (!target) {
target = {};
}
target.lastSeenDate = new Date(vessel.navigation?.position?.timestamp);
var lastSeen = Math.round((new Date() - target.lastSeenDate) / 1000);
// dont add targets that have already aged out
if (lastSeen >= TARGET_MAX_AGE) {
continue;
}
target.mmsi = vessel.mmsi;
target.name = vessel.name || '<' + vessel.mmsi + '>';
target.sog = vessel.navigation?.speedOverGround?.value;
target.cog = vessel.navigation?.courseOverGroundTrue?.value;
target.hdg = vessel.navigation?.headingTrue?.value;
target.rot = vessel.navigation?.rateOfTurn?.value;
target.callsign = vessel.communication?.callsignVhf || '---';
target.type = (vessel.design?.aisShipType?.value.name || vessel.atonType?.value.name) ?? '---';
target.aisClass = vessel.sensors?.ais?.class?.value || 'A';
target.isVirtual = vessel.virtual?.value;
target.isOffPosition = vessel.offPosition?.value;
target.status = vessel.navigation?.state?.value ?? '---';
target.length = vessel.design?.length?.value.overall;
target.beam = vessel.design?.beam?.value;
target.draft = vessel.design?.draft?.current ?? '---';
target.destination = vessel.navigation?.destination?.commonName?.value ?? '---';
target.eta = vessel.navigation?.destination?.eta?.value ?? '---';
target.imo = vessel.registrations?.imo;
target.latitude = vessel.navigation?.position?.value.latitude;
target.longitude = vessel.navigation?.position?.value.longitude;
// FIXME - override gps to testing with signalk team sample data (netherlands)
// if (target.mmsi == selfMmsi) {
// target.latitude = 53.44;
// target.longitude = 4.86 //5.07;
// }
targets.set(target.mmsi, target);
}
}
function updateSelectedVesselProperties(target) {
updateButtonMuteToggleIcon(target);
document.getElementById("target.name").textContent = target.name;
document.getElementById("target.lastSeen").textContent = target.lastSeen;
document.getElementById("target.cpaFormatted").textContent = target.cpaFormatted;
document.getElementById("target.tcpaFormatted").textContent = target.tcpaFormatted;
document.getElementById("target.rangeFormatted").textContent = target.rangeFormatted;
document.getElementById("target.bearingFormatted").textContent = target.bearingFormatted;
document.getElementById("target.sogFormatted").textContent = target.sogFormatted;
document.getElementById("target.cogFormatted").textContent = target.cogFormatted;
document.getElementById("target.hdgFormatted").textContent = target.hdgFormatted;
document.getElementById("target.rotFormatted").textContent = target.rotFormatted;
document.getElementById("target.callsign").textContent = target.callsign;
document.getElementById("target.mmsi").textContent = target.mmsi;
document.getElementById("target.mmsiCountryCode").textContent = target.mmsiCountryCode;
document.getElementById("target.mmsiCountryCode").setAttribute("data-bs-title", target.mmsiCountryName);
document.getElementById("target.type").textContent = target.type;
document.getElementById("target.aisClassFormatted").textContent = target.aisClassFormatted;
document.getElementById("target.status").textContent = target.status;
document.getElementById("target.sizeFormatted").textContent = target.sizeFormatted;
document.getElementById("target.draft").textContent = target.draft;
document.getElementById("target.destination").textContent = target.destination;
document.getElementById("target.eta").textContent = target.eta;
document.getElementById("target.imoFormatted").textContent = target.imoFormatted;
document.getElementById("target.latitudeFormatted").textContent = target.latitudeFormatted;
document.getElementById("target.longitudeFormatted").textContent = target.longitudeFormatted;
// navigation.specialManeuver
activateToolTips();
var classARows = document.querySelectorAll('.ais-class-a')
// show/hide class A fields:
if (target.aisClass == 'A') {
[...classARows].map(row => row.classList.remove("d-none"));
} else {
[...classARows].map(row => row.classList.add("d-none"));
}
// show/hide alert:
var selectedVesselAlert = document.getElementById("selectedVesselAlert");
if (target.alarmState == 'danger') {
selectedVesselAlert.classList.remove("alert-warning");
selectedVesselAlert.classList.add("alert-danger");
selectedVesselAlert.textContent = (target.alarmType + " alarm").toUpperCase();
selectedVesselAlert.classList.remove("d-none");
} else if (target.alarmState == 'warning') {
selectedVesselAlert.classList.remove("alert-danger");
selectedVesselAlert.classList.add("alert-warning");
selectedVesselAlert.textContent = (target.alarmType + " warning").toUpperCase();
selectedVesselAlert.classList.remove("d-none");
} else {
selectedVesselAlert.classList.add("d-none");
}
}
function deactivateToolTips() {
if (tooltipList) {
tooltipList.forEach((tooltip) => { tooltip.dispose() });
}
}
function activateToolTips() {
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
}
function updateUI() {
// keep map centered on selfTarget as it moves
// accomodates offsets if the user has panned the map
if (selfTarget.isValid) {
// we cant pan the map in code if the user is already panning the map by mouse
if (!disableMapPanTo) {
try {
disableMoveend = true;
map.panTo([
selfTarget.latitude + offsetLatitude,
selfTarget.longitude + offsetLongitude
], {
animate: false,
});
}
finally {
disableMoveend = false;
}
}
// keep the range rings centered on selfTarget - even if we didnt pan (above)
drawRangeRings();
}
targets.forEach((target, mmsi) => {
//console.log(target);
updateSingleVesselUI(target);
// update data shown in modal properties screen
if (target.mmsi == selectedVesselMmsi) {
updateSelectedVesselProperties(target);
}
});
labelToCollisionController.update();
updateTableOfTargets();
// update displayed target counts
totalTargetCountUI.textContent = validTargetCount || 0;
filteredTargetCountUI.textContent = filteredTargetCount || 0;
alarmTargetCountUI.textContent = alarmTargetCount || 0;
}
function updateTableOfTargets() {
var targetsArray = Array.from(targets.values());
targetsArray.sort(function (a, b) {
return a.order - b.order
});
var tableBody = '';
// FIXME add target icons to table: sail, pleasure, ship, tug, fishing, sart, aton
// https://www.flaticon.com/search?word=ship
var rowCount = 0;
for (var target of targetsArray) {
if (target.mmsi != selfMmsi && target.isValid) {
tableBody += `
<tr class="${target.alarmState == 'danger' ? 'table-danger' : target.alarmState == 'warning' ? 'table-warning' : ''}" data-mmsi="${target.mmsi}">
<th scope="row">${target.name}</th>
<td>${target.bearingFormatted}</td>
<td>${target.rangeFormatted}</td>
<td>${target.sogFormatted}</td>
<td>${target.cpa ? target.cpaFormatted : ''}</td>
<td>${target.cpa ? target.tcpaFormatted : ''}</td>
</tr>`;
rowCount++;
// <td>${target.order}</td>
}
}
document.getElementById("tableOfTargetsBody").innerHTML = tableBody;
document.getElementById("numberOfAisTargets").textContent = rowCount;
}
function updateSingleVesselUI(target) {
// dont update (and dont add back in) old targets
if (!target.isValid) {
return;
}
var boatMarker = boatMarkers.get(target.mmsi);
var boatProjectedCourseLine = boatProjectedCourseLines.get(target.mmsi);
if (!boatMarker) {
var icon = getTargetIcon(
target,
false,
'gray'
);
boatMarker = L.marker([0, 0], { icon: icon, riseOnHover: true }).addTo(map);
boatMarkers.set(target.mmsi, boatMarker);
boatMarker.bindTooltip('', {
permanent: true,
direction: 'right',
opacity: 0.7,
offset: [25, 10],
className: 'map-labels',
interactive: false,
zIndexOffset: -999
});
if (target.mmsi != selfMmsi) {
boatMarker.on('click', boatClicked);
}
boatProjectedCourseLine = L.polyline([[]], {
color: 'gray',
opacity: 0.7,
interactive: false,
dashArray: '20 10',
zIndexOffset: -999
}).addTo(map);
boatProjectedCourseLines.set(target.mmsi, boatProjectedCourseLine);
}
boatMarker.setLatLng([target.latitude, target.longitude]);
var vesselIconColor;
var vesselIconIsLarge;
if (target.mmsi == selectedVesselMmsi) {
vesselIconColor = 'blue';
vesselIconIsLarge = true;
} else if (target.alarmState == 'danger') {
vesselIconColor = 'red';
vesselIconIsLarge = true;
} else if (target.alarmState == 'warning') {
vesselIconColor = 'orange';
vesselIconIsLarge = true;
} else {
vesselIconColor = 'gray';
vesselIconIsLarge = false;
}
boatMarker.setIcon(getTargetIcon(
target,
vesselIconIsLarge,
vesselIconColor
));
// move the blue box with the selected boat over time
if (target.mmsi == selectedVesselMmsi && blueBoxIcon) {
blueBoxIcon.setLatLng([target.latitude, target.longitude]);
}
// store the whole vessel data model on the boat marker
boatMarker.mmsi = target.mmsi;
// FIXME add aton data for popup: isOffposition? or do the yellow box?
if (target.mmsi != selfMmsi) {
// update counts
validTargetCount++;
if (target.alarmState) {
filteredTargetCount++;
if (target.alarmState == 'danger') {
alarmTargetCount++;
}
}
// add tooltip text
var tooltipText = target.name + '<br/>';
if (target.sog > 0.1) {
tooltipText += target.sogFormatted + ' ';
}
if (target.cpa) {
tooltipText += target.cpaFormatted + ' ';
}
if (target.tcpa > 0 && target.tcpa < 3600) {
tooltipText += target.tcpaFormatted;
}
// ensure the tooltip is always 2 rows - to prevent onscreen jumpiness
tooltipText += " ";
boatMarker.setTooltipContent(tooltipText);
addLabelToCollisionController(boatMarker, target.mmsi, target.order);
}
// if this is our vessel and another vessel has been selected
// draw a solid blue line to cpa point from our vessel
// FIXME: the problem with this is that we process our vessel first. so the selected vessel wont be in targets yet: & targets.has(selectedVesselMmsi)
if (target.mmsi == selfMmsi && selectedVesselMmsi) {
//console.log(selectedVesselMmsi, targets.get(selectedVesselMmsi));
var projectedCpaLocation = projectedLocation([
target.latitude, target.longitude],
target.cog || 0,
(target.sog || 0) * (targets.get(selectedVesselMmsi).tcpa || 0));
boatProjectedCourseLine.setLatLngs([
[target.latitude, target.longitude],
projectedCpaLocation
]);
boatProjectedCourseLine.setStyle({
color: 'blue',
opacity: 1.0,
interactive: false,
dashArray: '',
className: 'blueStuff'
});
blueCircle1.setLatLng(projectedCpaLocation);
if (!map.hasLayer(blueCircle1)) {
blueCircle1.addTo(map);
}
}
// if this is the selected vessel
// draw solid blue line to the cpa point from selected vessel
else if (selectedVesselMmsi == target.mmsi) {
var projectedCpaLocation = projectedLocation([
target.latitude, target.longitude],
target.cog || 0,
(target.sog || 0) * (target.tcpa || 0));
boatProjectedCourseLine.setLatLngs([
[target.latitude, target.longitude],
projectedCpaLocation
]);
boatProjectedCourseLine.setStyle({
color: 'blue',
opacity: 1.0,
interactive: false,
dashArray: '',
className: 'blueStuff'
});
blueCircle2.setLatLng(projectedCpaLocation);
if (!map.hasLayer(blueCircle2)) {
blueCircle2.addTo(map);
}
}
// all other vessels (not our vessel and not a selected vessel)
// draw dashed gray line to course projected position
// but do orange or red depending on alarm state
else {
boatProjectedCourseLine.setLatLngs([
[target.latitude, target.longitude],
projectedLocation([
target.latitude, target.longitude],
target.cog || 0,
(target.sog || 0) * 60 * COURSE_PROJECTION_MINUTES)
]);
boatProjectedCourseLine.setStyle({
color: vesselIconColor,
opacity: 0.7,
interactive: false,
dashArray: '20 10'
});
}
}
function ageOutOldTargets() {
targets.forEach((target, mmsi) => {
// dont age ourselves out. should never happen, but...
if (mmsi == selfMmsi) {
return;
}
if (target.lastSeen > TARGET_MAX_AGE) {
console.log('aging out old target', mmsi, target.name, target.mmsi, target.lastSeen / 60);
if (mmsi == selectedVesselMmsi) {
blueBoxIcon.removeFrom(map);
blueCircle1.removeFrom(map);
blueCircle2.removeFrom(map);
bsModalSelectedVesselProperties.hide();
selectedVesselMmsi = null;
}
if (boatMarkers.has(mmsi)) {
boatMarkers.get(mmsi).removeFrom(map);
boatMarkers.delete(mmsi);
}
if (boatProjectedCourseLines.has(mmsi)) {
boatProjectedCourseLines.get(mmsi).removeFrom(map);
boatProjectedCourseLines.delete(mmsi);
}
labelToCollisionController.removeLabel(mmsi, null);
targets.delete(mmsi);
}
});
}
function boatClicked(event) {
//console.log('event', event);
var boatMarker = event.target;
var closebyBoatMarkers = findClosebyBoats(event.latlng);
if (closebyBoatMarkers.length > 1) {
closebyBoatMarkers.sort(function (a, b) {
return a.distanceInPixels - b.distanceInPixels
});
var div = document.getElementById("listOfClosebyBoats");
div.innerHTML = "";
var target;
var a;
closebyBoatMarkers.forEach((closebyBoatMarker, i) => {
target = targets.get(closebyBoatMarker.mmsi);
//console.log(i, target.name, target.alarmState, closebyBoatMarker.distanceInPixels);
a = document.createElement('a');
a.href = "#";
a.setAttribute("data-bs-toggle", "list");
a.setAttribute("data-mmsi", target.mmsi);
// list-group-item-danger list-group-item-warning
a.classList = "list-group-item list-group-item-action";
if (i == 0) {
a.classList.add("active");
}
if (target.alarmState == "danger") {
a.classList.add("list-group-item-danger");
} else if (target.alarmState == "warning") {
a.classList.add("list-group-item-warning");
}
a.appendChild(document.createTextNode(target.name));
div.appendChild(a);
});
selectBoatMarker(closebyBoatMarkers[0]);
positionModalWindow(boatMarker.getLatLng(), "modalClosebyBoats");
bsModalClosebyBoats.show();
return;
}
selectBoatMarker(boatMarker);
positionModalWindow(boatMarker.getLatLng(), "modalSelectedVesselProperties");
showModalSelectVesselProperties(targets.get(boatMarker.mmsi));
}
function showModalSelectVesselProperties(target) {
updateSelectedVesselProperties(target);
alertPlaceholder.innerHTML = "";
bsModalSelectedVesselProperties.show();
}
function positionModalWindow(latLng, modalId) {
var clickedBoatMarkerLocationInPixels = map.latLngToContainerPoint(latLng);
var mapWidthInPixels = document.getElementById("map").clientWidth;
// if its a narrow screen, show modal in the default centered manner
// if boat is right of center, place modal on left
// if boat is left of center, place modal on right
var modalDialog = document.getElementById(modalId).children[0];
if (mapWidthInPixels > 600) {
if (clickedBoatMarkerLocationInPixels.x > mapWidthInPixels / 2) {
modalDialog.style.marginLeft = "100px";
modalDialog.style.marginRight = "auto";
} else {
modalDialog.style.marginLeft = "auto";
modalDialog.style.marginRight = "100px";
}
} else {
modalDialog.removeAttribute("style");
}
}
function findClosebyBoats(latLng) {
var mapHeightInPixels = map.getSize().y;
var mapHeightInMeters = Math.abs(map.getBounds().getNorth() - map.getBounds().getSouth()) * 60 * METERS_PER_NM;
var mapScaleMetersPerPixel = mapHeightInMeters / mapHeightInPixels;
var closebyBoatMarkers = [];
boatMarkers.forEach((boatMarker, mmsi) => {
if (mmsi == selfMmsi) {
return;
}
var distanceInMeters = latLng.distanceTo(boatMarker.getLatLng());
var distanceInPixels = distanceInMeters / mapScaleMetersPerPixel;
if (distanceInPixels < 30) {
boatMarker.distanceInPixels = distanceInPixels;
closebyBoatMarkers.push(boatMarker);
}
});
return closebyBoatMarkers;
}
function handleListOfClosebyBoatsClick(event) {
//console.log(event);
var boatMarker = boatMarkers.get(event.target.dataset.mmsi);
selectBoatMarker(boatMarker);
}
function selectBoatMarker(boatMarker) {
// if my own boat was selected - quit
// if clicking on the boat that is already selected - quit
if (boatMarker.mmsi == selfMmsi
|| boatMarker.mmsi == selectedVesselMmsi) {
return;
}
// clear previous blue box
// if (blueBoxIcon) {
// blueBoxIcon.removeFrom(map);
// console.log('about to remove blueCircle1', blueCircle1);
// blueCircle1.removeFrom(map);
// blueCircle2.removeFrom(map);
// }
// add blue box to selected boat marker
blueBoxIcon.setLatLng(boatMarker.getLatLng());
blueBoxIcon.addTo(map);
// bring boat to front - on top of the blue box - so that the boat can be clicked rather than the blue box
boatMarker.setZIndexOffset(1000);
var oldSelectedVesselMmsi;
// get vessel that was selected before this new selection (if any)
if (selectedVesselMmsi) {
oldSelectedVesselMmsi = selectedVesselMmsi;
}
selectedVesselMmsi = boatMarker.mmsi;
updateSingleVesselUI(targets.get(selectedVesselMmsi));
if (oldSelectedVesselMmsi) {
updateSingleVesselUI(targets.get(oldSelectedVesselMmsi));
}
// FIXME blueLayerGroup.addTo(map);
}
function handleMapClick(event) {
blueBoxIcon.removeFrom(map);
blueCircle1.removeFrom(map);
blueCircle2.removeFrom(map);
//blueLayerGroup.removeFrom(map);
if (selectedVesselMmsi) {
// update selected vessel (remove blue):
var savedSelectedVesselMmsi = selectedVesselMmsi;
selectedVesselMmsi = null;
updateSingleVesselUI(targets.get(savedSelectedVesselMmsi));
// update own vessel (remove blue):
updateSingleVesselUI(targets.get(selfMmsi));
}
}
function getTargetIcon(target, isLarge, color) {
// self
if (target.mmsi == selfMmsi) {
return getSelfIcon();
}
// 111MIDXXX SAR (Search and Rescue) aircraft
// 970MIDXXX AIS SART (Search and Rescue Transmitter)
// 972XXXXXX MOB (Man Overboard) device
// 974XXXXXX EPIRB (Emergency Position Indicating Radio Beacon) AIS
else if (target.mmsi.startsWith('111')
|| target.mmsi.startsWith('970')
|| target.mmsi.startsWith('972')
|| target.mmsi.startsWith('974')
) {
getSartIcon();
}
// 99MIDXXXX Aids to Navigation
else if (target.aisClass == 'ATON' || target.mmsi.startsWith('99')) {
return getAtonIcon(target, isLarge, color);
}
// class A
else if (target.aisClass == 'A') {
return getClassAIcon(target, isLarge, color);
}
// BASE
else if (target.aisClass == 'BASE') {
return getBaseIcon(target, isLarge, color);
}
// class B
else {
return getClassBIcon(target, isLarge, color);
}
}
function addLabelToCollisionController(layer, id, weight) {
var label = layer.getTooltip()._source._tooltip._container;
if (label) {
var rect = label.getBoundingClientRect();
var bottomLeft = map.containerPointToLatLng([rect.left, rect.bottom]);
var topRight = map.containerPointToLatLng([rect.right, rect.top]);
var boundingBox = {
bottomLeft: [bottomLeft.lng, bottomLeft.lat],
topRight: [topRight.lng, topRight.lat]
};
labelToCollisionController.ingestLabel(
boundingBox,
id,
-weight,
label,
id, // name
false //being dragged
);
}
}
function projectedLocation(start, θ, distance) {
const radius = 6371e3; // (Mean) radius of earth in meters
const [lat, lon] = start
// sinφ2 = sinφ1·cosδ + cosφ1·sinδ·cosθ
// tanΔλ = sinθ·sinδ·cosφ1 / cosδ−sinφ1·sinφ2
// see mathforum.org/library/drmath/view/52049.html for derivation
const δ = Number(distance) / radius; // angular distance in radians
const φ1 = toRadians(Number(lat));
const λ1 = toRadians(Numbe