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 >
2025-08-22 02:04:42 +02:00
< div >
< v-icon
class = "my-1"
title = "Tilt out"
@ click = "tiltOut"
2025-08-22 02:12:45 +02:00
> mdi - axis - x - rotate - counterclockwise < / v-icon >
2025-08-22 02:04:42 +02:00
< / div >
< div >
< v-icon
class = "my-1"
2025-08-22 02:13:15 +02:00
title = "Tilt in"
2025-08-22 02:04:42 +02:00
@ click = "tiltIn"
2025-08-22 02:12:45 +02:00
> mdi - axis - x - rotate - clockwise < / v-icon >
2025-08-22 02:04:42 +02:00
< / div >
< div >
< v-icon v-if = "bearing==0"
class = "my-1"
title = "Bin up"
@ click = "setBearing('ζ')"
> mdi - view - grid - outline < / v-icon >
< v-icon v-else
class = "my-1"
title = "North up"
: style = "`transform: rotate(${-bearing}deg);`"
@ click = "setBearing(0)"
> mdi - navigation < / v-icon >
< / div >
2025-08-21 14:57:50 +02:00
< 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…"
2025-08-21 15:20:31 +02:00
: indeterminate = "!loadingProgress"
2025-08-21 14:57:50 +02:00
: 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 : 12 px / 20 px 'Helvetica Neue' , Arial , Helvetica , sans - serif ;
position : absolute ;
z - index : 1 ;
}
. top {
top : 8 px ;
}
. bottom {
bottom : 8 px ;
}
. left {
left : 8 px ;
max - width : 20 % ;
}
. right {
right : 8 px ;
max - width : 20 % ;
text - align : right ;
}
. map - overlay - icon {
background - color : white ;
border : 1 px solid black ;
border - radius : 3 px ;
}
. map - overlay - inner {
padding : 10 px ;
background - color : white ;
border : 1 px solid black ;
border - radius : 3 px ;
}
. map - overlay - inner form {
display : grid ;
grid - template - columns : 36 px 1 fr ;
}
. map - overlay - inner . lines - points {
display : grid ;
grid - template - columns : minmax ( auto , 1 fr ) 36 px 36 px 36 px ;
grid - column - gap : 4 px ;
}
. map - overlay - inner . lines - points : nth - child ( 4 n + 1 ) {
margin - right : 6 px ;
}
. map - overlay input [ type = text ] {
background - color : white ;
border : 1 px solid black ;
border - radius : 4 px ;
}
. 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 ;
}
/ * B E G I N
* 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' ;
let deck ;
/ * D e c o d e d S V G :
* < 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
} ,
2025-08-22 02:04:42 +02:00
bearing : 0 ,
2025-08-21 14:57:50 +02:00
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 ( ) {
2025-08-22 02:04:42 +02:00
// We need the configuration of the baseline project so that
// the "bin up" orientation control will work.
if ( this . baseline ? . pid ) {
this . $store . dispatch ( 'getProject' , this . baseline . pid ) ;
}
2025-08-21 14:57:50 +02:00
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 } ) ;
}
} ,
2025-08-22 02:04:42 +02:00
tiltIn ( ) {
if ( deck ) {
const viewState = deck . getViewports ( ) [ 0 ] ;
const initialViewState = { ... this . viewStateDefaults , ... viewState } ;
initialViewState . pitch -= 10 ;
initialViewState . transitionDuration = 300 ;
deck . setProps ( { initialViewState } ) ;
}
} ,
tiltOut ( ) {
if ( deck ) {
const viewState = deck . getViewports ( ) [ 0 ] ;
const initialViewState = { ... this . viewStateDefaults , ... viewState } ;
initialViewState . pitch += 10 ;
initialViewState . transitionDuration = 300 ;
deck . setProps ( { initialViewState } ) ;
}
} ,
setBearing ( bearing ) {
if ( deck ) {
if ( bearing === 'ζ' ) {
bearing = this . $store . getters . projectConfiguration ? . binning ? . theta ? ? 0 ;
}
const viewState = deck . getViewports ( ) [ 0 ] ;
const initialViewState = { ... this . viewStateDefaults , ... viewState } ;
initialViewState . bearing = ( bearing + 360 ) % 360 ;
initialViewState . transitionDuration = 300 ;
deck . setProps ( { initialViewState } ) ;
}
} ,
2025-08-21 14:57:50 +02:00
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 ) {
2025-08-22 02:04:59 +02:00
// Does nothing
2025-08-21 14:57:50 +02:00
} ,
setViewState ( ) {
const viewport = deck . getViewports ( ) [ 0 ] ;
if ( deck && viewport && this . viewState ) {
const initialViewState = {
... this . initialViewState ,
... viewport ,
... this . viewState
} ;
deck . setProps ( { initialViewState } ) ;
}
} ,
2025-08-22 02:04:42 +02:00
viewStateUpdated ( { viewState } ) {
this . bearing = viewState . bearing ;
} ,
2025-08-21 14:57:50 +02:00
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 ,
2025-08-22 02:04:42 +02:00
onWebGLInitialized : this . initLayers ,
onViewStateChange : this . viewStateUpdated ,
2025-08-21 14:57:50 +02:00
} ) ;
//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 ` ) ;
2025-08-22 02:04:42 +02:00
// We need the configuration of the baseline project so that
// the "bin up" orientation control will work.
this . $store . dispatch ( 'getProject' , this . baseline . pid ) ;
2025-08-21 14:57:50 +02:00
}
if ( this . monitors ) {
this . monitors . forEach ( monitor => {
2025-08-21 15:21:01 +02:00
//this.layerSelection.push(`${monitor.pid}-seqfl`);
2025-08-21 14:57:50 +02:00
this . layerSelection . push ( ` ${ monitor . pid } -seqfp ` ) ;
2025-08-21 15:21:01 +02:00
//this.layersPendingLoad.push(`${monitor.pid}-seqfl`);
2025-08-21 14:57:50 +02:00
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 >