Files
dougal-software/lib/www/client/source/src/components/groups/group-map.vue

1227 lines
40 KiB
Vue
Raw Normal View History

2025-08-21 14:57:50 +02:00
<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>