Files
dougal-software/lib/www/client/source/src/views/MapLayersMixin.vue
2025-08-06 11:20:40 +02:00

618 lines
18 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

<script>
// Important info about performance:
// https://deck.gl/docs/developer-guide/performance#supply-attributes-directly
import { Deck, WebMercatorViewport, FlyToInterpolator, CompositeLayer } from '@deck.gl/core';
import { GeoJsonLayer, LineLayer, PathLayer, BitmapLayer, ScatterplotLayer, ColumnLayer, IconLayer } from '@deck.gl/layers';
import {HeatmapLayer} from '@deck.gl/aggregation-layers';
import { TileLayer, MVTLayer } from '@deck.gl/geo-layers';
//import { json } from 'd3-fetch';
import * as d3a from 'd3-array';
import { DougalBinaryBundle, DougalBinaryChunkSequential, DougalBinaryChunkInterleaved } from '@dougal/binary';
import { DougalShotLayer } from '@/lib/deck.gl';
import { DougalSequenceLayer, DougalEventsLayer } from '@/lib/deck.gl';
import DougalBinaryLoader from '@/lib/deck.gl/DougalBinaryLoader';
import { colors } from 'vuetify/lib'
function hexToArray (hex, defaultValue = [ 0xc0, 0xc0, 0xc0, 0xff ]) {
if (typeof hex != "string" || hex.length < 6) {
return defaultValue;
}
if (hex[0] == "#") {
hex = hex.slice(1); // remove the '#' character
}
return [
parseInt(hex.slice(0, 2), 16),
parseInt(hex.slice(2, 4), 16),
parseInt(hex.slice(4, 6), 16),
hex.length > 6 ? parseInt(hex.slice(6, 8), 16) : 255
];
}
function namedColourToArray (name) {
const parts = name.split(/\s+/).map( (s, i) =>
i
? s.replace("-", "")
: s.replace(/-([a-z])/g, (match, group1) => group1.toUpperCase())
);
parts[0]
if (parts.length == 1) parts[1] = "base";
const hex = parts.reduce((acc, key) => acc[key], colors);
return hexToArray(hex);
}
export default {
name: "MapLayersMixin",
data () {
return {
COLOUR_SCALE_1: [
// negative
[65, 182, 196],
[127, 205, 187],
[199, 233, 180],
[237, 248, 177],
// positive
[255, 255, 204],
[255, 237, 160],
[254, 217, 118],
[254, 178, 76],
[253, 141, 60],
[252, 78, 42],
[227, 26, 28],
[189, 0, 38],
[128, 0, 38]
]
};
},
methods: {
normalisedColourScale(v, scale = this.COLOUR_SCALE_1, min = 0, max = 1) {
const range = max-min;
const i = Math.min(scale.length, Math.max(Math.round((v-min) / range * scale.length), 0));
//console.log(`v=${v}, scale.length=${scale.length}, min=${min}, max=${max}, i=${i}, → ${scale[i]}`);
return scale[i];
},
osmLayer (options = {}) {
return new TileLayer({
id: "osm",
// https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Tile_servers
data: 'https://c.tile.openstreetmap.org/{z}/{x}/{y}.png',
minZoom: 0,
maxZoom: 19,
tileSize: 256,
renderSubLayers: props => {
const {
bbox: {west, south, east, north}
} = props.tile;
return new BitmapLayer(props, {
data: null,
image: props.data,
bounds: [west, south, east, north]
});
},
...options
})
},
// OSM tiles layer. Handy to make water transparent
// but not super reliable yet
osmVectorLayer (options = {}) {
return new MVTLayer({
id: 'osm',
data: 'https://vector.openstreetmap.org/shortbread_v1/{z}/{x}/{y}.mvt',
minZoom: 0,
maxZoom: 14,
getFillColor: feature => {
const layer = feature.properties.layerName;
//console.log("layer =", layer, feature.properties.kind);
switch (layer) {
case "ocean":
return [0, 0, 0, 0];
case "land":
return [ 0x54, 0x6E, 0x7A, 255 ];
default:
return [ 240, 240, 240, 255 ];
}
},
getLineColor: feature => {
if (feature.properties.layer === 'water') {
return [0, 0, 0, 0]; // No outline for water
}
return [192, 192, 192, 255]; // Default line color for roads, etc.
},
getLineWidth: feature => {
if (feature.properties.highway) {
return feature.properties.highway === 'motorway' ? 6 : 3; // Example road widths
}
return 1;
},
stroked: true,
filled: true,
pickable: true
});
},
openSeaMapLayer (options = {}) {
return new TileLayer({
id: "sea",
data: 'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png',
minZoom: 0,
maxZoom: 19,
tileSize: 256,
renderSubLayers: props => {
const {
bbox: {west, south, east, north}
} = props.tile;
return new BitmapLayer(props, {
data: null,
image: props.data,
bounds: [west, south, east, north]
});
},
...options
})
},
// Norwegian nautical charts
// As of 2025, not available for some weird reason
nauLayer (options = {}) {
return new TileLayer({
id: "nau",
// https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Tile_servers
data: 'https://opencache.statkart.no/gatekeeper/gk/gk.open_gmaps?layers=sjokartraster&zoom={z}&x={x}&y={y}',
minZoom: 0,
maxZoom: 19,
tileSize: 256,
renderSubLayers: props => {
const {
bbox: {west, south, east, north}
} = props.tile;
return new BitmapLayer(props, {
data: null,
image: props.data,
bounds: [west, south, east, north]
});
},
...options
})
},
vesselTrackPointsLayer (options = {}) {
return new ScatterplotLayer({
id: 'navp',
data: `/api/navdata?limit=10000`,
getPosition: (d) => ([d.longitude, d.latitude]),
getRadius: d => (d.speed),
radiusScale: 1,
lineWidthMinPixels: 2,
getFillColor: d => d.guns
? d.lineStatus == "online"
? [0xaa, 0x00, 0xff] // Online
: [0xd5, 0x00, 0xf9] // Soft start or guns otherwise active
: [0xea, 0x80, 0xfc], // Offline, guns inactive
getLineColor: [127, 65, 90],
getColor: [ 255, 0, 0 ],
getPointRadius: 12,
radiusUnits: "pixels",
pointRadiusMinPixels: 4,
stroked: false,
filled: true,
pickable: true,
...options
})
},
vesselTrackLinesLayer (options = {}) {
return new LineLayer({
id: 'navl',
data: `/api/navdata?v=${Date.now()}`, // NOTE Not too sure about this
lineWidthMinPixels: 2,
getLineColor: (d) => d.properties.ntba ? [240, 248, 255, 200] : [85, 170, 255, 200],
getSourcePosition: (obj, i) => i.index < i.data?.length ? [i.data[i.index]?.longitude, i.data[i.index]?.latitude] : null,
getTargetPosition: (obj, i) => i.index < i.data?.length ? [i.data[i.index+1]?.longitude, i.data[i.index+1]?.latitude] : null,
getLineWidth: 3,
getPointRadius: 2,
radiusUnits: "pixels",
pointRadiusMinPixels: 2,
pickable: true,
...options
})
},
eventsLogLayer (options = {}) {
const labelColour = (d, i, t, c = [127, 65, 90]) => {
const label = d?.properties?.labels?.[0];
const colour = this.labels[label]?.view?.colour ?? "#cococo";
if (colour) {
if (colour[0] == "#") {
c = hexToArray(colour);
} else {
c = namedColourToArray(colour);
}
} else {
//return [127, 65, 90];
}
if (t != null) {
c[3] = t;
}
return c;
};
return new DougalEventsLayer({
id: 'log',
data: `/api/project/${this.$route.params.project}/event?mime=application/geo%2Bjson`,
lineWidthMinPixels: 2,
getPosition: d => d.geometry.coordinates,
jitter: 0.00015,
getElevation: d => Math.min(Math.max(d.properties.remarks?.length || 10, 10), 200),
getFillColor: (d, i) => labelColour(d, i, 200),
getLineColor: (d, i) => labelColour(d, i, 200),
radius: 0.001,
radiusScale: 1,
// This just won't work with radiusUnits = "pixels".
// See: https://grok.com/share/c2hhcmQtMw%3D%3D_16578be4-20fd-4000-a765-f082503d0495
radiusUnits: "pixels",
radiusMinPixels: 1.5,
radiusMaxPixels: 2.5,
pickable: true,
...options
})
},
preplotSaillinesLinesLayer (options = {}) {
return new GeoJsonLayer({
id: 'psll',
data: `/api/project/${this.$route.params.project}/gis/preplot/line?class=V&v=${this.lineTStamp?.valueOf()}`,
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
})
},
preplotLinesLayer (options = {}) {
return new GeoJsonLayer({
id: 'ppll',
data: `/api/project/${this.$route.params.project}/gis/preplot/line?v=${this.lineTStamp?.valueOf()}`,
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
})
},
plannedLinesLinesLayer (options = {}) {
return new PathLayer({
id: 'planl',
data: [...this.plannedSequences], // Create new array to trigger Deck.gl update
dataTransform: (sequences) => {
// Raise the data 10 m above ground so that it's visible over heatmaps, etc.
return sequences.map( seq => ({
...seq,
geometry: {
...seq.geometry,
coordinates: seq.geometry.coordinates.map( pos => [...pos, 10] )
}
}))
},
getPath: d => d.geometry.coordinates,
//getSourcePosition: d => d.geometry.coordinates[0],
//getTargetPosition: d => d.geometry.coordinates[1],
widthUnits: "meters",
widthMinPixels: 4,
getWidth: 25,
//getLineWidth: 10,
getColor: (d) => {
const k = (d?.azimuth??0)/360*255;
return [ k, 128, k, 200 ];
},
stroked: true,
pickable: true,
...options
});
},
rawSequencesLinesLayer (options = {}) {
return new GeoJsonLayer({
id: 'seqrl',
data: `/api/project/${this.$route.params.project}/gis/raw/line?v=${this.sequenceTStamp?.valueOf()}`,
lineWidthMinPixels: 1,
getLineColor: (d) => d.properties.ntbp ? [0xe6, 0x51, 0x00, 200] : [0xff, 0x98, 0x00, 200],
getLineWidth: 1,
getPointRadius: 2,
radiusUnits: "pixels",
pointRadiusMinPixels: 2,
pickable: true,
...options
})
},
finalSequencesLinesLayer (options = {}) {
return new GeoJsonLayer({
id: 'seqfl',
data: `/api/project/${this.$route.params.project}/gis/final/line?v=${this.sequenceTStamp?.valueOf()}`,
lineWidthMinPixels: 1,
getLineColor: (d) => d.properties.pending ? [0xa7, 0xff, 0xab, 200] : [0x00, 0x96, 0x88, 200],
getLineWidth: 1,
getPointRadius: 2,
radiusUnits: "pixels",
pointRadiusMinPixels: 2,
pickable: true,
...options
})
},
preplotSaillinesPointLayer (options = {}) {
return new DougalSequenceLayer({
id: 'pslp',
data: `/api/project/${this.$route.params.project}/line/sail`, // API endpoint returning binary data
loaders: [DougalBinaryLoader],
loadOptions: {
fetch: {
method: 'GET',
headers: { 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
});
},
preplotPointsLayer (options = {}) {
return new DougalSequenceLayer({
id: 'pplp',
data: `/api/project/${this.$route.params.project}/line/source`, // API endpoint returning binary data
loaders: [DougalBinaryLoader],
loadOptions: {
fetch: {
method: 'GET',
headers: { 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
});
},
plannedLinesPointsLayer (options = {}) {
},
rawSequencesPointsLayer (options = {}) {
return new DougalSequenceLayer({
id: 'seqrp',
data: `/api/project/${this.$route.params.project}/sequence?type=2`, // API endpoint returning binary data
loaders: [DougalBinaryLoader],
loadOptions: {
fetch: {
method: 'GET',
headers: { Accept: 'application/vnd.aaltronav.dougal+octet-stream' }
}
},
getRadius: 2,
getFillColor: [0, 120, 220, 200],
pickable: true,
...options
});
},
heatmapLayer(options = {}) {
const { positions, values } = this.sequenceBinaryData;
if (!positions?.length || !values?.length) {
console.warn('No valid data for heatmapLayer');
return new HeatmapLayer({
id: 'seqrh',
data: [],
...options
});
}
let weights, offset = 0, scaler = 1;
let colorDomain = null;
let aggregation = "MEAN";
let transform = (v) => v;
switch (this.heatmapValue) {
case "total_error":
weights = Float32Array.from(values[3], (ei, i) => {
const ej = values[4][i];
return Math.sqrt(ei * ei + ej * ej) / 10; // Euclidean distance in meters
});
colorDomain = [2, 20];
break;
case "delta_j":
weights = values[3];
scaler = 0.1;
colorDomain = [2, 20];
break;
case "delta_i":
weights = values[4];
scaler = 0.1;
colorDomain = [0.5, 5];
break;
case "delta_μ":
weights = values[5];
scaler = 0.1;
break;
case "delta_σ":
weights = values[6];
scaler = 0.1;
colorDomain = [ 0.1, 1.5 ];
break;
case "delta_R":
weights = values[7];
scaler = 0.1;
colorDomain = [ 0.5, 1.0 ];
break;
case "press_μ":
weights = values[8];
offset = -2000;
colorDomain = [ 5, 50 ];
break;
case "press_σ":
weights = values[9];
colorDomain = [ 1, 19 ];
break;
case "press_R":
weights = values[10];
colorDomain = [ 3, 50 ];
break;
case "depth_μ":
weights = values[11];
offset = -6;
scaler = 0.1;
colorDomain = [ 0.1, 1 ];
break;
case "depth_σ":
weights = values[12];
scaler = 0.1;
break;
case "depth_R":
weights = values[13];
scaler = 0.1;
break;
case "fill_μ":
weights = values[14];
colorDomain = [ 300, 1000 ];
break;
case "fill_σ":
weights = values[15];
offset = -250;
colorDomain = [ 0, 250 ];
break;
case "fill_R":
weights = values[16];
offset = -500;
colorDomain = [ 0, 500 ];
break;
case "delay_μ":
weights = values[17];
offset = -150;
colorDomain = [ 1.5, 25 ];
//transform = (v) => {console.log("τ(v)", v); return v;};
break;
case "delay_σ":
weights = values[18];
break;
case "delay_R":
weights = values[19];
break;
case "no_fire":
weights = values[20];
transform = v => v >> 4;
aggregation = "SUM";
colorDomain = [ 0.1, 1.5 ];
break;
case "autofire":
weights = values[20];
transform = v => v & 0xf;
aggregation = "SUM";
colorDomain = [ 0.5, 1.5 ];
break;
case "misfire":
weights = values[20];
aggregation = "SUM";
colorDomain = [ 0.5, 1.5 ];
break;
}
const stats = {
min: d3a.min(weights),
mode: d3a.mode(weights),
mean: d3a.mean(weights),
max: d3a.max(weights),
sd: d3a.deviation(weights),
};
const sr0 = [ stats.mean - 2.1*stats.sd, stats.mean + 2.1*stats.sd ];
const sr1 = [ stats.mode - 2.1*stats.sd, stats.mode + 2.1*stats.sd ];
console.log('Positions sample:', positions.slice(0, 10));
console.log('Weights sample:', weights.slice(0, 10));
console.log("Mode:", this.heatmapValue);
console.log('Weight stats:', stats);
console.log("Suggested ranges");
console.log(sr0);
console.log(sr1);
console.log("Actual ranges");
console.log(colorDomain);
return new HeatmapLayer({
id: 'seqrh',
data: {
length: weights.length,
positions,
weights
/*
attributes: {
getPosition: { value: positions, type: 'float32', size: 2 },
getWeight: { value: weights, type: 'float32', size: 1 }
}
*/
},
getPosition: (d, {index, data}) => [ data.positions[index*2], data.positions[index*2+1] ],
getWeight: (d, {index, data}) => transform(Math.abs(data.weights[index] * scaler + offset)),
colorDomain,
radiusPixels: 25,
aggregation,
pickable: false,
...options
});
},
},
}
</script>