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

1273 lines
47 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>
<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>
<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">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_i')">
<v-list-item-content>
<v-list-item-title>Δi Inline 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 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">
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 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>
<!-- 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: [],
sequenceDataTStamp: null,
loadingProgress: null,
viewState: {},
viewStateDefaults: {
//maxZoom: 18,
maxPitch: 89
},
heatmapValue: "total_error",
isFullscreen: false,
crosshairsPosition: [],
searchText: "",
searchVisible: false,
filterText: "",
filterVisible: false,
error: null,
};
},
computed: {
sequenceData () {
return this.sequenceDataElements?.map( el => el.data );
},
sequenceBinaryData() {
const sequences = this.sequenceDataElements?.sort((a, b) => a.sequence - b.sequence) || [];
if (!sequences.length) {
console.warn('No sequence data available');
return { positions: new Float32Array(0), values: [], udv: 2 };
}
// 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');
}
} catch (e) {
console.error('Failed to process first sequence:', e);
return { positions: new Float32Array(0), values: [], udv: 2 };
}
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: 2 };
}
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;
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 };
},
...mapGetters(['user', 'loading', 'serverEvent', 'lineName', 'serverEvent']),
...mapState({projectSchema: state => state.project.projectSchema})
},
watch: {
layerSelection (newVal, oldVal) {
this.render();
},
viewState: {
handler () {
this.setViewState();
},
deep: true
},
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 () {
const cache = await caches.open("dougal");
for (const key of await cache.keys()) {
cache.delete(key);
}
await this.$root.sleep(300);
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 () {
const types = ["p", "R", "F"];
const sequenceList = await this.api([`/project/${this.$route.params.project}/sequence`]);
// FIXME if sequenceList is undefined the API call failed. It should error and maybe retry
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/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}]);
});
}
for (const sequence of sequenceNumbers) {
try {
const url = `/project/${self.$route.params.project}/sequence/${sequence}?type=2`;
const sequenceData = await download(url);
if (sequenceData) {
yield {
id: `seq-${sequence}`,
sequence: sequence,
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");
}
}
}
}
},
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;
},
...mapActions(["api"])
},
async mounted () {
while (!Object.keys(this.$store.state.label.labels??{}).length) {
console.log("Waiting for labels");
await this.$root.sleep(1000);
console.log("LABELS", this.$store.state.label.labels);
}
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.rawSequencesPointsGunDataLayer;
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.layerSelection = [ "seq" ];
this.getSequenceData();
}
}
</script>