Further refactor Map component.

Layer and tooltip definitions have been split out into different
files as mixins.

Uses Dougal binary bundles.
This commit is contained in:
D. Berge
2025-08-01 17:18:16 +02:00
parent 17c6d9d1e5
commit 44113c89c0
4 changed files with 956 additions and 505 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,378 @@
<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, BitmapLayer, ScatterplotLayer, 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 from '@/lib/deck.gl/DougalSequenceLayer';
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 () {
},
computed: {
labels () {
return this.$store.state.label.labels;
}
},
methods: {
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 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`,
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) => {
const label = d?.properties?.labels?.[0];
const colour = this.labels[label]?.view?.colour ?? "#cococo";
if (colour) {
if (colour[0] == "#") {
return hexToArray(colour);
} else {
return namedColourToArray(colour);
}
} else {
return [127, 65, 90];
}
};
return new GeoJsonLayer({
id: 'log',
data: `/api/project/${this.$route.params.project}/event?mime=application/geo%2Bjson`,
lineWidthMinPixels: 2,
//getLineColor: [127, 65, 90],
getFillColor: (d, i) => labelColour(d, i),
getLineColor: (d, i) => labelColour(d, i),
getPointRadius: 2,
radiusUnits: "pixels",
pointRadiusMinPixels: 2,
pickable: true,
...options
})
},
preplotSaillinesLinesLayer (options = {}) {
return new GeoJsonLayer({
id: 'psll',
data: `/api/project/${this.$route.params.project}/gis/preplot/line?class=V`,
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`,
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 LineLayer({
id: 'plan',
data: `/api/project/${this.$route.params.project}/plan`,
widthMinPixels: 2,
widthUnits: "pixels",
getSourcePosition: d => d.geometry.coordinates[0],
getTargetPosition: d => d.geometry.coordinates[1],
getLineWidth: 8,
getColor: (d) => {
const k = d.azimuth/360*255;
return [ k, 128, k, 200 ];
},
pickable: true,
...options
})
},
rawSequencesLinesLayer (options = {}) {
return new GeoJsonLayer({
id: 'seqrl',
data: `/api/project/${this.$route.params.project}/gis/raw/line`,
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`,
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 = {}) {
},
preplotPointsLayer (options = {}) {
},
plannedLinesPointsLayer (options = {}) {
},
rawSequencesPointsLayer (options = {}) {
return new DougalSequenceLayer({
id: 'seqrp',
data: this.sequenceBinaryData,
getRadius: 2,
getFillColor: [0, 120, 220, 200],
pickable: true,
...options
});
},
rawSequencesPointsGunDataLayer (options = {}) {
return new DougalSequenceLayer({
id: 'seqrp',
data: this.sequenceBinaryData,
getRadius: (d, i) => i?.data?.attributes?.value9.value[i?.index],
//getRadius: (d, i) => {console.log(d, i); return 1;},
//getRadius: 2,
radiusMinPixels: 1,
//autoScale: true,
//getFillColor: gunPressureColours,
getFillColor: [0, 120, 220, 200],
pickable: true,
...options
});
},
},
}
</script>

View File

@@ -0,0 +1,201 @@
<script>
export default {
name: "MapTooltipsMixin",
data () {
return {
tooltipDefaultStyle: { "max-width": "50ex"}
}
},
methods: {
getTooltip (args) {
if (args?.layer?.constructor?.tooltip) {
return args.layer.constructor.tooltip(args);
} else if (args?.layer?.id == "seqrp" || args?.layer?.id == "seqfp") {
return this.sequencePointsTooltip(args);
} else if (args?.layer?.id == "log") {
return this.eventLogTooltip(args);
} else if (args?.layer?.id == "pplp" || args?.layer?.id == "pslp") {
return this.preplotPointsTooltip(args);
} else if (args?.layer?.id == "plan") {
return this.plannedLinesTooltip(args);
} else if (args?.layer?.id == "seqrl" || args?.layer?.id == "seqfl") {
return this.sequenceLinesTooltip(args);
} else if (args?.layer?.id == "navp") {
return this.vesselTrackPointsTooltip(args);
}
},
preplotPointsTooltip (args) {
const p = args?.object?.properties;
const isSailline = args?.layer?.id == "psl";
if (p) {
let html = `${isSailline ? "Sail" : "Preplot"} line ${p.line} (${p.incr ? "+" : "-"})`;
if (p.ntba) {
html += ` <b title="Not to be acquired">NTBA</a>`;
}
if (p.remarks) {
html += `<br/>\n${p.remarks}`;
}
const style = { "max-width": "50ex"};
return {html, style: this.tooltipDefaultStyle};
}
},
sequenceLinesTooltip (args) {
let type;
switch(args.layer.id) {
case "seqrl":
type = "Raw";
break;
case "seqfl":
type = "Final";
break;
}
const p = args?.object?.properties;
if (p) {
let html = `Sequence ${p.sequence} (${type})<br/>\n`;
html += `Line <b>${p.line}</b><br/>\n`;
html += `${p.num_points} points (${p.missing_shots ? (p.missing_shots + " missing") : "None missing"})<br/>\n`;
html+= `${(p.length??0).toFixed(0)} m ${(p.azimuth??0).toFixed(1)}°<br/>\n`;
html += `${p.duration}<br/>\n`;
html += `<b>${p.fsp}</b> @ ${(new Date(p.ts0))?.toISOString()}<br/>\n`;
html += `<b>${p.lsp}</b> @ ${(new Date(p.ts1))?.toISOString()}<br/>\n`;
if (p.ntbp) {
html += "<b>Not to be processed</b><br/>\n";
} else if (p.pending) {
html += "<b>Pending</b><br/>\n";
}
html += "<hr/><br/>\n";
html += this.$root.markdown(p.remarks);
return {html, style: this.tooltipDefaultStyle};
}
},
sequencePointsTooltip (args) {
const p = args?.object;
if (p) {
let html = "";
if (p.udv == 2) { // FIXME Must change this to something more meaningful
// This is a shot info record:
html += `S${p.i.toString().padStart(3, "0")} ${p.j}</br>\n`;
html += `<small>${new Date(Number(p.ts)).toISOString()}</small><br/>\n`;
html += `Δi: ${p.εj.toFixed(2)} m / Δj: ${p.εi.toFixed(2)} m<br/>\n`;
html += `<hr/>\n`;
if (p.nofire) {
html += `<b>No fire: ${p.nofire} guns</b><br/>\n`;
}
if (p.autofire) {
html += `<b>Autofire: ${p.autofire} guns</b><br/>\n`;
}
html += `P: ${p.press} psi ±${p.press_σ} psi (R=${p.press_R})<br/>\n`;
html += `Δ: ${p.delta} ms ±${p.delta_σ} ms (R=${p.delta_R})<br/>\n`;
html += `D: ${p.depth} m ±${p.depth_σ} m (R=${p.depth_R})<br/>\n`;
html += `F: ${p.fill} ms ±${p.fill_σ} ms (R=${p.fill_R})<br/>\n`;
html += `W: ${p.delay} ms ±${p.delay_σ} ms (R=${p.delay_R})<br/>\n`;
return {html, style: this.tooltipDefaultStyle};
}
}
console.log("no tooltip");
},
eventLogTooltip (args) {
const p = args?.object?.properties;
if (p) {
let html = "";
if (p.sequence && p.point) {
html += `S${p.sequence.toString().padStart(3, "0")} ${p.point}</br>\n`;
}
html += `<small>${p.tstamp}</small><br/>\n`;
html += `<span>${p.remarks}</span>`;
if (p.labels?.length) {
html += `</br>\n<small><i>${p.labels.join(", ")}</i></small>`;
}
return {html, style: this.tooltipDefaultStyle};
}
},
plannedLinesTooltip (args) {
const p = args?.object;
if (p) {
const duration = `${(p.duration?.hours??0).toString().padStart(2, "0")}:${(p.duration?.minutes??0).toString().padStart(2, "0")}`;
const Δt = ((new Date(p.ts1)).valueOf() - (new Date(p.ts0)).valueOf()) / 1000; // seconds
const speed = (p.length / Δt) * 3.6 / 1.852; // knots
let html = `Planned sequence <b>${p.sequence}</b></br>\n` +
`Line <b>${p.line}</b> ${p.name}</br>\n` +
`${p.num_points} Points</br>\n` +
`${p.length} m ${p.azimuth.toFixed(2)}°</br>\n` +
`${duration} @ ${speed.toFixed(1)} kt</br>\n` +
`<b>${p.fsp}</b> @ ${(new Date(p.ts0))?.toISOString()}</br>\n` +
`<b>${p.lsp}</b> @ ${(new Date(p.ts1))?.toISOString()}`;
if (p.remarks) {
html += `</br>\n<hr/>${p.remarks}`;
}
return {html, style: this.tooltipDefaultStyle};
}
},
vesselTrackPointsTooltip (args) {
const p = args.object;
if (p) {
let html = `${p.vesselName}<br/>\n`
+ `${p.tstamp}<br/>\n`
+ `BSP ${(p.speed??0).toFixed(1)} kt CMG ${(p.cmg??0).toFixed(1).padStart(5, "0")}° HDG ${(p.bearing??0).toFixed(1).padStart(5, "0")}° DPT ${(p.waterDepth??0).toFixed(1)} m<br/>\n`
+ `${p.lineStatus}<br/>\n`;
if (p.guns) {
console.log(p);
const pressure = p.guns.map( i => i[11] ); // 11 is gun pressure
const μpress = d3a.mean(pressure);
const σpress = d3a.deviation(pressure);
if (p.lineStatus && p.lineStatus != "offline") {
html += `${p.lineName}<br/>\n`
+ `S: ${p._sequence} L: ${p._line} P: ${p.shot}<br/>`
+ `Source ${p.src_number} `
+ ((p.trg_mode && p.trg_mode != "external") ? `<b>${p.trg_mode.toUpperCase()} TRIGGER</b> ` : "")
+ `<small>FSID ${p.fsid}</small> <small>mask ${p.mask}</small><br/>\n`
+ `Δ ${(p.avg_delta??0).toFixed(3)} ms ±${(p.std_delta??0).toFixed(3)} ms<br/>\n`
+ `${(μpress??0).toFixed(0)} psi ±${(σpress??0).toFixed(0)} psi / ${(p.volume??0).toFixed(0)} in³<br/>\n`
+ `along ${(p.inline??0).toFixed(1)} m / across ${(p.crossline??0).toFixed(1)} m<br/>\n`;
} else {
// Soft start?
html +=
`Source ${p.src_number} `
+ ((p.trg_mode && p.trg_mode != "external") ? `<b>${p.trg_mode.toUpperCase()} TRIGGER</b> ` : "")
+ `<small>mask ${p.mask}</small><br/>\n`
+ `Δ ${(p.avg_delta??0).toFixed(3)} ms ±${(p.std_delta??0).toFixed(3)} ms<br/>\n`
+ `${(p.manifold??0).toFixed(0)} psi / ${(p.volume??0).toFixed(0)} in³<br/>\n`
+ `along ${(p.inline??0).toFixed(1)} m / across ${(p.crossline??0).toFixed(1)} m<br/>\n`;
}
}
return {html, style: this.tooltipDefaultStyle};
}
},
}
}
</script>