Files
dougal-software/lib/www/client/source/src/views/Map.vue
2025-08-08 13:47:30 +02:00

1518 lines
54 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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 id="map-container">
<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>Background</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" disabled/>
<label for="lyr-nau" title="Scan of Norway's nautical charts CURRENTLY UNAVAILABLE" disabled>Nautical charts (NO)</label>
</form>
<hr class="my-2"/>
<h3>Layers</h3>
<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" disabled><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>
<!-- We do not show individual points for planned lines. There are the preplots for that.
<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>
-->
<span><!-- Necessary for grid alignment purposes --></span>
<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>
<div>
<v-menu bottom offset-y class="mr-1 pb-1">
<template v-slot:activator="{ on, attrs }">
<v-icon small left class="mx-0" v-bind="attrs" v-on="on" :title="`Show deviations.\nCurrently selected mode: ${heatmapTitle}. Click to change`">mdi-dots-grid</v-icon>
</template>
<v-list nav dense>
<v-list-item @click="setHeatmapValue('total_error')">
<v-list-item-content>
<v-list-item-title>Δ<span style="text-decoration:overline;">ij</span> Total error</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item @click="setHeatmapValue('delta_j')">
<v-list-item-content>
<v-list-item-title>Δj Inline error</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item @click="setHeatmapValue('delta_i')">
<v-list-item-content>
<v-list-item-title>Δi Crossline error</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>
<v-menu bottom offset-y>
<template v-slot:activator="{ on, attrs }">
<v-list-item-title v-bind="attrs" v-on="on">
Raw vs final <v-icon small right>mdi-chevron-right</v-icon>
</v-list-item-title>
</template>
<v-list nav dense>
<v-list-item @click="setHeatmapValue('co_total_error')">
<v-list-item-content>
<v-list-item-title>Δ<span style="text-decoration:overline;">ij</span> Total error</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item @click="setHeatmapValue('co_delta_j')">
<v-list-item-content>
<v-list-item-title>Δj Inline error</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item @click="setHeatmapValue('co_delta_i')">
<v-list-item-content>
<v-list-item-title>Δi Crossline error</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>
<v-menu bottom offset-y>
<template v-slot:activator="{ on, attrs }">
<v-list-item-title v-bind="attrs" v-on="on">
Gun data <v-icon small right>mdi-chevron-right</v-icon>
</v-list-item-title>
</template>
<v-list nav dense>
<v-list-item>
<v-list-item-content>
<v-menu bottom offset-y>
<template v-slot:activator="{ on, attrs }">
<v-list-item-title v-bind="attrs" v-on="on">
Pressure <v-icon small right>mdi-chevron-right</v-icon>
</v-list-item-title>
</template>
<v-list nav dense>
<v-list-item @click="setHeatmapValue('press_μ')">
<v-list-item-content>
<v-list-item-title>Mean (μ)</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item @click="setHeatmapValue('press_σ')">
<v-list-item-content>
<v-list-item-title>Standard deviation (σ)</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item @click="setHeatmapValue('press_R')">
<v-list-item-content>
<v-list-item-title>Range (R)</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>
<v-menu bottom offset-y>
<template v-slot:activator="{ on, attrs }">
<v-list-item-title v-bind="attrs" v-on="on">
Depths <v-icon small right>mdi-chevron-right</v-icon>
</v-list-item-title>
</template>
<v-list nav dense>
<v-list-item @click="setHeatmapValue('depth_μ')">
<v-list-item-content>
<v-list-item-title>Mean (μ)</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item @click="setHeatmapValue('depth_σ')">
<v-list-item-content>
<v-list-item-title>Standard deviation (σ)</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item @click="setHeatmapValue('depth_R')">
<v-list-item-content>
<v-list-item-title>Range (R)</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>
<v-menu bottom offset-y>
<template v-slot:activator="{ on, attrs }">
<v-list-item-title v-bind="attrs" v-on="on">
Deltas <v-icon small right>mdi-chevron-right</v-icon>
</v-list-item-title>
</template>
<v-list nav dense>
<v-list-item @click="setHeatmapValue('delta_μ')">
<v-list-item-content>
<v-list-item-title>Mean (μ)</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item @click="setHeatmapValue('delta_σ')">
<v-list-item-content>
<v-list-item-title>Standard deviation (σ)</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item @click="setHeatmapValue('delta_R')">
<v-list-item-content>
<v-list-item-title>Range (R)</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>
<v-menu bottom offset-y>
<template v-slot:activator="{ on, attrs }">
<v-list-item-title v-bind="attrs" v-on="on">
Delay <v-icon small right>mdi-chevron-right</v-icon>
</v-list-item-title>
</template>
<v-list nav dense>
<v-list-item @click="setHeatmapValue('delay_μ')">
<v-list-item-content>
<v-list-item-title>Mean (μ)</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item @click="setHeatmapValue('delay_σ')">
<v-list-item-content>
<v-list-item-title>Standard deviation (σ)</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item @click="setHeatmapValue('delay_R')">
<v-list-item-content>
<v-list-item-title>Range (R)</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>
<v-menu bottom offset-y>
<template v-slot:activator="{ on, attrs }">
<v-list-item-title v-bind="attrs" v-on="on">
Fill time <v-icon small right>mdi-chevron-right</v-icon>
</v-list-item-title>
</template>
<v-list nav dense>
<v-list-item @click="setHeatmapValue('fill_μ')">
<v-list-item-content>
<v-list-item-title>Mean (μ)</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item @click="setHeatmapValue('fill_σ')">
<v-list-item-content>
<v-list-item-title>Standard deviation (σ)</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item @click="setHeatmapValue('fill_R')">
<v-list-item-content>
<v-list-item-title>Range (R)</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>
<v-menu bottom offset-y>
<template v-slot:activator="{ on, attrs }">
<v-list-item-title v-bind="attrs" v-on="on">
Misfires <v-icon small right>mdi-chevron-right</v-icon>
</v-list-item-title>
</template>
<v-list nav dense>
<v-list-item @click="setHeatmapValue('no_fire')">
<v-list-item-content>
<v-list-item-title>No fire</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item @click="setHeatmapValue('autofire')">
<v-list-item-content>
<v-list-item-title>Autofire</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item @click="setHeatmapValue('misfire')">
<v-list-item-content>
<v-list-item-title>No fire / autofire</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
<input type="checkbox" value="seqrh" v-model="layerSelection"/>
</div>
<!--
<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><!-- No heatmap available --></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>
<!-- 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 -->
<hr class="my-2"/>
<div title="Not yet implemented">
<v-btn
small
text
disabled
>
<v-icon small left>mdi-cog-outline</v-icon>
Map settings
</v-btn>
</div>
</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>
<v-icon
class="my-1"
:title="isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'"
@click="toggleFullscreen"
>{{ isFullscreen ? 'mdi-fullscreen-exit' : 'mdi-fullscreen' }}</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 MapLayersMixin from './MapLayersMixin';
import MapTooltipsMixin from './MapTooltipsMixin';
import { mapActions, mapGetters, mapState } from 'vuex';
import zoomFitIcon from '@/assets/zoom-fit-best.svg'
import { Deck, FlyToInterpolator } from '@deck.gl/core';
import { IconLayer } from '@deck.gl/layers';
import { DougalBinaryBundle, DougalBinaryChunkSequential, DougalBinaryChunkInterleaved } from '@dougal/binary';
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",
mixins: [
MapLayersMixin,
MapTooltipsMixin
],
components: {
},
data () {
return {
layerSelection: [],
layersAvailable: {},
sequenceDataElements: [],
sequenceBinaryData: { positions: new Float32Array(0), values: [], udv: 2 },
sequenceBinaryDataFinal: { positions: new Float32Array(0), values: [], udv: 3 },
sequenceDataTStamp: null,
loadingProgress: null,
//loadingRefreshInterval: 6000, // Refresh sequenceBinaryData* every six seconds while loading
loadingRefreshInterval: 0,
viewState: {},
viewStateDefaults: {
//maxZoom: 18,
maxPitch: 89
},
heatmapValue: "total_error",
isFullscreen: false,
crosshairsPosition: [],
searchText: "",
searchVisible: false,
filterText: "",
filterVisible: false,
error: null,
};
},
computed: {
lineTStamp () {
return this.$store.state.line.timestamp;
},
sequenceTStamp () {
return this.$store.state.sequence.timestamp;
},
sequenceData () {
return this.sequenceDataElements?.map( el => el.data );
},
/*
sequenceBinaryData() {
return this.getSequenceBinaryData(2); // Raw + gun data
},
sequenceBinaryDataFinal() {
return this.getSequenceBinaryData(3); // Final + c-o data
},
*/
heatmapTitle () {
let title = this.heatmapValue;
switch (this.heatmapValue) {
// Raw vs preplot
case "total_error":
title = "Total position error (raw data)";
break;
case "delta_i":
title = "Total crossline position error (raw data)";
break;
case "delta_j":
title = "Total inline position error (raw data)";
break;
// Final vs raw (c-o)
case "co_total_error":
title = "Total position error (final vs raw)";
break;
case "co_delta_i":
title = "Total crossline position error (final vs raw)";
break;
case "co_delta_j":
title = "Total inline position error (final vs raw)";
break;
// Gun deltas
case "delta_μ":
title = "Guns mean delta error";
break;
case "delta_σ":
title = "Guns delta standard deviation (1⋅σ)";
break;
case "delta_R":
title = "Guns delta range";
break;
// Gun pressure
case "press_μ":
title = "Fired array guns mean pressure";
break;
case "press_σ":
title = "Fired array pressure standard deviation (1⋅σ)";
break;
case "press_R":
title = "Fired array pressure range";
break;
// Gun depth
case "depth_μ":
title = "Guns mean depth";
break;
case "depth_σ":
title = "Gun depth standard deviation (1⋅σ)";
break;
case "depth_R":
title = "Gun depth range";
break;
// Gun fill time
case "fill_μ":
title = "Guns mean fill time";
break;
case "fill_σ":
title = "Guns fill time standard deviation (1⋅σ)";
break;
case "fill_R":
title = "Guns fill time range";
break;
// Gun delay
case "delay_μ":
title = "Guns mean firing delay";
break;
case "delay_σ":
title = "Guns firing delay standard deviation (1⋅σ)";
break;
case "delay_R":
title = "Guns firing delay range";
break;
default:
}
return title;
},
...mapGetters(['user', 'loading', 'serverEvent', 'lineName', 'serverEvent', 'lines', 'plannedSequences', 'sequences', 'labels']),
...mapState({projectSchema: state => state.project.projectSchema})
},
watch: {
layerSelection (newVal, oldVal) {
this.render();
},
viewState: {
handler () {
this.setViewState();
},
deep: true
},
lines () {
// Refresh map on change of preplot data
this.render();
},
plannedSequences () {
// Refresh map on change of planned lines
this.render();
},
sequences () {
// Refresh map on change of sequence data (raw / final)
this.sequenceDataTStamp = Date.now();
this.render();
},
labels () {
// Refresh the events layer on labels change
if (this.layerSelection.includes("log")) {
this.render();
}
},
sequenceDataElements () {
//console.log("seq data changed");
this.sequenceDataTStamp = Date.now();
//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];
if (typeof fn != 'function') {
throw new Error(`Layer ${name}: expected a function, got ${typeof fn}`);
}
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.getBBox(deck);
if (bounds) {
const viewport = deck.getViewports()[0];
const newViewState = {
...this.viewStateDefaults,
...viewport.fitBounds(bounds)
};
newViewState.transitionDuration = 500;
newViewState.transitionInterpolator = new FlyToInterpolator();
deck.setProps({ initialViewState: newViewState });
} else {
console.warn('Unable to calculate bounding box');
}
} else {
console.warn('Deck instance not available');
}
},
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});
}
},
toggleFullscreen() {
const mapElement = document.getElementById('map-container');
if (!this.isFullscreen) {
if (mapElement.requestFullscreen) {
mapElement.requestFullscreen();
} else if (mapElement.webkitRequestFullscreen) { // Safari
mapElement.webkitRequestFullscreen();
} else if (mapElement.msRequestFullscreen) { // IE11
mapElement.msRequestFullscreen();
}
this.isFullscreen = true;
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
} else if (document.msExitFullscreen) {
document.msExitFullscreen();
}
this.isFullscreen = false;
}
},
async refresh () {
if (window.caches) {
const cache = await caches.open("dougal");
for (const key of await cache.keys()) {
// Match only resolved requests
if (key instanceof Request) {
if (key.url?.match(`/project/${this.$route.params.project}`)) {
console.log("removing", key.method, key.url);
cache.delete(key);
}
}
}
}
await this.$root.sleep(300);
this.sequenceDataElements = [];
this.getSequenceData();
},
getBBox(deck, margin = 0.1) {
if (!deck || !deck.layerManager) {
console.warn('Invalid deck instance or layer manager');
return null;
}
let λ0 = +Infinity, φ0 = +Infinity, λ1 = -Infinity, φ1 = -Infinity;
const nonTileLayerTypes = ['ScatterplotLayer', 'GeoJsonLayer', 'LineLayer', 'IconLayer', 'DougalSequenceLayer'];
deck.layerManager.layers.forEach(layer => {
// Skip tile-based layers
if (['TileLayer', 'MVTLayer'].includes(layer.constructor.layerName)) {
return;
}
// Only process visible layers
if (!layer.props.visible || !nonTileLayerTypes.includes(layer.constructor.layerName)) {
return;
}
let data = layer.props.data;
// Handle DougalSequenceLayer
if (layer.constructor.layerName === 'DougalSequenceLayer') {
const { positions } = data;
if (positions && positions.length) {
for (let i = 0; i < positions.length; i += 2) {
const lon = positions[i];
const lat = positions[i + 1];
if (lon != null && lat != null && isFinite(lon) && isFinite(lat)) {
λ0 = Math.min(λ0, lon);
φ0 = Math.min(φ0, lat);
λ1 = Math.max(λ1, lon);
φ1 = Math.max(φ1, lat);
}
}
}
} else if (layer.constructor.layerName === 'ScatterplotLayer') {
// Direct point data (e.g., navp)
if (Array.isArray(data)) {
data.forEach(d => {
const [lon, lat] = layer.props.getPosition(d);
if (lon != null && lat != null && isFinite(lon) && isFinite(lat)) {
λ0 = Math.min(λ0, lon);
φ0 = Math.min(φ0, lat);
λ1 = Math.max(λ1, lon);
φ1 = Math.max(φ1, lat);
}
});
} else if (data && data.attributes && data.attributes.getPosition) {
// Binary data
const positions = data.attributes.getPosition.value;
for (let i = 0; i < data.length * 2; i += 2) {
const lon = positions[i];
const lat = positions[i + 1];
if (lon != null && lat != null && isFinite(lon) && isFinite(lat)) {
λ0 = Math.min(λ0, lon);
φ0 = Math.min(φ0, lat);
λ1 = Math.max(λ1, lon);
φ1 = Math.max(φ1, lat);
}
}
}
} else if (layer.constructor.layerName === 'GeoJsonLayer') {
// Extract points from GeoJSON features
let featureCollections = Array.isArray(data) ? data : [data];
featureCollections.forEach(item => {
let features = item;
if (item?.type === 'FeatureCollection') {
features = item.features || [];
}
if (Array.isArray(features)) {
features.forEach(feature => {
if (feature.geometry?.type === 'Point') {
const [lon, lat] = feature.geometry.coordinates;
if (lon != null && lat != null && isFinite(lon) && isFinite(lat)) {
λ0 = Math.min(λ0, lon);
φ0 = Math.min(φ0, lat);
λ1 = Math.max(λ1, lon);
φ1 = Math.max(φ1, lat);
}
} else if (feature.geometry?.type === 'LineString') {
feature.geometry.coordinates.forEach(([lon, lat]) => {
if (lon != null && lat != null && isFinite(lon) && isFinite(lat)) {
λ0 = Math.min(λ0, lon);
φ0 = Math.min(φ0, lat);
λ1 = Math.max(λ1, lon);
φ1 = Math.max(φ1, lat);
}
});
} else if (feature.geometry?.type === 'Polygon') {
feature.geometry.coordinates[0].forEach(([lon, lat]) => {
if (lon != null && lat != null && isFinite(lon) && isFinite(lat)) {
λ0 = Math.min(λ0, lon);
φ0 = Math.min(φ0, lat);
λ1 = Math.max(λ1, lon);
φ1 = Math.max(φ1, lat);
}
});
}
});
}
});
} else if (layer.constructor.layerName === 'LineLayer') {
// Extract source and target positions
if (Array.isArray(data)) {
data.forEach(d => {
const source = layer.props.getSourcePosition(d);
const target = layer.props.getTargetPosition(d);
if (source && source[0] != null && source[1] != null && isFinite(source[0]) && isFinite(source[1])) {
λ0 = Math.min(λ0, source[0]);
φ0 = Math.min(φ0, source[1]);
λ1 = Math.max(λ1, source[0]);
φ1 = Math.max(φ1, source[1]);
}
if (target && target[0] != null && target[1] != null && isFinite(target[0]) && isFinite(target[1])) {
λ0 = Math.min(λ0, target[0]);
φ0 = Math.min(φ0, target[1]);
λ1 = Math.max(λ1, target[0]);
φ1 = Math.max(φ1, target[1]);
}
});
}
} else if (layer.constructor.layerName === 'IconLayer') {
// Single point (e.g., crosshairs)
if (Array.isArray(data)) {
data.forEach(d => {
const [lon, lat] = layer.props.getPosition(d);
if (lon != null && lat != null && isFinite(lon) && isFinite(lat)) {
λ0 = Math.min(λ0, lon);
φ0 = Math.min(φ0, lat);
λ1 = Math.max(λ1, lon);
φ1 = Math.max(φ1, lat);
}
});
}
}
});
// Check if any valid points were found
if (!isFinite(λ0) || !isFinite(φ0) || !isFinite(λ1) || !isFinite(φ1)) {
console.warn('No valid coordinates found for non-tile layers');
return null;
}
// Apply margin
const δλ = λ1 - λ0;
const δφ = φ1 - φ0;
const = δλ * margin;
const = δφ * margin;
return [[λ0 - , φ0 - ], [λ1 + , φ1 + ]];
},
async getSequenceData (sequenceNumbers, types = [2, 3]) {
//const types = [2, 3]; // Bundle types: 2 → raw/gun data; 3 final data. See bundles.js
if (!sequenceNumbers) {
const sequenceList = await this.api([`/project/${this.$route.params.project}/sequence`]);
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 = {
headers: {
//Accept: "application/vnd.aaltronav.dougal+octet-stream; format=1c"
Accept: "application/vnd.aaltronav.dougal+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.
self.api([url, init, cb, {cache: true, format: "arrayBuffer"}]);
});
}
for (const sequence of sequenceNumbers) {
for (const type of types) {
try {
const url = `/project/${self.$route.params.project}/sequence/${sequence}?type=${type}`;
const sequenceData = await download(url);
if (sequenceData) {
yield {
id: `seq-${sequence}-${type}`,
sequence: sequence,
type: type,
data: sequenceData.data
};
}
} catch (err) {
console.error(`Error downloading sequence ${sequence}`);
console.error(err);
}
}
}
};
const transferData = () => {
this.sequenceBinaryData = this.getSequenceBinaryData(2);
this.sequenceBinaryDataFinal = this.getSequenceBinaryData(3);
};
for await (const value of iterator()) {
this.loadingProgress = ++count/sequenceCount*100;
//console.log("PRG", count, sequenceCount, this.loadingProgress, );
this.sequenceDataElements.push(Object.freeze(value));
if (this.loadingRefreshInterval > 0) {
setTimeout( () => {
transferData();
this.render();
}, this.loadingRefreshInterval );
}
}
transferData();
this.loadingProgress = null;
console.log("passed for await", deck);
},
getSequenceBinaryData (type) {
const sequences = this.sequenceDataElements?.filter( el => el.type == type)?.sort((a, b) => a.sequence - b.sequence) || [];
if (!sequences.length) {
console.warn('No sequence data available');
return { positions: new Float32Array(0), values: [], udv: type };
}
// Validate first sequence to get array sizes
let firstBundle;
try {
firstBundle = DougalBinaryBundle.clone(sequences[0].data);
if (!firstBundle.chunks || typeof firstBundle.chunks !== 'function') {
throw new Error('Invalid DougalBinaryBundle: chunks method missing');
}
if (!firstBundle.chunks().length) {
throw new Error('Invalid DougalBinaryBundle: bundle has no chunks');
}
} catch (e) {
console.error('Failed to process first sequence:', e);
return { positions: new Float32Array(0), values: [], udv: type };
}
const totalCount = sequences.reduce((acc, { data }) => {
try {
const bundle = DougalBinaryBundle.clone(data);
return acc + bundle.chunks().reduce((sum, chunk) => sum + chunk.jCount, 0);
} catch (e) {
console.warn('Skipping invalid sequence data:', e);
return acc;
}
}, 0);
if (totalCount === 0) {
console.warn('No valid points found in sequences');
return { positions: new Float32Array(0), values: [], udv: type };
}
const ΔelemCount = firstBundle.chunks()[0].ΔelemCount;
const elemCount = firstBundle.chunks()[0].elemCount;
const positions = new Float32Array(totalCount * 2);
const values = new Array(ΔelemCount + elemCount + 2);
for (let k = 0; k < values.length; k++) {
values[k] = new (k === 0 ? Uint16Array : k === 1 ? Uint32Array : k === 2 ? BigUint64Array : Float32Array)(totalCount);
}
let offset = 0;
let udv = 2;
sequences.forEach(({ data, sequence }) => {
try {
const bundle = DougalBinaryBundle.clone(data);
const chunks = bundle.chunks();
if (!chunks.length) {
console.warn(`No chunks in sequence ${sequence}`);
return;
}
udv = chunks[0].udv;
if (udv != type) {
console.warn(`Found bundle with udv = ${udv} but I'm only expecting udv = ${type}`);
}
let chunkOffset = offset;
for (const chunk of chunks) {
const λarray = chunk.elem(0);
const φarray = chunk.elem(1);
for (let i = 0; i < λarray.length; i++) {
positions[chunkOffset * 2 + i * 2] = λarray[i];
positions[chunkOffset * 2 + i * 2 + 1] = φarray[i];
}
values[0].set(new Uint16Array(chunk.jCount).fill(chunk.i), chunkOffset);
values[1].set(Uint32Array.from({ length: chunk.jCount }, (_, i) => chunk.j0 + i * chunk.Δj), chunkOffset);
for (let j = 0; j < chunk.ΔelemCount; j++) {
values[2 + j].set(chunk.Δelem(j), chunkOffset);
}
for (let j = 2; j < chunk.elemCount; j++) {
values[2 + chunk.ΔelemCount + j - 2].set(chunk.elem(j), chunkOffset);
}
chunkOffset += chunk.jCount;
}
offset += chunkOffset - offset;
} catch (e) {
console.warn(`Error processing sequence ${sequence}:`, e);
}
});
if (offset !== totalCount) {
console.warn(`Offset mismatch: ${offset}${totalCount}`);
}
console.log(`Concatenated ${totalCount} points, ${values.length} value arrays`);
return { positions, values, udv };
},
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");
}
}
}
}
},
setHeatmapValue (v) {
this.heatmapValue = v;
console.log("Switched heatmap to", v);
this.render();
},
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;
},
async handleSequences (context, {payload}) {
if (payload.pid != this.$route.params.project) {
console.warn(`${this.$route.params.project} ignoring notification for ${payload.pid}`);
return;
}
console.log("handleSequences (Map)", payload);
if (payload.old?.sequence) {
console.log(`Remove sequence ${payload.old.sequence} from data`);
this.sequenceDataElements = this.sequenceDataElements.filter( el => el.sequence != payload.old.sequence );
if (window.caches) {
const cache = await caches.open("dougal");
const rx = new RegExp(`/project/${this.$route.params.project}/sequence/${payload.old.sequence}(\\?.*)?$`);
const keys = await cache.keys();
for (const req of keys) {
if (rx.test(req.url)) {
console.log(`Removing ${req.url} from cache`);
cache.delete(req);
}
}
}
}
if (payload.new?.sequence) {
console.log(`Add sequence ${payload.new.sequence} to data`);
this.getSequenceData([payload.new.sequence]);
}
},
registerNotificationHandlers (action = "registerHandler") {
["raw_lines", "raw_shots", "final_lines", "final_shots"].forEach( table => {
this.$store.dispatch(action, {
table,
handler: (context, message) => {
this.handleSequences(context, message);
}
})
});
},
unregisterNotificationHandlers () {
this.registerNotificationHandlers("unregisterHandler");
},
...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");
}
this.layersAvailable.osm = this.osmLayer;
this.layersAvailable.sea = this.openSeaMapLayer;
this.layersAvailable.navp = this.vesselTrackPointsLayer;
this.layersAvailable.navl = this.vesselTrackLinesLayer;
this.layersAvailable.log = this.eventsLogLayer;
this.layersAvailable.psll = this.preplotSaillinesLinesLayer;
this.layersAvailable.ppll = this.preplotLinesLayer;
this.layersAvailable.planl = this.plannedLinesLinesLayer;
this.layersAvailable.seqrl = this.rawSequencesLinesLayer;
this.layersAvailable.seqfl = this.finalSequencesLinesLayer;
this.layersAvailable.pslp = this.preplotSaillinesPointLayer;
this.layersAvailable.pplp = this.preplotPointsLayer;
this.layersAvailable.seqrp = this.rawSequencesPointsLayer;
this.layersAvailable.seqfp = this.finalSequencesPointsLayer;
this.layersAvailable.seqrh = this.heatmapLayer;
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: this.getTooltip,
pickingRadius: 24,
onWebGLInitialized: this.initLayers
});
// Get fullscreen state
document.addEventListener('fullscreenchange', () => {
this.isFullscreen = !!document.fullscreenElement;
});
document.addEventListener('webkitfullscreenchange', () => {
this.isFullscreen = !!document.fullscreenElement;
});
document.addEventListener('msfullscreenchange', () => {
this.isFullscreen = !!document.fullscreenElement;
});
this.registerNotificationHandlers();
//this.layerSelection = [ "seq" ];
this.getSequenceData();
},
beforeDestroy () {
this.unregisterNotificationHandlers();
}
}
</script>