Files
dougal-software/lib/www/client/source/src/views/Map.vue
D. Berge 3b69a15703 Add manual refresh control to map.
It may or may not be permanenet, once tasks #322, #323, #324, #325
are implemented.

Closes #326
2025-07-28 12:05:10 +02:00

1297 lines
40 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div>
<v-overlay :value="error">
<v-alert type="error">{{ error }}</v-alert>
</v-overlay>
<div id="map">
</div>
<div> <!-- This div holds the map overlays -->
<div class="map-overlay top left expand-on-hover">
<div class="map-overlay-icon">
<v-icon>mdi-menu</v-icon>
</div>
<div class="map-overlay-inner">
<h3>Layers</h3>
<form>
<input id="lyr-osm" type="checkbox" value="osm" v-model="layerSelection"/>
<label for="lyr-osm">OpenStreetMap</label>
<input id="lyr-sea" type="checkbox" value="sea" v-model="layerSelection"/>
<label for="lyr-sea">OpenSeaMap</label>
<input id="lyr-nau" type="checkbox" value="nau" v-model="layerSelection"/>
<label for="lyr-nau" title="Scan of Norway's nautical charts">Nautical charts (NO)</label>
</form>
<hr class="my-2"/>
<div class="lines-points">
<span>Vessel track</span>
<label title="Show points"><v-icon small left class="mx-0">mdi-vector-point</v-icon> <input type="checkbox" value="navp" v-model="layerSelection"/></label>
<label title="Show lines" disabled><v-icon small left class="mx-0">mdi-vector-line</v-icon> <input type="checkbox" value="navl" v-model="layerSelection"/></label>
<label><!-- No heatmap available --></label>
<span>Sail lines</span>
<label title="Show points"><v-icon small left class="mx-0">mdi-vector-point</v-icon> <input type="checkbox" value="pslp" v-model="layerSelection"/></label>
<label title="Show lines" disabled><v-icon small left class="mx-0">mdi-vector-line</v-icon> <input type="checkbox" value="psll" v-model="layerSelection"/></label>
<label><!-- No heatmap available --></label>
<span>Preplots</span>
<label title="Show points"><v-icon small left class="mx-0">mdi-vector-point</v-icon> <input type="checkbox" value="pplp" v-model="layerSelection"/></label>
<label title="Show lines"><v-icon small left class="mx-0">mdi-vector-line</v-icon> <input type="checkbox" value="ppll" v-model="layerSelection"/></label>
<label><!-- No heatmap available --></label>
<span>Plan</span>
<label title="Show points"><v-icon small left class="mx-0">mdi-vector-point</v-icon> <input type="checkbox" value="planp" v-model="layerSelection"/></label>
<label title="Show lines"><v-icon small left class="mx-0">mdi-vector-line</v-icon> <input type="checkbox" value="planl" v-model="layerSelection"/></label>
<label><!-- No heatmap available --></label>
<span>Raw data</span>
<label title="Show points"><v-icon small left class="mx-0">mdi-vector-point</v-icon> <input type="checkbox" value="seqrp" v-model="layerSelection"/></label>
<label title="Show lines"><v-icon small left class="mx-0">mdi-vector-line</v-icon> <input type="checkbox" value="seqrl" v-model="layerSelection"/></label>
<label title="Show position error"><v-icon small left class="mx-0">mdi-dots-grid</v-icon> <input type="checkbox" value="seqrh" v-model="layerSelection"/></label>
<span>Final data</span>
<label title="Show points"><v-icon small left class="mx-0">mdi-vector-point</v-icon> <input type="checkbox" value="seqfp" v-model="layerSelection"/></label>
<label title="Show lines"><v-icon small left class="mx-0">mdi-vector-line</v-icon> <input type="checkbox" value="seqfl" v-model="layerSelection"/></label>
<label title="Show position error"><v-icon small left class="mx-0">mdi-dots-grid</v-icon> <input type="checkbox" value="seqfh" v-model="layerSelection"/></label>
<span>Events</span>
<label title="Show points"><v-icon small left class="mx-0">mdi-vector-point</v-icon> <input type="checkbox" value="log" v-model="layerSelection"/></label>
<label><!-- No lines available --></label>
<label><!-- No heatmap available --></label>
</div>
<!--
<input id="lyr-psl" type="checkbox" value="psl" v-model="layerSelection"/>
<label for="lyr-psl" title="Vessel preplots">Sail lines</label>
<input id="lyr-ppl" type="checkbox" value="ppl" v-model="layerSelection"/>
<label for="lyr-ppl" title="Source preplots">Preplot lines</label>
<input id="lyr-plan" type="checkbox" value="plan" v-model="layerSelection"/>
<label for="lyr-plan" title="Sequences in the planner">Planned lines</label>
<input id="lyr-preplots" type="checkbox" value="preplots" v-model="layerSelection"/>
<label for="lyr-preplots" title="Shotpoint deviation from preplot position">Preplot error</label>
<input id="lyr-peh" type="checkbox" value="peh" v-model="layerSelection"/>
<label for="lyr-peh" title="Shotpoint deviation from preplot position">Preplot error (heatmap)</label>
<input id="lyr-seq" type="checkbox" value="seq" v-model="layerSelection"/>
<label for="lyr-seq" title="Raw and/or final shot positions">Sequence data</label>
<input id="lyr-log" type="checkbox" value="log" v-model="layerSelection"/>
<label for="lyr-log" title="Event positions">Event log data</label>
<input id="lyr-nav" type="checkbox" value="nav" v-model="layerSelection"/>
<label for="lyr-nav" title="Vessel track">Navigation trail</label>
-->
<form>
<template v-if="crosshairsPosition.length">
<input id="lyr-crosshairs" type="checkbox" value="crosshairs" v-model="layerSelection"/>
<label for="lyr-crosshairs" title="Show or hide the crosshairs position marker">Crosshairs marker</label>
</template>
</form>
<!--
<h3 class="mt-3" title="At least one of raw or final will always be selected">Sequence data</h3>
<form>
<input id="sd-raw" type="checkbox" value="R" v-model="sequenceDataTypes"/>
<label for="sd-raw">Show raw</label>
<input id="sd-final" type="checkbox" value="F" v-model="sequenceDataTypes"/>
<label for="sd-final">Show final</label>
</form>
-->
<!-- QC data: This section is meant to show (some) QC results in a graphical way,
as 3D columns with lengths proportional to the QC values. Not implemented
at this time. -->
<!-- BEGIN QC data
<h3
class="mt-3"
title="The selected attribute will be displayed as different length columns in 3D view. Use Ctrl or Shift + Left Click to tilt the map."
>QC data aspect</h3>
<v-select
class="mt-1"
style="max-width: 20ex;"
dense
hide-details
clearable
:items="[ 'Depth', 'Pressure', 'Delta' ]"
>
</v-select>
END QC data -->
</div>
</div>
<div class="map-overlay top right">
<!-- TODO Implement search -->
<!--
<div>
<input v-show="searchVisible"
type="text"
class="mr-2"
placeholder="Search for a point or event"
v-model="searchText"
/>
<v-icon
class="my-1"
title="Find element"
@click="searchVisible = !searchVisible"
>mdi-crosshairs</v-icon>
</div>
-->
<!-- TODO Implement filtering -->
<!--
<div>
<input v-show="filterVisible"
type="text"
class="mr-2"
placeholder="Filter by sequence or line"
v-model="filterText"
/>
<v-icon
class="my-1"
title="Filter items"
@click="filterVisible = !filterVisible"
>mdi-filter-outline</v-icon>
</div>
-->
<div>
<v-icon
class="my-1 mt-4"
title="Fit view"
@click="zoomReset"
>mdi-magnify-scan</v-icon>
</div>
<div>
<v-icon
class="my-1"
title="Zoom in"
@click="zoomIn"
>mdi-magnify-plus-outline</v-icon>
</div>
<div>
<v-icon
class="my-1"
title="Zoom out"
@click="zoomOut"
>mdi-magnify-minus-outline</v-icon>
</div>
</div>
<div class="map-overlay bottom right">
<div>
<v-progress-circular v-if="loading || loadingProgress !== null"
size="42"
color="primary"
title="Loading data…"
:indeterminate="loadingProgress === null"
:value="loadingProgress"
>{{ Math.round(loadingProgress) }}%</v-progress-circular>
<div v-else>
<v-icon
class="my-1"
title="Manual refresh"
@click="refresh"
>mdi-cloud-refresh-variant-outline</v-icon>
</div>
</div>
</div>
<div class="map-overlay bottom left">
<div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
#map {
height: 100%;
background-color: #dae4f7;
}
.theme--dark #map {
background-color: #111;
}
.theme--dark .map-overlay-inner, .theme--dark .map-overlay-icon {
background-color: #222;
}
.map-overlay {
font: 12px/20px 'Helvetica Neue', Arial, Helvetica, sans-serif;
position: absolute;
z-index: 1;
}
.top {
top: 8px;
}
.bottom {
bottom: 8px;
}
.left {
left: 8px;
max-width: 20%;
}
.right {
right: 8px;
max-width: 20%;
text-align: right;
}
.map-overlay-icon {
background-color: white;
border: 1px solid black;
border-radius: 3px;
}
.map-overlay-inner {
padding: 10px;
background-color: white;
border: 1px solid black;
border-radius: 3px;
}
.map-overlay-inner form {
display: grid;
grid-template-columns: 36px 1fr;
}
.map-overlay-inner .lines-points {
display: grid;
grid-template-columns: minmax(auto, 1fr) 36px 36px 36px;
grid-column-gap: 4px;
}
.map-overlay-inner .lines-points :nth-child(4n+1) {
margin-right: 6px;
}
.map-overlay input[type=text] {
background-color: white;
border: 1px solid black;
border-radius: 4px;
}
.expand-on-hover:hover .map-overlay-icon {
display: none;
}
.expand-on-hover:not(:hover) .map-overlay-inner {
display: none;
}
.expand-on-hover:hover .map-overlay-inner {
display: block;
}
/* BEGIN
* This is meant to be a possible solution for keeping
* the layers menu open while selecting from a v-select
* in the context of displaying QC values. There may be
* better solutions.
.expand-on-hover:focus-within .map-overlay-icon {
display: none;
}
.expand-on-hover:focus-within .map-overlay-inner {
display: block;
}
* END
*/
</style>
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import ftstamp from '@/lib/FormatTimestamp'
import zoomFitIcon from '@/assets/zoom-fit-best.svg'
import { markdown, markdownInline } from '@/lib/markdown';
import { Deck, WebMercatorViewport, FlyToInterpolator, CompositeLayer } from '@deck.gl/core';
import { GeoJsonLayer, LineLayer, BitmapLayer, ScatterplotLayer, IconLayer } from '@deck.gl/layers';
import {HeatmapLayer} from '@deck.gl/aggregation-layers';
import { TileLayer, MVTLayer } from '@deck.gl/geo-layers';
import * as d3a from 'd3-array';
//import { json } from 'd3-fetch';
import { SequenceDataLayer } from '@/lib/deck.gl';
import { unbundle, unpack, toJSON, isLittleEndian } from '@/lib/binary';
// Important info about performance:
// https://deck.gl/docs/developer-guide/performance#supply-attributes-directly
const endianness = isLittleEndian();
let deck;
/* Decoded SVG:
* <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path style="fill:#ff0000;fill-opacity:1;stroke:none" d="M 7 3 L 7 4.03125 A 4.5 4.5 0 0 0 3.0332031 8 L 2 8 L 2 9 L 3.03125 9 A 4.5 4.5 0 0 0 7 12.966797 L 7 14 L 8 14 L 8 12.96875 A 4.5 4.5 0 0 0 11.966797 9 L 13 9 L 13 8 L 11.96875 8 A 4.5 4.5 0 0 0 8 4.0332031 L 8 3 L 7 3 z M 7 5.0390625 L 7 8 L 4.0410156 8 A 3.5 3.5 0 0 1 7 5.0390625 z M 8 5.0410156 A 3.5 3.5 0 0 1 10.960938 8 L 8 8 L 8 5.0410156 z M 4.0390625 9 L 7 9 L 7 11.958984 A 3.5 3.5 0 0 1 4.0390625 9 z M 8 9 L 10.958984 9 A 3.5 3.5 0 0 1 8 11.960938 L 8 9 z " />
</svg>
*/
const hashMarkerIconURL = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij4KPHBhdGggc3R5bGU9ImZpbGw6I2ZmMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZSIgZD0iTSA3IDMgTCA3IDQuMDMxMjUgQSA0LjUgNC41IDAgMCAwIDMuMDMzMjAzMSA4IEwgMiA4IEwgMiA5IEwgMy4wMzEyNSA5IEEgNC41IDQuNSAwIDAgMCA3IDEyLjk2Njc5NyBMIDcgMTQgTCA4IDE0IEwgOCAxMi45Njg3NSBBIDQuNSA0LjUgMCAwIDAgMTEuOTY2Nzk3IDkgTCAxMyA5IEwgMTMgOCBMIDExLjk2ODc1IDggQSA0LjUgNC41IDAgMCAwIDggNC4wMzMyMDMxIEwgOCAzIEwgNyAzIHogTSA3IDUuMDM5MDYyNSBMIDcgOCBMIDQuMDQxMDE1NiA4IEEgMy41IDMuNSAwIDAgMSA3IDUuMDM5MDYyNSB6IE0gOCA1LjA0MTAxNTYgQSAzLjUgMy41IDAgMCAxIDEwLjk2MDkzOCA4IEwgOCA4IEwgOCA1LjA0MTAxNTYgeiBNIDQuMDM5MDYyNSA5IEwgNyA5IEwgNyAxMS45NTg5ODQgQSAzLjUgMy41IDAgMCAxIDQuMDM5MDYyNSA5IHogTSA4IDkgTCAxMC45NTg5ODQgOSBBIDMuNSAzLjUgMCAwIDEgOCAxMS45NjA5MzggTCA4IDkgeiAiIC8+Cjwvc3ZnPgo=";
export default {
name: "Map",
components: {
},
data () {
return {
layerSelection: [],
layersAvailable: {},
sequenceDataTypes: [ "P", "F" ],
sequenceDataElements: [],
sequenceDataTStamp: null,
loadingProgress: null,
viewState: {},
viewStateDefaults: {
//maxZoom: 18,
//maxPitch: 85
},
crosshairsPosition: [],
searchText: "",
searchVisible: false,
filterText: "",
filterVisible: false,
error: null,
/*
layerDefinitions: [
{
id: "osm",
name: "OpenStreetMap"
},
{
id: "sea",
name: "OpenSeaMap"
},
{
id: "nau",
name: "Nautical charts (NO)",
title: "Scan of Norway's nautical charts"
disabled: true
},
{
]
*/
};
},
computed: {
sequenceDataPreplots () {
return {
tstamp: this.sequenceDataTStamp,
sequences: this.sequenceDataElements.filter(i => i.type == "P")
};
},
sequenceDataRaw () {
return {
tstamp: this.sequenceDataTStamp,
sequences: this.sequenceDataElements.filter(i => i.type == "R")
};
},
sequenceDataFinal () {
return {
tstamp: this.sequenceDataTStamp,
sequences: this.sequenceDataElements.filter(i => i.type == "F")
};
},
sequenceDataPostplots () {
const fn = i => i.type != "P" && this.sequenceDataTypes.includes(i.type);
return {
tstamp: this.sequenceDataTStamp,
sequences: this.sequenceDataElements.filter(fn)
};
},
fullProspectRaw () {
// Get raw data, so type = "R"
const sequences = this.sequenceDataElements.filter( i => i.type == "R" );
return [...sequences.map( i => toJSON(i.data) )].flat();
},
fullProspectFinal () {
// Get raw data, so type = "R"
const sequences = this.sequenceDataElements.filter( i => i.type == "F" );
return [...sequences.map( i => toJSON(i.data) )].flat();
},
...mapGetters(['user', 'loading', 'serverEvent', 'lineName', 'serverEvent']),
...mapState({projectSchema: state => state.project.projectSchema})
},
watch: {
layerSelection (newVal, oldVal) {
this.render();
},
viewState: {
handler () {
this.setViewState();
},
deep: true
},
sequenceDataTypes (val, old) {
// Ensure that at least one of raw or final is always selected
if (!val.includes("R") && !val.includes("F")) {
if (old.includes("F")) {
this.sequenceDataTypes.push("R");
} else {
this.sequenceDataTypes.push("F");
}
}
},
sequenceDataElements () {
//console.log("seq data changed");
this.sequenceDataTStamp = Date.now();
//this.render();
},
sequenceDataPreplots () {
this.render();
},
sequenceDataPostplots () {
this.render();
},
$route (to, from) {
if (to.name == "map") {
this.decodeURLHash();
}
},
},
methods: {
initView () {
},
render () {
if (deck) {
const layers = [];
for (const name in this.layersAvailable) {
//console.log("Visible", name, this.layerSelection.includes(name));
const fn = this.layersAvailable[name];
layers.push(fn({
visible: this.layerSelection.includes(name)
}));
}
//console.log("setProps", this.sequenceDataTStamp, deck.layerManager && deck.layerManager.getLayers().length);
//deck.setProps({layers, initialViewState: this.viewState});
deck.setProps({layers});
this.updateURL();
}
},
zoomReset () {
if (deck) {
const bounds = this.sequencesBBox();
if (bounds) {
const layer = deck.layerManager.layers[0]; // Get the first layer we come across
const initialViewState = {...this.viewStateDefaults, ...layer.context.viewport.fitBounds(bounds)};
initialViewState.transitionDuration = 500;
deck.setProps({initialViewState});
}
} else {
console.warn("Unable to calculate bounding box");
}
},
zoomIn () {
if (deck) {
const viewState = deck.getViewports()[0];
const initialViewState = {...this.viewStateDefaults, ...viewState};
initialViewState.zoom += 1;
initialViewState.transitionDuration = 300;
deck.setProps({initialViewState});
}
},
zoomOut () {
if (deck) {
const viewState = deck.getViewports()[0];
const initialViewState = {...this.viewStateDefaults, ...viewState};
initialViewState.zoom -= 1;
initialViewState.transitionDuration = 300;
deck.setProps({initialViewState});
}
},
async refresh () {
const cache = await caches.open("dougal");
for (const key of await cache.keys()) {
cache.delete(key);
}
await this.$root.sleep(300);
this.getSequenceData();
},
sequencesBBox (margin = 0.1) {
let [ λ0, φ0, λ1, φ1 ] = [ +Infinity, +Infinity, -Infinity, -Infinity ];
// FIXME Temporary fix to get this working again, but this.fullProspectRaw /
// this.fullProspectFinal are not intended to remain as JSON data.
const data = this.fullProspectRaw ?? this.fullProspectFinal;
if (data) {
for (const i of data) {
const λ = i.longitude;
const φ = i.latitude;
λ0 = Math.min(λ0, λ);
φ0 = Math.min(φ0, φ);
λ1 = Math.max(λ1, λ);
φ1 = Math.max(φ1, φ);
}
const δλ = λ1 - λ0;
const δφ = φ1 - φ0;
const = δλ * margin;
const = δφ * margin;
return [ [λ0-, φ0-], [λ1+, φ1+] ];
}
},
async getSequenceData () {
const types = ["P", "R", "F"];
const sequenceList = await this.api([`/project/${this.$route.params.project}/sequence`]);
const sequenceNumbers = sequenceList.map(i => i.sequence);
const sequenceCount = sequenceNumbers.length * types.length;
let count = 0;
const self = this;
const iterator = async function* () {
const download = (url) => {
const init = {
format: "arrayBuffer",
headers: {
Accept: "application/dougal-map-sequence+octet-stream"
}
};
return new Promise( async (resolve, reject) => {
const cb = (err, res) => {
if (res && !err) {
const etag = res.headers.get("ETag");
res.arrayBuffer().then(data => {
resolve({etag, data});
})
} else {
reject(err);
}
};
// Technically we do not need to await but this is
// to slow down the cient and avoid firing too many
// requests at once.
await self.api([url, init, cb, {cache: true}]);
});
}
for (const sequence of sequenceNumbers) {
for (const type of types) {
try {
const url = `/project/${self.$route.params.project}/sequence/${sequence}?geometry=${type}${endianness ? "" : "&endianness=big"}`;
const sequenceData = await download(url);
if (sequenceData) {
yield {
id: `${sequence}-${sequenceData.etag}`,
sequence: sequence,
type,
data: sequenceData.data
};
}
} catch (err) {
console.error(`Error downloading sequence ${sequence}`);
console.error(err);
}
}
}
};
for await (const value of iterator()) {
this.loadingProgress = ++count/sequenceCount*100;
//console.log("PRG", count, sequenceCount, this.loadingProgress, );
this.sequenceDataElements.push(Object.freeze(value));
}
this.loadingProgress = null;
console.log("passed for await", deck);
},
async initLayers (gl) {
console.log("SHOULD BE INITIALISING LAYERS HERE", gl);
this.decodeURL();
this.decodeURLHash();
deck.onViewStateChange = this.updateURL;
},
setViewState () {
const viewport = deck.getViewports()[0];
if (deck && viewport && this.viewState) {
const initialViewState = {
...this.initialViewState,
...viewport,
...this.viewState
};
deck.setProps({initialViewState});
}
},
updateURL ({viewState} = {}) {
if (!viewState && deck?.viewManager) {
viewState = deck.getViewports()[0];
}
if (viewState) {
const key = {x: "longitude", y: "latitude", z: "zoom", p: "pitch", b: "bearing"};
const view = Object.keys(key).map(k => [k, viewState[key[k]]]).filter(i => i[1]).map(i => i.join("")).join("");
const layers = this.layerSelection.join(";")
const value = [view, layers].join(":");
if (view.length && layers.length) {
localStorage.setItem(`dougal/prefs/${this.user?.name}/${this.$route.params.project}/${this.$options.name}/view/v1`, value);
}
}
},
decodeURL () {
const value = localStorage.getItem(`dougal/prefs/${this.user?.name}/${this.$route.params.project}/${this.$options.name}/view/v1`);
if (value) {
this.decodeURLHash(value);
}
},
decodeURLHash (hash) {
if (!hash) {
hash = document.location.hash.substring(1);
}
if (hash) {
const [ view, layers, crosshairs ] = hash.split(":");
if (view) {
const rx0 = /[xyzpb][+-]?[0-9]+\.?[0-9]*/g;
const rx1 = /([xyzpb])([+-]?[0-9]+\.?[0-9]*)/;
const key = {x: "longitude", y: "latitude", z: "zoom", p: "pitch", b: "bearing"};
this.viewState = Object.fromEntries(view.match(rx0).map(i => i.match(rx1).slice(1, 3)).map(i => {i[0] = key[i[0]]; i[1] = Number(i[1]); return i}));
}
if (layers) {
const l = layers.split(";").filter(i => this.layersAvailable.hasOwnProperty(i));
if (l.length) {
this.layerSelection = l;
}
}
if (crosshairs) {
const [ x, y ] = crosshairs.split(",").map(i => Number(i));
if (!isNaN(x) && !isNaN(y)) {
this.crosshairsPosition = [ x, y ];
if (!this.layerSelection.includes("crosshairs")) {
this.layerSelection.push("crosshairs");
}
}
}
}
},
checkWebGLSupport() {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
if (!gl) {
//this.error = 'WebGL is not supported in this browser or device.';
//this.loading = false;
console.error('WebGL not supported');
return false;
}
const isWebGL2 = gl instanceof WebGL2RenderingContext;
console.log('WebGL Support:', isWebGL2 ? 'WebGL2' : 'WebGL1');
return true;
},
toBase64 (binary) {
return btoa(String.fromCharCode(...new Uint8Array(binary)));
},
fromBase64 (text) {
const decoded = atob(text);
const arr = new Uint8Array(decoded.length);
for (let i=0; i<decoded.length; i++) {
arr[i] = decoded.charCodeAt(i);
}
return arr.buffer;
},
...mapActions(["api"])
},
async mounted () {
if (!this.checkWebGLSupport()) {
for (const countdown = 5; countdown > 0; countdown--) {
if (countdown) {
this.error = `This browser does not support WebGL. Switching to legacy map view in ${countdown} seconds.`;
await new Promise( (resolve) => setTimeout(() => resolve, 1000) );
}
}
this.error = null;
console.log("TODO: Should switch to legacy map view");
}
const COLOR_SCALE = [
// negative
[65, 182, 196],
[127, 205, 187],
[199, 233, 180],
[237, 248, 177],
// positive
[255, 255, 204],
[255, 237, 160],
[254, 217, 118],
[254, 178, 76],
[253, 141, 60],
[252, 78, 42],
[227, 26, 28],
[189, 0, 38],
[128, 0, 38]
];
this.layersAvailable.osm = (options = {}) => {
return new TileLayer({
id: "osm",
// https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Tile_servers
data: 'https://c.tile.openstreetmap.org/{z}/{x}/{y}.png',
minZoom: 0,
maxZoom: 19,
tileSize: 256,
renderSubLayers: props => {
const {
bbox: {west, south, east, north}
} = props.tile;
return new BitmapLayer(props, {
data: null,
image: props.data,
bounds: [west, south, east, north]
});
},
...options
})
}
/*
// OSM tiles layer. Handy to make water transparent
// but super reliable yet
this.layersAvailable.osm = (options = {}) => {
return new MVTLayer({
id: 'osm',
data: 'https://vector.openstreetmap.org/shortbread_v1/{z}/{x}/{y}.mvt',
minZoom: 0,
maxZoom: 14,
getFillColor: feature => {
const layer = feature.properties.layerName;
//console.log("layer =", layer, feature.properties.kind);
switch (layer) {
case "ocean":
return [0, 0, 0, 0];
case "land":
return [ 0x54, 0x6E, 0x7A, 255 ];
default:
return [ 240, 240, 240, 255 ];
}
},
getLineColor: feature => {
if (feature.properties.layer === 'water') {
return [0, 0, 0, 0]; // No outline for water
}
return [192, 192, 192, 255]; // Default line color for roads, etc.
},
getLineWidth: feature => {
if (feature.properties.highway) {
return feature.properties.highway === 'motorway' ? 6 : 3; // Example road widths
}
return 1;
},
stroked: true,
filled: true,
pickable: true
});
}
*/
/*
this.layersAvailable.nau = (options = {}) => {
return new TileLayer({
id: "nau",
// https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Tile_servers
data: 'https://opencache.statkart.no/gatekeeper/gk/gk.open_gmaps?layers=sjokartraster&zoom={z}&x={x}&y={y}',
minZoom: 0,
maxZoom: 19,
tileSize: 256,
renderSubLayers: props => {
const {
bbox: {west, south, east, north}
} = props.tile;
return new BitmapLayer(props, {
data: null,
image: props.data,
bounds: [west, south, east, north]
});
},
...options
})
}
*/
this.layersAvailable.navp = (options = {}) => {
return new ScatterplotLayer({
id: 'navp',
data: `/api/navdata?limit=10000`,
getPosition: (d) => ([d.longitude, d.latitude]),
getRadius: d => (d.speed),
radiusScale: 1,
lineWidthMinPixels: 2,
getFillColor: d => d.guns
? d.lineStatus == "online"
? [0xaa, 0x00, 0xff] // Online
: [0xd5, 0x00, 0xf9] // Soft start or guns otherwise active
: [0xea, 0x80, 0xfc], // Offline, guns inactive
getLineColor: [127, 65, 90],
getColor: [ 255, 0, 0 ],
getPointRadius: 12,
radiusUnits: "pixels",
pointRadiusMinPixels: 4,
stroked: false,
filled: true,
pickable: true,
...options
})
};
this.layersAvailable.navl = (options = {}) => {
return new LineLayer({
id: 'navl',
data: `/api/navdata`,
lineWidthMinPixels: 2,
getLineColor: (d) => d.properties.ntba ? [240, 248, 255, 200] : [85, 170, 255, 200],
getSourcePosition: (obj, i) => i.index < i.data?.length ? [i.data[i.index]?.longitude, i.data[i.index]?.latitude] : null,
getTargetPosition: (obj, i) => i.index < i.data?.length ? [i.data[i.index+1]?.longitude, i.data[i.index+1]?.latitude] : null,
getLineWidth: 3,
getPointRadius: 2,
radiusUnits: "pixels",
pointRadiusMinPixels: 2,
pickable: true,
...options
})
};
this.layersAvailable.sea = (options = {}) => {
return new TileLayer({
id: "sea",
data: 'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png',
minZoom: 0,
maxZoom: 19,
tileSize: 256,
renderSubLayers: props => {
const {
bbox: {west, south, east, north}
} = props.tile;
return new BitmapLayer(props, {
data: null,
image: props.data,
bounds: [west, south, east, north]
});
},
...options
})
}
this.layersAvailable.log = (options = {}) => {
return new GeoJsonLayer({
id: 'log',
data: `/api/project/${this.$route.params.project}/event?mime=application/geo%2Bjson`,
lineWidthMinPixels: 2,
getLineColor: [127, 65, 90],
getPointRadius: 2,
radiusUnits: "pixels",
pointRadiusMinPixels: 2,
pickable: true,
...options
})
};
this.layersAvailable.psll = (options = {}) => {
return new GeoJsonLayer({
id: 'psll',
data: `/api/project/${this.$route.params.project}/gis/preplot/line?class=V`,
lineWidthMinPixels: 1,
getLineColor: (d) => d.properties.ntba ? [240, 248, 255, 200] : [85, 170, 255, 200],
getLineWidth: 1,
getPointRadius: 2,
radiusUnits: "pixels",
pointRadiusMinPixels: 2,
pickable: true,
...options
})
};
this.layersAvailable.ppll = (options = {}) => {
return new GeoJsonLayer({
id: 'ppll',
data: `/api/project/${this.$route.params.project}/gis/preplot/line`,
lineWidthMinPixels: 1,
getLineColor: (d) => d.properties.ntba ? [240, 248, 255, 200] : [85, 170, 255, 200],
getLineWidth: 1,
getPointRadius: 2,
radiusUnits: "pixels",
pointRadiusMinPixels: 2,
pickable: true,
...options
})
};
this.layersAvailable.planl = (options = {}) => {
return new LineLayer({
id: 'plan',
data: `/api/project/${this.$route.params.project}/plan`,
widthMinPixels: 2,
widthUnits: "pixels",
getSourcePosition: d => d.geometry.coordinates[0],
getTargetPosition: d => d.geometry.coordinates[1],
getLineWidth: 8,
getColor: (d) => {
const k = d.azimuth/360*255;
console.log("COLOUR", k);
return [ k, 128, k, 200 ];
},
pickable: true,
...options
})
};
this.layersAvailable.seqrl = (options = {}) => {
return new GeoJsonLayer({
id: 'seqrl',
data: `/api/project/${this.$route.params.project}/gis/raw/line`,
lineWidthMinPixels: 1,
getLineColor: (d) => d.properties.ntbp ? [0xe6, 0x51, 0x00, 200] : [0xff, 0x98, 0x00, 200],
getLineWidth: 1,
getPointRadius: 2,
radiusUnits: "pixels",
pointRadiusMinPixels: 2,
pickable: true,
...options
})
};
this.layersAvailable.seqfl = (options = {}) => {
return new GeoJsonLayer({
id: 'seqfl',
data: `/api/project/${this.$route.params.project}/gis/final/line`,
lineWidthMinPixels: 1,
getLineColor: (d) => d.properties.pending ? [0xa7, 0xff, 0xab, 200] : [0x00, 0x96, 0x88, 200],
getLineWidth: 1,
getPointRadius: 2,
radiusUnits: "pixels",
pointRadiusMinPixels: 2,
pickable: true,
...options
})
};
this.layersAvailable.pplp = (options = {}) => {
return new SequenceDataLayer({
id: 'pplp',
data: this.sequenceDataPreplots,
pickable: true,
...options
});
};
this.layersAvailable.seqrp = (options = {}) => {
return new SequenceDataLayer({
id: 'seqrp',
data: this.sequenceDataRaw,
pickable: true,
...options
});
};
this.layersAvailable.seqfp = (options = {}) => {
return new SequenceDataLayer({
id: 'seqfp',
data: this.sequenceDataFinal,
pickable: true,
...options
});
};
this.layersAvailable.seqrh = (options = {}) => {
return new HeatmapLayer({
id: 'seqrh',
data: this.fullProspectRaw,
radiusPixels: 15,
getPosition: d => [ d.longitude, d.latitude ],
getWeight: d => Math.sqrt(d.Δi**2 + d.Δj**2),
colorDomain: [2, 20],
aggregation: "MEAN",
pickable: false,
...options
});
};
this.layersAvailable.seqfh = (options = {}) => {
return new HeatmapLayer({
id: 'seqfh',
data: this.fullProspectFinal,
radiusPixels: 15,
getPosition: d => [ d.longitude, d.latitude ],
getWeight: d => Math.sqrt(d.Δi**2 + d.Δj**2),
colorDomain: [2, 20],
aggregation: "MEAN",
pickable: false,
...options
});
};
this.layersAvailable.crosshairs = (options = {}) => {
return new IconLayer({
id: 'crosshairs',
data: [{position: [...this.crosshairsPosition]}],
getSize: 24,
getColor: [ 255, 0, 0 ],
getIcon: (d) => {
return {
url: hashMarkerIconURL,
width: 24,
height: 24
};
},
...options
});
};
deck = new Deck({
parent: document.getElementById("map"),
mapStyle: 'https://basemaps.cartocdn.com/gl/positron-nolabels-gl-style/style.json',
initialViewState: {
...this.viewStateDefaults,
latitude: 61.5,
longitude: 2.5,
zoom: 7,
pitch: 0,
},
controller: {inertia: true},
layers: [],
getTooltip,
pickingRadius: 24,
onWebGLInitialized: this.initLayers
});
function getTooltip (args) {
//console.log("tooltip", args?.layer?.constructor?.layerName, args);
if (args?.layer?.constructor?.tooltip) {
return args.layer.constructor.tooltip(args);
} else if (args?.layer?.id == "log") {
const p = args?.object?.properties;
if (p) {
let html = "";
if (p.sequence && p.point) {
html += `S${p.sequence.toString().padStart(3, "0")} ${p.point}</br>\n`;
}
html += `<small>${p.tstamp}</small><br/>\n`;
html += `<span>${p.remarks}</span>`;
if (p.labels?.length) {
html += `</br>\n<small><i>${p.labels.join(", ")}</i></small>`;
}
const style = { "max-width": "50ex"};
return {html, style};
}
} else if (args?.layer?.id == "pplp" || args?.layer?.id == "pslp") {
const p = args?.object?.properties;
const isSailline = args?.layer?.id == "psl";
if (p) {
let html = `${isSailline ? "Sail" : "Preplot"} line ${p.line} (${p.incr ? "+" : "-"})`;
if (p.ntba) {
html += ` <b title="Not to be acquired">NTBA</a>`;
}
if (p.remarks) {
html += `<br/>\n${p.remarks}`;
}
const style = { "max-width": "50ex"};
return {html, style};
}
} else if (args?.layer?.id == "plan") {
const p = args?.object;
if (p) {
const duration = `${(p.duration?.hours??0).toString().padStart(2, "0")}:${(p.duration?.minutes??0).toString().padStart(2, "0")}`;
const Δt = ((new Date(p.ts1)).valueOf() - (new Date(p.ts0)).valueOf()) / 1000; // seconds
const speed = (p.length / Δt) * 3.6 / 1.852; // knots
let html = `Planned sequence <b>${p.sequence}</b></br>\n` +
`Line <b>${p.line}</b> ${p.name}</br>\n` +
`${p.num_points} Points</br>\n` +
`${p.length} m ${p.azimuth.toFixed(2)}°</br>\n` +
`${duration} @ ${speed.toFixed(1)} kt</br>\n` +
`<b>${p.fsp}</b> @ ${p.ts0.toISOString()}</br>\n` +
`<b>${p.lsp}</b> @ ${p.ts1.toISOString()}`;
if (p.remarks) {
html += `</br>\n<hr/>${p.remarks}`;
}
const style = { "max-width": "50ex"};
return {html, style};
}
} else if (args?.layer?.id == "seqrl" || args?.layer?.id == "seqfl") {
let type;
switch(args.layer.id) {
case "seqrl":
type = "Raw";
break;
case "seqfl":
type = "Final";
break;
}
const p = args?.object?.properties;
if (p) {
let html = `Sequence ${p.sequence} (${type})<br/>\n`;
html += `Line <b>${p.line}</b><br/>\n`;
html += `${p.num_points} points (${p.missing_shots ? (p.missing_shots + " missing") : "None missing"})<br/>\n`;
html+= `${(p.length??0).toFixed(0)} m ${(p.azimuth??0).toFixed(1)}°<br/>\n`;
html += `${p.duration}<br/>\n`;
html += `<b>${p.fsp}</b> @ ${p.ts0}<br/>\n`;
html += `<b>${p.lsp}</b> @ ${p.ts1}<br/>\n`;
if (p.ntbp) {
html += "<b>Not to be processed</b><br/>\n";
} else if (p.pending) {
html += "<b>Pending</b><br/>\n";
}
html += "<hr/><br/>\n";
html += markdown(p.remarks);
const style = { "max-width": "50ex"};
return {html, style};
}
} else if (args?.layer?.id == "navp") {
const p = args.object;
if (p) {
let html = `${p.vesselName}<br/>\n`
+ `${p.tstamp}<br/>\n`
+ `BSP ${(p.speed??0).toFixed(1)} kt CMG ${(p.cmg??0).toFixed(1).padStart(5, "0")}° HDG ${(p.bearing??0).toFixed(1).padStart(5, "0")}° DPT ${(p.waterDepth??0).toFixed(1)} m<br/>\n`
+ `${p.lineStatus}<br/>\n`;
if (p.guns) {
console.log(p);
const pressure = p.guns.map( i => i[11] ); // 11 is gun pressure
const μpress = d3a.mean(pressure);
const σpress = d3a.deviation(pressure);
if (p.lineStatus && p.lineStatus != "offline") {
html += `${p.lineName}<br/>\n`
+ `S: ${p._sequence} L: ${p._line} P: ${p.shot}<br/>`
+ `Source ${p.src_number} `
+ ((p.trg_mode && p.trg_mode != "external") ? `<b>${p.trg_mode.toUpperCase()} TRIGGER</b> ` : "")
+ `<small>FSID ${p.fsid}</small> <small>mask ${p.mask}</small><br/>\n`
+ `Δ ${(p.avg_delta??0).toFixed(3)} ms ±${(p.std_delta??0).toFixed(3)} ms<br/>\n`
+ `${(μpress??0).toFixed(0)} psi ±${(σpress??0).toFixed(0)} psi / ${(p.volume??0).toFixed(0)} in³<br/>\n`
+ `along ${(p.inline??0).toFixed(1)} m / across ${(p.crossline??0).toFixed(1)} m<br/>\n`;
} else {
// Soft start?
html +=
`Source ${p.src_number} `
+ ((p.trg_mode && p.trg_mode != "external") ? `<b>${p.trg_mode.toUpperCase()} TRIGGER</b> ` : "")
+ `<small>mask ${p.mask}</small><br/>\n`
+ `Δ ${(p.avg_delta??0).toFixed(3)} ms ±${(p.std_delta??0).toFixed(3)} ms<br/>\n`
+ `${(p.manifold??0).toFixed(0)} psi / ${(p.volume??0).toFixed(0)} in³<br/>\n`
+ `along ${(p.inline??0).toFixed(1)} m / across ${(p.crossline??0).toFixed(1)} m<br/>\n`;
}
}
const style = { "max-width": "50ex"};
return {html, style};
}
}
}
//this.layerSelection = [ "seq" ];
this.getSequenceData();
}
}
</script>