mirror of
https://gitlab.com/wgp/dougal/software.git
synced 2025-12-06 07:37:08 +00:00
1227 lines
40 KiB
Vue
1227 lines
40 KiB
Vue
<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>
|
||
|
||
<h4>Baseline</h4>
|
||
|
||
<div class="lines-points">
|
||
|
||
<template v-if="baseline">
|
||
<span :title="baseline.name">
|
||
<v-icon small right :color="stringToRGB(baseline.pid)">mdi-solid</v-icon>
|
||
{{baseline.pid}}
|
||
</span>
|
||
<label title="Show points"><v-icon small left class="mx-0">mdi-vector-point</v-icon> <input type="checkbox" :value="`${baseline.pid}-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="`${baseline.pid}-seqfl`" v-model="layerSelection"/></label>
|
||
<label><!-- No heatmap available --></label>
|
||
</template>
|
||
|
||
</div>
|
||
|
||
<h4>Monitor data</h4>
|
||
|
||
<div class="lines-points">
|
||
|
||
<template v-if="monitor">
|
||
<span :title="monitor.name">
|
||
<v-icon small right :color="stringToRGB(monitor.pid)">mdi-solid</v-icon>
|
||
{{monitor.pid}}
|
||
</span>
|
||
<label title="Show points"><v-icon small left class="mx-0">mdi-vector-point</v-icon> <input type="checkbox" :value="`${monitor.pid}-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="`${monitor.pid}-seqfl`" v-model="layerSelection"/></label>
|
||
<label><!-- No heatmap available --></label>
|
||
</template>
|
||
<template v-else-if="monitors && monitors.length">
|
||
|
||
<template v-if="monitors.length > 2">
|
||
<span title="Show / hide all monitor data">
|
||
<v-icon small right color="#00000000">mdi-solid</v-icon>
|
||
<i>All data</i>
|
||
</span>
|
||
<label title="Show points"><v-icon small left class="mx-0">mdi-vector-point</v-icon> <input type="checkbox" :checked="allMonitorPoints" @click="toggleAllMonitorPoints"/></label>
|
||
<label title="Show lines"><v-icon small left class="mx-0">mdi-vector-line</v-icon> <input type="checkbox" :checked="allMonitorLines" @click="toggleAllMonitorLines"/></label>
|
||
<label><!-- No heatmap available --></label>
|
||
</template>
|
||
|
||
<template v-for="monitor of monitors">
|
||
<span :title="monitor.name">
|
||
<v-icon small right :color="stringToRGB(monitor.pid)">mdi-solid</v-icon>
|
||
{{monitor.pid}}
|
||
</span>
|
||
<label title="Show points"><v-icon small left class="mx-0">mdi-vector-point</v-icon> <input type="checkbox" :value="`${monitor.pid}-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="`${monitor.pid}-seqfl`" v-model="layerSelection"/></label>
|
||
<label><!-- No heatmap available --></label>
|
||
</template>
|
||
</template>
|
||
|
||
</div>
|
||
|
||
<div class="lines-points">
|
||
|
||
<template v-if="crosshairsPositions.length">
|
||
<span>Markers</span>
|
||
<label title="Show crosshair markers"><v-icon small left class="mx-0">mdi-crosshairs</v-icon> <input type="checkbox" value="crosshairs" v-model="layerSelection"/></label>
|
||
<label title="Show marker labels" v-if="crosshairsLabels.length"><v-icon small left class="mx-0">mdi-format-text-variant</v-icon> <input type="checkbox" value="labels" v-model="layerSelection"/></label>
|
||
<label v-else><!-- No labels provided --></label>
|
||
<label><!-- No heatmap available --></label>
|
||
</template>
|
||
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<div class="map-overlay top right">
|
||
<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">
|
||
<!-- 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-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>
|
||
<v-btn color="primary" title="Switch to data view" @click="$emit('input', false)">Back to data</v-btn>
|
||
</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 '@/views/MapLayersMixin';
|
||
import { mapActions, mapGetters, mapState } from 'vuex';
|
||
import zoomFitIcon from '@/assets/zoom-fit-best.svg'
|
||
|
||
import { Deck, FlyToInterpolator } from '@deck.gl/core';
|
||
import { IconLayer, TextLayer, GeoJsonLayer, PathLayer } from '@deck.gl/layers';
|
||
import { DougalBinaryBundle, DougalBinaryChunkSequential, DougalBinaryChunkInterleaved } from '@dougal/binary';
|
||
import { DougalSequenceLayer } from '@/lib/deck.gl';
|
||
import DougalBinaryLoader from '@/lib/deck.gl/DougalBinaryLoader';
|
||
import { getLayerById, getAllPickingInfo } from '@/lib/deck.gl/getLayerPickingInfo';
|
||
|
||
|
||
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=";
|
||
|
||
|
||
function stringToRGB(input, o = 255) {
|
||
const str = String(input);
|
||
let hash = 0;
|
||
for (let i = 0; i < str.length; i++) {
|
||
const char = str.charCodeAt(i);
|
||
hash = ((hash << 5) - hash) + char;
|
||
hash |= 0; // Convert to 32-bit integer
|
||
}
|
||
const r = (hash >> 16) & 255;
|
||
const g = (hash >> 8) & 255;
|
||
const b = hash & 255;
|
||
return [r, g, b, (o%256)];
|
||
}
|
||
|
||
export default {
|
||
name: "DougalGroupMap",
|
||
|
||
mixins: [
|
||
MapLayersMixin
|
||
],
|
||
|
||
components: {
|
||
|
||
},
|
||
|
||
props: {
|
||
baseline: { type: Object, required: true },
|
||
monitor: { type: Object, required: false },
|
||
comparison: { type: Object, required: false },
|
||
monitors: { type: Array, required: false },
|
||
comparisons: { type: Array, required: false },
|
||
},
|
||
|
||
data () {
|
||
return {
|
||
layerSelection: [],
|
||
layersAvailable: {},
|
||
layersPendingLoad: [],
|
||
sequenceDataTStamp: null,
|
||
loadingProgress: null,
|
||
viewState: {},
|
||
viewStateDefaults: {
|
||
//maxZoom: 18,
|
||
maxPitch: 89
|
||
},
|
||
|
||
heatmapValue: "total_error",
|
||
isFullscreen: false,
|
||
crosshairsPositions: [],
|
||
crosshairsLabels: [],
|
||
|
||
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 );
|
||
},
|
||
|
||
allMonitorPoints () {
|
||
return this.monitors?.every( ({pid}) => this.layerSelection.includes(`${pid}-seqfp`) );
|
||
},
|
||
|
||
allMonitorLines () {
|
||
return this.monitors?.every( ({pid}) => this.layerSelection.includes(`${pid}-seqfl`) );
|
||
},
|
||
|
||
...mapGetters(['user', 'loading', 'serverEvent', 'lineName', 'serverEvent', 'lines', 'plannedSequences', 'sequences', 'labels']),
|
||
...mapState({projectSchema: state => state.project.projectSchema})
|
||
},
|
||
|
||
watch: {
|
||
|
||
baseline () {
|
||
this.populateDataLayersAvailable();
|
||
},
|
||
|
||
monitor () {
|
||
this.populateDataLayersAvailable();
|
||
},
|
||
|
||
monitors () {
|
||
this.populateDataLayersAvailable();
|
||
},
|
||
|
||
layerSelection (newVal, oldVal) {
|
||
this.populateDataLayersAvailable();
|
||
this.render();
|
||
},
|
||
|
||
layersPendingLoad (nv, ov) {
|
||
const total = this.layerSelection.length;
|
||
const pending = this.layerSelection.map( l => this.layersPendingLoad.includes(l) ? 1 : 0 ).filter( i => i ).length;
|
||
console.log("total = ", total, "pending = ", pending);
|
||
if (total && pending) {
|
||
this.loadingProgress = Math.min(100, Math.max(0, 100 - (pending / total * 100)))
|
||
} else {
|
||
this.loadingProgress = null;
|
||
}
|
||
console.log("loading progress = ", this.loadingProgress);
|
||
},
|
||
|
||
loadingProgress (newValue, oldValue) {
|
||
if (oldValue && newValue === null) {
|
||
console.log("Zooming to extents");
|
||
this.zoomReset();
|
||
}
|
||
},
|
||
|
||
viewState: {
|
||
handler () {
|
||
this.setViewState();
|
||
},
|
||
deep: true
|
||
},
|
||
|
||
},
|
||
|
||
methods: {
|
||
initView () {
|
||
},
|
||
|
||
async 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(await 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});
|
||
}
|
||
},
|
||
|
||
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;
|
||
}
|
||
},
|
||
|
||
getLayerById (layerId) {
|
||
return getLayerById(deck, layerId);
|
||
},
|
||
|
||
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', 'PathLayer', '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;
|
||
|
||
// If the layer has a getBounds() method, use that
|
||
if (typeof layer.getBounds === 'function') {
|
||
const bounds = layer.getBounds();
|
||
if (bounds?.length) {
|
||
bounds.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);
|
||
|
||
return; // Continue with the next layer
|
||
}
|
||
})
|
||
}
|
||
// If for whatever reason getBounds() has failed, we try other methods
|
||
}
|
||
|
||
// Handle DougalSequenceLayer
|
||
if (layer.constructor.layerName === 'DougalSequenceLayer') {
|
||
const positions = layer?.props?.data?.attributes?.getPosition?.value;
|
||
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 mλ = δλ * margin;
|
||
const mφ = δφ * margin;
|
||
|
||
return [[λ0 - mλ, φ0 - mφ], [λ1 + mλ, φ1 + mφ]];
|
||
},
|
||
|
||
// Returns the current second, as an integer.
|
||
// Used for triggering Deck.gl URL refreshes
|
||
currentSecond () {
|
||
return Math.floor(Date.now()/1000);
|
||
},
|
||
|
||
|
||
|
||
async initLayers (gl) {
|
||
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});
|
||
}
|
||
},
|
||
|
||
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;
|
||
},
|
||
|
||
loadOptions (options = {}) {
|
||
return {
|
||
loadOptions: {
|
||
fetch: {
|
||
method: 'GET',
|
||
headers: {
|
||
'Authorization': `Bearer ${this.$store.getters.jwt}`,
|
||
}
|
||
},
|
||
...options
|
||
},
|
||
};
|
||
|
||
},
|
||
|
||
preplotLinesLayer (options = {}) {
|
||
return new GeoJsonLayer({
|
||
id: 'ppll',
|
||
data: `/api/project/${this.baseline.pid}/gis/preplot/line?v=${Date.now()}`,
|
||
...this.loadOptions(),
|
||
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
|
||
})
|
||
},
|
||
|
||
|
||
preplotPointsLayer (options = {}) {
|
||
return new DougalSequenceLayer({
|
||
id: 'pplp',
|
||
data: `/api/project/${this.baseline.pid}/line/source?v=${Date.now()}`, // API endpoint returning binary data
|
||
loaders: [DougalBinaryLoader],
|
||
...this.loadOptions({
|
||
fetch: {
|
||
method: 'GET',
|
||
headers: {
|
||
Authorization: `Bearer ${this.$store.getters.jwt}`,
|
||
Accept: 'application/vnd.aaltronav.dougal+octet-stream'
|
||
}
|
||
}
|
||
}),
|
||
getRadius: 2,
|
||
getFillColor: (d, {data, index}) => data.attributes.value2.value[index] ? [240, 248, 255, 200] : [85, 170, 255, 200],
|
||
//getFillColor: [0, 120, 220, 200],
|
||
pickable: true,
|
||
...options
|
||
});
|
||
},
|
||
|
||
getMonitorLayer (monitor, type = "points") {
|
||
|
||
const colour = stringToRGB(monitor.pid, 150);
|
||
|
||
if (type == "lines") {
|
||
|
||
return (options = {}) => {
|
||
return new PathLayer({
|
||
id: `${monitor.pid}-seqfl`,
|
||
data: `/api/project/${monitor.pid}/sequence?type=4`, // API endpoint returning binary data
|
||
...this.loadOptions({
|
||
fetch: {
|
||
method: 'GET',
|
||
headers: {
|
||
Authorization: `Bearer ${this.$store.getters.jwt}`,
|
||
Accept: 'application/vnd.aaltronav.dougal+octet-stream'
|
||
}
|
||
}
|
||
}),
|
||
loaders: [DougalBinaryLoader],
|
||
dataTransform: (data) => {
|
||
//console.log("Incoming data:", data);
|
||
|
||
// Validate input
|
||
if (!data || !data.attributes) {
|
||
console.error("Invalid loaded data: missing attributes");
|
||
return [];
|
||
}
|
||
|
||
const lines = data.attributes.value0?.value; // Uint16Array (line numbers)
|
||
const sequences = data.attributes.value2?.value; // Uint16Array (sequence numbers)
|
||
const positions = data.attributes.getPosition?.value; // Float32Array (lon, lat pairs)
|
||
|
||
// Check for missing attributes
|
||
if (!lines || !sequences || !positions) {
|
||
console.error("Missing required attributes:", {
|
||
lines: !!lines,
|
||
sequences: !!sequences,
|
||
positions: !!positions,
|
||
});
|
||
return [];
|
||
}
|
||
|
||
// Validate array lengths
|
||
const numPoints = lines.length;
|
||
if (numPoints !== sequences.length || numPoints !== positions.length / 2) {
|
||
console.error("Mismatched attribute lengths:", {
|
||
lines: numPoints,
|
||
sequences: sequences.length,
|
||
positions: positions.length / 2,
|
||
});
|
||
return [];
|
||
}
|
||
|
||
// Group points by sequence and line, preserving point order
|
||
const tuples = {};
|
||
for (let n = 0; n < numPoints; n++) {
|
||
const line = lines[n];
|
||
const sequence = sequences[n];
|
||
const λ = positions[n * 2]; // Longitude
|
||
const φ = positions[n * 2 + 1]; // Latitude
|
||
const point = data.attributes.value1?.value?.[n] || n; // Fallback to index if point (j) unavailable
|
||
|
||
// Validate coordinates
|
||
if (typeof λ !== 'number' || typeof φ !== 'number' || isNaN(λ) || isNaN(φ)) {
|
||
console.warn(`Skipping invalid position at index ${n}: [${λ}, ${φ}]`);
|
||
continue;
|
||
}
|
||
|
||
const key = `${sequence}-${line}`; // Unique key for sequence and line
|
||
if (!tuples[key]) {
|
||
tuples[key] = { sequence, line, path: [], points: [] };
|
||
}
|
||
tuples[key].path.push({ point, coords: [λ, φ] });
|
||
}
|
||
|
||
// Sort paths by point and filter single-point paths
|
||
const result = [];
|
||
for (const key in tuples) {
|
||
const item = tuples[key];
|
||
// Sort by point to ensure correct path order
|
||
item.path.sort((a, b) => a.point - b.point);
|
||
item.path = item.path.map(p => p.coords); // Extract sorted coordinates
|
||
|
||
if (item.path.length >= 2) {
|
||
result.push({
|
||
sequence: item.sequence,
|
||
line: item.line,
|
||
path: item.path,
|
||
pid: monitor.pid,
|
||
name: monitor.name
|
||
});
|
||
} else {
|
||
console.log(`Discarding single-point path: sequence=${item.sequence}, line=${item.line}, points=${item.path.length}`);
|
||
}
|
||
}
|
||
|
||
//console.log("Outgoing data:", result);
|
||
return result;
|
||
},
|
||
onDataLoad: (value, {layer}) => {
|
||
console.log("Loading finished", layer.id);
|
||
const index = this.layersPendingLoad.indexOf(layer.id);
|
||
if (index != -1)
|
||
this.layersPendingLoad.splice(index, 1);
|
||
},
|
||
getPath: (d) => d.path,
|
||
getWidth: 2,
|
||
widthUnits: "pixels",
|
||
widthMinPixels: 1,
|
||
getColor: colour,
|
||
//getFillColor: [0, 120, 220, 200],
|
||
pickable: true,
|
||
...options
|
||
});
|
||
}
|
||
|
||
|
||
} else if (type == "points") {
|
||
|
||
return (options = {}) => {
|
||
return new DougalSequenceLayer({
|
||
id: `${monitor.pid}-seqfp`,
|
||
data: `/api/project/${monitor.pid}/sequence?type=4`, // API endpoint returning binary data
|
||
loaders: [DougalBinaryLoader],
|
||
...this.loadOptions({
|
||
fetch: {
|
||
method: 'GET',
|
||
headers: {
|
||
Authorization: `Bearer ${this.$store.getters.jwt}`,
|
||
Accept: 'application/vnd.aaltronav.dougal+octet-stream'
|
||
}
|
||
}
|
||
}),
|
||
dataTransform: (data) => {
|
||
data.pid = monitor.pid;
|
||
data.name = monitor.name;
|
||
return data;
|
||
},
|
||
onDataLoad: (value, {layer}) => {
|
||
console.log("Loading finished", layer.id);
|
||
const index = this.layersPendingLoad.indexOf(layer.id);
|
||
if (index != -1)
|
||
this.layersPendingLoad.splice(index, 1);
|
||
},
|
||
getRadius: 3,
|
||
getFillColor: colour,
|
||
//getFillColor: [0, 120, 220, 200],
|
||
pickable: true,
|
||
...options
|
||
});
|
||
}
|
||
|
||
}
|
||
|
||
},
|
||
|
||
populateDataLayersAvailable () {
|
||
Object.keys(this.layersAvailable)
|
||
.filter( key => [ "seqfl", "seqfp" ].some( id => key.includes(id ) ))
|
||
.forEach( key => delete this.layersAvailable[key] );
|
||
|
||
if (this.baseline) {
|
||
const pid = this.baseline.pid;
|
||
this.layersAvailable[`${pid}-seqfp`] = this.getMonitorLayer(this.baseline, "points");
|
||
this.layersAvailable[`${pid}-seqfl`] = this.getMonitorLayer(this.baseline, "lines");
|
||
}
|
||
|
||
if (this.monitor) {
|
||
const pid = this.monitor.pid;
|
||
this.layersAvailable[`${pid}-seqfp`] = this.getMonitorLayer(this.monitor, "points");
|
||
this.layersAvailable[`${pid}-seqfl`] = this.getMonitorLayer(this.monitor, "lines");
|
||
} else if (this.monitors) {
|
||
for (const monitor of this.monitors) {
|
||
const pid = monitor.pid;
|
||
this.layersAvailable[`${pid}-seqfp`] = this.getMonitorLayer(monitor, "points");
|
||
this.layersAvailable[`${pid}-seqfl`] = this.getMonitorLayer(monitor, "lines");
|
||
}
|
||
}
|
||
},
|
||
|
||
getTooltip (args) {
|
||
const style = { "max-width": "50ex"};
|
||
let html = "";
|
||
|
||
if (args?.layer?.constructor?.tooltip) {
|
||
return args.layer.constructor.tooltip(args);
|
||
} else if (args?.layer?.id.includes("seqrp")) {
|
||
// Baseline points (NOTE: not used!)
|
||
|
||
const line = args.layer.props.data.attributes.value0.value[args.index];
|
||
const point = args.layer.props.data.attributes.value1.value[args.index];
|
||
const sequence = args.layer.props.data.attributes.value2.value[args.index];
|
||
|
||
html += `Baseline ${args.layer.props.data.pid} – ${args.layer.props.data.name}<br/>\n`;
|
||
html += `L${line} S${sequence} P${point}`;
|
||
|
||
return {html, style};
|
||
|
||
} else if (args?.layer?.id.includes("seqrl")) {
|
||
// Baseline lines (NOTE: not used!)
|
||
|
||
if (!args.object) {
|
||
console.warn("No 'object' in", args);
|
||
return;
|
||
}
|
||
|
||
const { pid, name, sequence, line } = args?.object;
|
||
|
||
html += `Baseline ${pid} – ${name}<br/>\n`;
|
||
html += `L${line} S${sequence}`;
|
||
|
||
return {html, style};
|
||
|
||
} else if (args?.layer?.id.includes("seqfp")) {
|
||
// Monitor points
|
||
|
||
const pid = args.layer.props.data.pid;
|
||
const name = args.layer.props.data.name;
|
||
const line = args.layer.props.data.attributes.value0.value[args.index];
|
||
const point = args.layer.props.data.attributes.value1.value[args.index];
|
||
const sequence = args.layer.props.data.attributes.value2.value[args.index];
|
||
|
||
const kind = pid === this.baseline.pid
|
||
? "Baseline"
|
||
: "Monitor";
|
||
|
||
html += `${kind} ${pid} – ${name}<br/>\n`;
|
||
html += `L${line} S${sequence} P${point}`;
|
||
|
||
return {html, style};
|
||
|
||
} else if (args?.layer?.id.includes("seqfl")) {
|
||
// Monitor lines
|
||
|
||
if (!args.object) {
|
||
console.warn("No 'object' in", args);
|
||
return;
|
||
}
|
||
|
||
const { pid, name, sequence, line } = args.object;
|
||
const kind = pid === this.baseline.pid
|
||
? "Baseline"
|
||
: "Monitor";
|
||
|
||
html += `${kind} ${pid} – ${name}<br/>\n`;
|
||
html += `L${line} S${sequence}`;
|
||
|
||
return {html, style};
|
||
}
|
||
},
|
||
|
||
toggleAllMonitorPoints () {
|
||
if (this.allMonitorPoints) {
|
||
this.monitors?.forEach( ({pid}) => {
|
||
delete this.layerSelection[this.layerSelection.indexOf(`${pid}-seqfp`)];
|
||
this.layerSelection = [...this.layerSelection];
|
||
})
|
||
} else {
|
||
this.monitors?.forEach( ({pid}) => {
|
||
this.layerSelection.push(`${pid}-seqfp`);
|
||
})
|
||
}
|
||
},
|
||
|
||
toggleAllMonitorLines () {
|
||
if (this.allMonitorPoints) {
|
||
this.monitors?.forEach( ({pid}) => {
|
||
delete this.layerSelection[this.layerSelection.indexOf(`${pid}-seqfl`)];
|
||
this.layerSelection = [...this.layerSelection];
|
||
})
|
||
} else {
|
||
this.monitors?.forEach( ({pid}) => {
|
||
this.layerSelection.push(`${pid}-seqfl`);
|
||
})
|
||
}
|
||
},
|
||
|
||
stringToRGB (input, opacity) {
|
||
return "#"+stringToRGB(input, opacity).map( i => i.toString(16) ).join("");
|
||
},
|
||
|
||
...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.ppll = this.preplotLinesLayer;
|
||
|
||
//this.layersAvailable.pplp = this.preplotPointsLayer;
|
||
|
||
this.layersAvailable.crosshairs = (options = {}) => {
|
||
return new IconLayer({
|
||
id: 'crosshairs',
|
||
data: this.crosshairsPositions,
|
||
getPosition: d => d,
|
||
getSize: 32,
|
||
getColor: [ 255, 0, 0 ],
|
||
getIcon: (d) => {
|
||
return {
|
||
url: hashMarkerIconURL,
|
||
width: 24,
|
||
height: 24
|
||
};
|
||
},
|
||
...options
|
||
});
|
||
};
|
||
|
||
this.layersAvailable.labels = (options = {}) => {
|
||
return new TextLayer({
|
||
id: 'labels',
|
||
data: this.crosshairsPositions,
|
||
getPosition: d => d,
|
||
getSize: 18,
|
||
getColor: [ 255, 0, 0 ],
|
||
getText: (d, {index}) => this.crosshairsLabels[index],
|
||
getPixelOffset: [ 0, -24 ],
|
||
getTextAnchor: "start",
|
||
getAlignmentBase: "bottom",
|
||
...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
|
||
});
|
||
//console.log("deck = ", deck);
|
||
|
||
// All layers are initially shown
|
||
|
||
if (this.baseline) {
|
||
this.layerSelection.push(`${this.baseline.pid}-seqfl`);
|
||
this.layerSelection.push(`${this.baseline.pid}-seqfp`);
|
||
|
||
this.layersPendingLoad.push(`${this.baseline.pid}-seqfl`);
|
||
this.layersPendingLoad.push(`${this.baseline.pid}-seqfp`);
|
||
}
|
||
|
||
if (this.monitors) {
|
||
this.monitors.forEach( monitor => {
|
||
this.layerSelection.push(`${monitor.pid}-seqfl`);
|
||
this.layerSelection.push(`${monitor.pid}-seqfp`);
|
||
|
||
this.layersPendingLoad.push(`${monitor.pid}-seqfl`);
|
||
this.layersPendingLoad.push(`${monitor.pid}-seqfp`);
|
||
})
|
||
}
|
||
|
||
// Get fullscreen state
|
||
|
||
document.addEventListener('fullscreenchange', () => {
|
||
this.isFullscreen = !!document.fullscreenElement;
|
||
});
|
||
document.addEventListener('webkitfullscreenchange', () => {
|
||
this.isFullscreen = !!document.fullscreenElement;
|
||
});
|
||
document.addEventListener('msfullscreenchange', () => {
|
||
this.isFullscreen = !!document.fullscreenElement;
|
||
});
|
||
|
||
},
|
||
|
||
}
|
||
|
||
</script>
|