Files
dougal-software/lib/www/client/source/src/components/groups/group-map.vue
2025-08-22 16:01:20 +02:00

1227 lines
40 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>
<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 = δλ * margin;
const = δφ * margin;
return [[λ0 - , φ0 - ], [λ1 + , φ1 + ]];
},
// 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>