mirror of
https://gitlab.com/wgp/dougal/software.git
synced 2025-12-06 10:27:09 +00:00
If the location URL contains a hash of either: * #z/x/y * #x/y In the first case it will zoom and pan to the location; in the second case it will only pan while maintaining the current (or last used) zoom level. If the location URL does not contain a hash in one of those formats, the marker will be removed from the map.
806 lines
24 KiB
Vue
806 lines
24 KiB
Vue
<template>
|
||
<div id="map">
|
||
</div>
|
||
</template>
|
||
|
||
<style lang="sass">
|
||
@import '../../node_modules/leaflet/dist/leaflet.css'
|
||
</style>
|
||
|
||
<style>
|
||
#map {
|
||
height: 100%;
|
||
background-color: #dae4f7;
|
||
}
|
||
|
||
.theme--dark #map {
|
||
background-color: #111;
|
||
}
|
||
|
||
.cluster {
|
||
border: 2px solid green;
|
||
border-radius: 6px;
|
||
}
|
||
</style>
|
||
|
||
<script>
|
||
import L from 'leaflet'
|
||
import 'leaflet-realtime'
|
||
import 'leaflet.markercluster'
|
||
import 'leaflet-arrowheads'
|
||
import { mapActions, mapGetters, mapState } from 'vuex';
|
||
import ftstamp from '@/lib/FormatTimestamp'
|
||
import zoomFitIcon from '@/assets/zoom-fit-best.svg'
|
||
import crosshairsIcon from '@/assets/crosshairs.svg'
|
||
import { markdown, markdownInline } from '@/lib/markdown';
|
||
|
||
var map;
|
||
|
||
const tileMaps = {
|
||
|
||
"No background": L.tileLayer("/nullmap"), // FIXME
|
||
|
||
"Norwegian nautical charts": L.tileLayer('https://opencache.statkart.no/gatekeeper/gk/gk.open_gmaps?layers=sjokartraster&zoom={z}&x={x}&y={y}', {
|
||
attribution: '<a href="http://www.kartverket.no/" target="_blank">Kartverket</a>'
|
||
}),
|
||
|
||
"GEBCO": L.tileLayer.wms("https://www.gebco.net/data_and_products/gebco_web_services/2019/mapserv?", {layers: "GEBCO_2019_Grid_2", attribution: "<a href='https://www.gebco.net/' target='_blank' title='General Bathymetric Chart of the Oceans'>GEBCO</a>"}),
|
||
|
||
"OpenStreetMap": L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||
}),
|
||
|
||
"MODIS Satellite": L.tileLayer('https://map1.vis.earthdata.nasa.gov/wmts-webmerc/MODIS_Terra_CorrectedReflectance_TrueColor/default/{time}/{tilematrixset}{maxZoom}/{z}/{y}/{x}.{format}', {
|
||
attribution: 'Imagery provided by services from the Global Imagery Browse Services (GIBS), operated by the NASA/GSFC/Earth Science Data and Information System (<a href="https://earthdata.nasa.gov">ESDIS</a>) with funding provided by NASA/HQ.',
|
||
bounds: [[-85.0511287776, -179.999999975], [85.0511287776, 179.999999975]],
|
||
minZoom: 1,
|
||
maxZoom: 9,
|
||
format: 'jpg',
|
||
time: '',
|
||
tilematrixset: 'GoogleMapsCompatible_Level'
|
||
})
|
||
|
||
};
|
||
|
||
const layers = {
|
||
"OpenSeaMap": L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', {
|
||
attribution: 'Map data: © <a href="http://www.openseamap.org">OpenSeaMap</a> contributors'
|
||
}),
|
||
|
||
"Preplots": L.geoJSON(null, {
|
||
pointToLayer (point, latlng) {
|
||
return L.circle(latlng, {
|
||
radius: 1,
|
||
color: "#3388ff",
|
||
stroke: false,
|
||
fillOpacity: 0.8
|
||
});
|
||
},
|
||
style (feature) {
|
||
return {
|
||
opacity: 0.5
|
||
}
|
||
},
|
||
onEachFeature (feature, layer) {
|
||
const popup = feature.geometry.type == "Point"
|
||
? `Preplot<br/>Point <b>${feature.properties.line} / ${feature.properties.point}</b>`
|
||
: `Preplot<br/>Line <b>${feature.properties.line}</b>`;
|
||
layer.bindTooltip(popup, {sticky: true});
|
||
},
|
||
}),
|
||
|
||
"Plan": L.geoJSON(null, {
|
||
arrowheads: {
|
||
size: "8px",
|
||
frequency: "200px"
|
||
},
|
||
style (feature) {
|
||
return {
|
||
color: "brown",
|
||
opacity: 0.7
|
||
}
|
||
},
|
||
onEachFeature (feature, layer) {
|
||
const p = feature.properties;
|
||
if (feature.geometry) {
|
||
|
||
const d = p.duration;
|
||
const duration = `${d.days? d.days+" days ":""}${String(d.hours||0).padStart(2, "0")}:${String(d.minutes||0).padStart(2, "0")}`;
|
||
|
||
const speed = (p.length / (new Date(p.ts1) - new Date(p.ts0))) * 3.6/1.852 * 1000;
|
||
|
||
const remarks = p.remarks
|
||
? "<hr/>"+markdownInline(p.remarks)
|
||
: "";
|
||
|
||
const popup = `Planned sequence <b>${p.sequence}</b><br/>
|
||
Line <b>${p.line}</b> – ${p.name}<br/>
|
||
${p.num_points} points<br/>
|
||
${Math.round(p.length)} m ${p.azimuth.toFixed(1)}°<br/>
|
||
${duration} @ ${speed.toFixed(1)} kt<br/>
|
||
<table><tr><td><b>${p.fsp}</b></td><td>@ ${ftstamp(p.ts0)}</td></tr><tr><td><b>${p.lsp}</b></td><td>@ ${ftstamp(p.ts1)}</td></tr></table>${remarks}`;
|
||
layer.bindPopup(popup);
|
||
layer.bindTooltip(popup, {sticky: true});
|
||
}
|
||
}
|
||
}),
|
||
|
||
"Raw lines": L.geoJSON(null, {
|
||
pointToLayer (point, latlng) {
|
||
return L.circle(latlng, {
|
||
radius: 3,
|
||
color: "red",
|
||
stroke: false,
|
||
fillOpacity: 0.8
|
||
});
|
||
},
|
||
style (feature) {
|
||
return {
|
||
color: "red"
|
||
}
|
||
},
|
||
onEachFeature (feature, layer) {
|
||
const p = feature.properties;
|
||
if (feature.geometry) {
|
||
|
||
const ntbp = p.ntbp
|
||
? " <b>(NTBP)</b>"
|
||
: "";
|
||
|
||
const remarks = p.remarks
|
||
? "<hr/>"+markdown(p.remarks)
|
||
: "";
|
||
|
||
const popup = feature.geometry.type == "Point"
|
||
? `Raw sequence ${feature.properties.sequence}${ntbp}<br/>Point <b>${feature.properties.line} / ${feature.properties.point}</b><br/>${feature.properties.objref}<br/>${feature.properties.tstamp}`
|
||
: `Raw sequence ${p.sequence}${ntbp}<br/>
|
||
Line <b>${p.line}</b><br/>
|
||
${p.num_points} points${p.missing_shots ? " <i>("+p.missing_shots+" missing)</i>" : ""}<br/>
|
||
${Math.round(p.length)} m ${p.azimuth.toFixed(1)}°<br/>
|
||
${p.duration}<br/>
|
||
<table><tr><td><b>${p.fsp}</b></td><td>@ ${ftstamp(p.ts0)}</td></tr><tr><td><b>${p.lsp}</b></td><td>@ ${ftstamp(p.ts1)}</td></tr></table>${remarks}`;
|
||
layer.bindTooltip(popup, {sticky: true});
|
||
|
||
}
|
||
}
|
||
}),
|
||
|
||
"Final lines": L.geoJSON(null, {
|
||
pointToLayer (point, latlng) {
|
||
return L.circle(latlng, {
|
||
radius: 3,
|
||
color: "orange",
|
||
stroke: false,
|
||
fillOpacity: 0.8
|
||
});
|
||
},
|
||
style (feature) {
|
||
return {
|
||
color: "orange"
|
||
}
|
||
},
|
||
onEachFeature (feature, layer) {
|
||
const p = feature.properties;
|
||
|
||
const remarks = p.remarks
|
||
? "<hr/>"+markdown(p.remarks)
|
||
: "";
|
||
|
||
const popup = feature.geometry.type == "Point"
|
||
? `Final sequence ${p.sequence}<br/>Point <b>${p.line} / ${p.point}</b><br/>${p.objref}<br/>${p.tstamp}`
|
||
: `Final sequence ${p.sequence}<br/>
|
||
Line <b>${p.line}</b><br/>
|
||
${p.num_points} points${p.missing_shots ? " <i>("+p.missing_shots+" missing)</i>" : ""}<br/>
|
||
${Math.round(p.length)} m ${p.azimuth.toFixed(1)}°<br/>
|
||
${p.duration}<br/>
|
||
<table><tr><td><b>${p.fsp}</b></td><td>@ ${ftstamp(p.ts0)}</td></tr><tr><td><b>${p.lsp}</b></td><td>@ ${ftstamp(p.ts1)}</td></tr></table>${remarks}`;
|
||
// : `Final sequence ${feature.properties.sequence}<br/>Line <b>${feature.properties.line}</b><br/>${feature.properties.num_points} points<br/>From ${feature.properties.ts0}<br/>until ${feature.properties.ts0}`;
|
||
layer.bindTooltip(popup, {sticky: true});
|
||
}
|
||
}),
|
||
|
||
"Events (QC)": L.geoJSON(null),
|
||
|
||
"Events (Other)": L.geoJSON(null),
|
||
|
||
"Real-time": L.realtime({
|
||
url: '/api/navdata/gis/point?limit=1',
|
||
type: 'json',
|
||
}, {
|
||
start: false,
|
||
getFeatureId (feature) {
|
||
return feature.properties.vesselName || feature.properties.vesselId;
|
||
},
|
||
pointToLayer (point, latlng) {
|
||
return L.circleMarker(latlng, {
|
||
radius: 10,
|
||
color: "magenta",
|
||
stroke: false,
|
||
fillOpacity: 0.8
|
||
});
|
||
},
|
||
style (feature) {
|
||
return {
|
||
color: "magenta",
|
||
opacity: 0.5
|
||
}
|
||
},
|
||
onEachFeature (feature, layer) {
|
||
layer.bindPopup(function () {
|
||
return makeRealTimePopup(feature);
|
||
});
|
||
}
|
||
}),
|
||
|
||
"Real-time (trail)": L.realtime({
|
||
url: '/api/navdata/gis/line?limit=10000',
|
||
type: 'json',
|
||
}, {
|
||
start: false,
|
||
interval: 60 * 1000,
|
||
style (feature) {
|
||
return {
|
||
color: "magenta",
|
||
opacity: 0.5
|
||
}
|
||
}
|
||
})
|
||
};
|
||
|
||
layers["Real-time"].on('update', function (e) {
|
||
Object.keys(e.features).forEach( (id) => {
|
||
const feature = e.features[id];
|
||
this.getLayer(id).bindPopup(makeRealTimePopup(feature));
|
||
});
|
||
}, this);
|
||
|
||
layers["Real-time"].on('add', function (e) {
|
||
this.start();
|
||
});
|
||
|
||
layers["Real-time"].on('remove', function (e) {
|
||
this.stop();
|
||
});
|
||
|
||
layers["Real-time (trail)"].on('add', function (e) {
|
||
this.start();
|
||
});
|
||
|
||
layers["Real-time (trail)"].on('remove', function (e) {
|
||
this.stop();
|
||
});
|
||
|
||
function makeRealTimePopup(feature) {
|
||
const p = feature.properties;
|
||
const online = p._online
|
||
? `
|
||
<table>
|
||
<tr><td><b>Line name:</b></td><td>${p.lineName}</td></tr>
|
||
<tr><td><b>Sequence:</b></td><td>${p._sequence}</td></tr>
|
||
<tr><td><b>Line:</b></td><td>${p._line}</td></tr>
|
||
<tr><td><b>Shot:</b></td><td>${p._point}</td></tr>
|
||
<tr><td><b>Crossline:</b></td><td>${p.crossline || "???"} m</td></tr>
|
||
<tr><td><b>Inline:</b></td><td>${p.inline || "???"} m</td></tr>
|
||
<tr><td><b>Source fired:</b></td><td>${p.src_number|| "???"}</td></tr>
|
||
<tr><td><b>Manifold press.:</b></td><td>${p.manifold|| "???"} psi</td></tr>
|
||
</table>
|
||
`
|
||
: "";
|
||
const wgs84 = `${feature.geometry.coordinates[1].toFixed(6)}, ${feature.geometry.coordinates[0].toFixed(6)}`
|
||
const popup = `
|
||
Position as of ${p.tstamp}<br/><hr/>
|
||
${online}
|
||
<table>
|
||
<tr><td><b>Speed:</b></td><td>${p.speed ? p.speed*3.6/1.852 : "???"} kt</td></tr>
|
||
<tr><td><b>CMG:</b></td><td>${p.cmg || "???"}°</td></tr>
|
||
<tr><td><b>Water depth:</b></td><td>${p.waterDepth || "???"} m</td></tr>
|
||
<tr><td><b>WGS84:</b></td><td>${wgs84}</td></tr>
|
||
<tr><td><b>Local grid:</b></td><td>${p.easting.toFixed(1)}, ${p.northing.toFixed(1)}</td></tr>
|
||
</table>
|
||
`
|
||
return popup;
|
||
}
|
||
|
||
|
||
export default {
|
||
name: "Map",
|
||
|
||
data () {
|
||
return {
|
||
//map: null,
|
||
requestsCount: 0,
|
||
layerRefreshConfig: [
|
||
{
|
||
layer: layers.Preplots,
|
||
url: (query = "") => {
|
||
return map.getZoom() < 18
|
||
? `/project/${this.$route.params.project}/gis/preplot/line`
|
||
: `/project/${this.$route.params.project}/gis/preplot/point?${query.toString()}`;
|
||
}
|
||
},
|
||
{
|
||
layer: layers.Plan,
|
||
url: (query = "") => {
|
||
return `/project/${this.$route.params.project}/plan`;
|
||
}
|
||
},
|
||
{
|
||
layer: layers["Raw lines"],
|
||
url: (query = "") => {
|
||
return map.getZoom() < 17
|
||
? `/project/${this.$route.params.project}/gis/raw/line`
|
||
: `/project/${this.$route.params.project}/gis/raw/point?${query.toString()}`;
|
||
}
|
||
},
|
||
{
|
||
layer: layers["Final lines"],
|
||
url: (query = "") => {
|
||
return map.getZoom() < 17
|
||
? `/project/${this.$route.params.project}/gis/final/line`
|
||
: `/project/${this.$route.params.project}/gis/final/point?${query.toString()}`;
|
||
}
|
||
}
|
||
],
|
||
hashMarker: null
|
||
};
|
||
},
|
||
|
||
computed: {
|
||
...mapGetters(['user', 'loading', 'serverEvent', 'lineName', 'serverEvent']),
|
||
...mapState({projectSchema: state => state.project.projectSchema})
|
||
},
|
||
|
||
watch: {
|
||
loading (isLoading) {
|
||
const el = document.getElementById("loadingControl");
|
||
if (el) {
|
||
if (isLoading) {
|
||
el.classList.remove("d-none");
|
||
} else {
|
||
el.classList.add("d-none");
|
||
}
|
||
}
|
||
},
|
||
|
||
user (newVal, oldVal) {
|
||
if (newVal && (!oldVal || newVal.name != oldVal.name)) {
|
||
this.initView();
|
||
}
|
||
},
|
||
|
||
serverEvent (event) {
|
||
if (event.channel == "realtime" && event.payload && event.payload.new) {
|
||
const rtLayer = layers["Real-time"];
|
||
if (rtLayer.isRunning()) {
|
||
const geojson = {
|
||
type: "Feature",
|
||
geometry: event.payload.new.geometry,
|
||
properties: event.payload.new.meta
|
||
};
|
||
rtLayer.update(geojson);
|
||
}
|
||
} else if (event.channel == "event" && event.payload.schema == this.projectSchema) {
|
||
//console.log("EVENT", event);
|
||
}
|
||
},
|
||
|
||
$route (to, from) {
|
||
if (to.name == "map") {
|
||
this.setHashMarker();
|
||
}
|
||
}
|
||
},
|
||
|
||
methods: {
|
||
|
||
async fitProjectBounds () {
|
||
const res = await this.api([`/project/${this.$route.params.project}/gis`]);
|
||
const bbox = new L.GeoJSON(res);
|
||
map.fitBounds(bbox.getBounds());
|
||
},
|
||
|
||
getEvents (ffn = i => true) {
|
||
return async (success, error) => {
|
||
const url = `/project/${this.$route.params.project}/event`;
|
||
const data = await this.api([url, {headers: {"Accept": "application/geo+json"}}]);
|
||
if (data) {
|
||
|
||
function colour(feature) {
|
||
if (feature && feature.properties && feature.properties.type) {
|
||
if (feature.properties.type == "qc") {
|
||
return feature.properties.labels.includes("QCAccepted")
|
||
? "lightgray"
|
||
: "gray";
|
||
} else if (feature.properties.type == "midnight shot") {
|
||
return "cyan";
|
||
} else {
|
||
return "orange";
|
||
}
|
||
}
|
||
return "brown";
|
||
}
|
||
|
||
const features = data.filter(ffn).map(feature => {
|
||
feature.properties.colour = colour(feature);
|
||
return feature;
|
||
});
|
||
success(features);
|
||
} else {
|
||
error("Failed to get events");
|
||
}
|
||
}
|
||
},
|
||
|
||
async refreshLayers (layerset) {
|
||
|
||
const bounds = map.getBounds().pad(0.3);
|
||
const bboxScale = map.getZoom()/5;
|
||
const bbox = [
|
||
bounds._southWest.lng,
|
||
bounds._southWest.lat,
|
||
bounds._northEast.lng,
|
||
bounds._northEast.lat
|
||
].map(i => i.toFixed(bboxScale)).join(",");
|
||
const limit = 10000;
|
||
|
||
const query = new URLSearchParams({bbox, limit});
|
||
|
||
for (const l of this.layerRefreshConfig.filter(i => !layerset || layerset.includes(i.layer))) {
|
||
if (map.hasLayer(l.layer)) {
|
||
|
||
const url = l.url(query);
|
||
// Skip unnecessary requests
|
||
if (url == l.layer.lastRequestURL) continue;
|
||
|
||
if (l.layer.abort && l.layer.abort instanceof AbortController) {
|
||
l.layer.abort.abort();
|
||
}
|
||
|
||
l.layer.abort = new AbortController();
|
||
const signal = l.layer.abort.signal;
|
||
const init = {
|
||
signal,
|
||
headers: {
|
||
Accept: "application/geo+json"
|
||
}
|
||
};
|
||
|
||
// Firing all refresh events asynchronously, which is OK provided
|
||
// we don't have hundreds of layers to be refreshed.
|
||
this.api([url, init])
|
||
.then( (layer) => {
|
||
if (!layer) {
|
||
return;
|
||
}
|
||
|
||
if (typeof l.transform == 'function') {
|
||
layer = l.transform(layer);
|
||
}
|
||
|
||
l.layer.clearLayers();
|
||
if (layer instanceof L.Layer || (layer.features && layer.features.length < limit) || ("length" in layer && layer.length < limit)) {
|
||
if (l.layer.addData) {
|
||
l.layer.addData(layer);
|
||
} else if (l.layer.addLayer) {
|
||
l.layer.addLayer(layer);
|
||
}
|
||
|
||
l.layer.lastRequestURL = url;
|
||
} else {
|
||
console.warn("Too much data from", url);
|
||
}
|
||
})
|
||
.finally( () => {
|
||
delete l.layer.abort;
|
||
});
|
||
}
|
||
}
|
||
},
|
||
|
||
updateURL (includeLayers = true) {
|
||
const { lat, lng } = map.getCenter();
|
||
const zoom = map.getZoom();
|
||
const o = [];
|
||
const l = [];
|
||
let value;
|
||
if (includeLayers) {
|
||
for (const overlay of Object.keys(tileMaps)) {
|
||
if (map.hasLayer(tileMaps[overlay])) {
|
||
o.push(overlay);
|
||
}
|
||
}
|
||
for (const layer of Object.keys(layers)) {
|
||
if (map.hasLayer(layers[layer])) {
|
||
l.push(layer);
|
||
}
|
||
}
|
||
value = `${zoom}/${lat}/${lng}:${o.join(";")}:${l.join(";")}`;
|
||
} else {
|
||
value = `${zoom}/${lat}/${lng}`;
|
||
}
|
||
|
||
if (value) {
|
||
localStorage.setItem(`dougal/prefs/${this.user?.name}/${this.$route.params.project}/${this.$options.name}/view`, value);
|
||
}
|
||
},
|
||
|
||
decodeURL () {
|
||
const value = localStorage.getItem(`dougal/prefs/${this.user?.name}/${this.$route.params.project}/${this.$options.name}/view`);
|
||
|
||
if (!value) {
|
||
return {};
|
||
}
|
||
|
||
const parts = value.split(":");
|
||
const activeOverlays = parts.length > 1 && parts[1].split(";");
|
||
const activeLayers = parts.length > 2 && parts[2].split(";");
|
||
let position = parts && parts[0].split("/").map(i => Number(i));
|
||
if (position.length != 3) {
|
||
position = false;
|
||
}
|
||
|
||
return {position, activeOverlays, activeLayers};
|
||
},
|
||
|
||
initView () {
|
||
if (!map) {
|
||
return;
|
||
}
|
||
|
||
map.off('overlayadd', this.updateURL);
|
||
map.off('overlayremove', this.updateURL);
|
||
map.off('layeradd', this.updateURL);
|
||
map.off('layerremove', this.updateURL);
|
||
|
||
const init = this.decodeURL();
|
||
|
||
if (init.activeOverlays) {
|
||
Object.keys(tileMaps).forEach(k => {
|
||
const l = tileMaps[k];
|
||
if (init.activeOverlays.includes(k)) {
|
||
if (!map.hasLayer(l)) {
|
||
l.addTo(map);
|
||
}
|
||
} else {
|
||
map.removeLayer(l);
|
||
}
|
||
});
|
||
} else {
|
||
tileMaps["No background"].addTo(map);
|
||
}
|
||
|
||
if (init.activeLayers) {
|
||
Object.keys(layers).forEach(k => {
|
||
const l = layers[k];
|
||
if (init.activeLayers.includes(k)) {
|
||
if (!map.hasLayer(l)) {
|
||
l.addTo(map);
|
||
}
|
||
} else {
|
||
map.removeLayer(l);
|
||
}
|
||
});
|
||
} else {
|
||
layers.OpenSeaMap.addTo(map);
|
||
layers.Preplots.addTo(map);
|
||
}
|
||
|
||
if (init.position) {
|
||
map.setView(init.position.slice(1), init.position[0]);
|
||
}
|
||
|
||
map.on('overlayadd', this.updateURL);
|
||
map.on('overlayremove', this.updateURL);
|
||
map.on('layeradd', this.updateURL);
|
||
map.on('layerremove', this.updateURL);
|
||
|
||
},
|
||
|
||
setHashMarker () {
|
||
|
||
const crosshairsMarkerIcon = L.divIcon({
|
||
iconSize: [20, 20],
|
||
iconAnchor: [10, 10],
|
||
className: 'svgmarker',
|
||
html: `
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||
<path style="fill:inherit;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 updateMarker = (latlng) => {
|
||
if (this.hashMarker) {
|
||
if (latlng) {
|
||
this.hashMarker.setLatLng(latlng);
|
||
} else {
|
||
map.removeLayer(this.hashMarker);
|
||
this.hashMarker = null;
|
||
}
|
||
} else if (latlng) {
|
||
this.hashMarker = L.marker(latlng, {icon: crosshairsMarkerIcon, interactive: false});
|
||
this.hashMarker.addTo(map).getElement().style.fill = "fuchsia";
|
||
}
|
||
}
|
||
|
||
const parts = document.location.hash.substring(1).split(":")[0].split("/").map(p => decodeURIComponent(p));
|
||
if (parts.length == 3) {
|
||
setTimeout(() => map.setView(parts.slice(1).reverse(), parts[0]), 500);
|
||
updateMarker(parts.slice(1).reverse());
|
||
} else if (parts.length == 2) {
|
||
parts.reverse();
|
||
setTimeout(() => map.panTo(parts), 500);
|
||
updateMarker(parts);
|
||
} else {
|
||
updateMarker();
|
||
}
|
||
},
|
||
|
||
...mapActions(["api"])
|
||
|
||
},
|
||
|
||
mounted () {
|
||
map = L.map('map', {maxZoom: 22});
|
||
|
||
const eventsOptions = () => {
|
||
return {
|
||
start: false,
|
||
container: L.markerClusterGroup({maxClusterRadius: 1, className: "cluster"}),
|
||
getFeatureId (feature) {
|
||
return feature.properties.id;
|
||
},
|
||
pointToLayer (point, latlng) {
|
||
return L.circleMarker(latlng, {
|
||
radius: 6,
|
||
color: point.properties.colour || "gray",
|
||
stroke: false,
|
||
fillOpacity: 0.6
|
||
});
|
||
},
|
||
onEachFeature (feature, layer) {
|
||
const p = feature.properties;
|
||
const popup = (p.sequence
|
||
? `Event @ ${p.tstamp}<br/>Sequence ${p.sequence}<br/>Point <b>${p.line} / ${p.point}</b><br/><hr/>${markdownInline(p.remarks)}`
|
||
: `Event @ ${p.tstamp}<br/><hr/>${markdownInline(p.remarks)}`)
|
||
+ (p.labels.length? `<br/>[<i>${p.labels.join(", ")}</i>]` : "");
|
||
layer.bindTooltip(popup, {sticky: true});
|
||
}
|
||
}
|
||
};
|
||
|
||
layers["Events (QC)"] = L.realtime(this.getEvents(i => i.properties.type == "qc"), eventsOptions());
|
||
layers["Events (Other)"] = L.realtime(this.getEvents(i => i.properties.type != "qc"), eventsOptions());
|
||
|
||
layers["Events (Other)"].on('update', function (e) {
|
||
//console.log("Events (Other) update event", e);
|
||
});
|
||
|
||
layers["Events (QC)"].on('add', function (e) {
|
||
//console.log("Events (QC) add event", e);
|
||
e.target._src(data => e.target.update(data), err => console.error)
|
||
});
|
||
|
||
layers["Events (QC)"].on('remove', function (e) {
|
||
//console.log("Events (QC) remove event", e);
|
||
});
|
||
|
||
layers["Events (Other)"].on('add', function (e) {
|
||
//console.log("Events (Other) add event", e);
|
||
e.target._src(data => e.target.update(data), err => console.error)
|
||
});
|
||
|
||
layers["Events (Other)"].on('remove', function (e) {
|
||
//console.log("Events (Other) remove event", e);
|
||
});
|
||
|
||
|
||
const init = this.decodeURL();
|
||
|
||
if (init.activeOverlays) {
|
||
init.activeOverlays.forEach(o => tileMaps[o].addTo(map));
|
||
} else {
|
||
tileMaps["No background"].addTo(map);
|
||
}
|
||
|
||
if (init.activeLayers) {
|
||
init.activeLayers.forEach(l => layers[l].addTo(map));
|
||
} else {
|
||
layers.OpenSeaMap.addTo(map);
|
||
layers.Preplots.addTo(map);
|
||
}
|
||
|
||
const layerControl = L.control.layers(tileMaps, layers).addTo(map);
|
||
const scaleControl = L.control.scale().addTo(map);
|
||
|
||
if (init.position) {
|
||
map.setView(init.position.slice(1), init.position[0]);
|
||
} else {
|
||
map.setView([0, 0], 3);
|
||
}
|
||
|
||
let moveStart = map.getBounds().pad(0.3);
|
||
let zoomStart = map.getZoom();
|
||
|
||
map.on('movestart', () => {
|
||
moveStart = map.getBounds().pad(0.3);
|
||
zoomStart = map.getZoom();
|
||
});
|
||
|
||
map.on('moveend', () => {
|
||
if (!moveStart.contains(map.getBounds()) || map.getZoom() != zoomStart) {
|
||
this.refreshLayers();
|
||
}
|
||
|
||
this.updateURL();
|
||
});
|
||
|
||
map.on('overlayadd', this.updateURL);
|
||
map.on('overlayremove', this.updateURL);
|
||
map.on('layeradd', this.updateURL);
|
||
map.on('layerremove', this.updateURL);
|
||
|
||
this.layerRefreshConfig.forEach( l => {
|
||
l.layer.on('add', ({target}) => this.refreshLayers([target]));
|
||
});
|
||
|
||
if (init.position) {
|
||
this.refreshLayers();
|
||
} else {
|
||
setTimeout(() => {
|
||
if(!this.decodeURL().position) {
|
||
this.fitProjectBounds();
|
||
}
|
||
}, 1000);
|
||
}
|
||
|
||
// /usr/share/icons/breeze/actions/16/zoom-fit-best.svg
|
||
const fitProjectBounds = this.fitProjectBounds;
|
||
const FitToBoundsControl = L.Control.extend({
|
||
onAdd (map) {
|
||
const widget = L.DomUtil.create('div');
|
||
widget.className = "leaflet-touch leaflet-bar leaflet-control";
|
||
//widget.appendChild(document.getElementById("zoom-fit-best-icon"));
|
||
widget.innerHTML = `<a href="#"><img src="${zoomFitIcon}""></a>`;
|
||
widget.setAttribute("title", "Fit to bounds");
|
||
widget.addEventListener("click", fitProjectBounds);
|
||
return widget;
|
||
},
|
||
|
||
onRemove (map) {
|
||
this.removeEventListener("click", fitProjectBounds);
|
||
}
|
||
});
|
||
|
||
function fitToBoundsControl (opts) {
|
||
return new FitToBoundsControl(opts);
|
||
}
|
||
|
||
fitToBoundsControl({position: "topleft"}).addTo(map);
|
||
|
||
const LoadingControl = L.Control.extend({
|
||
onAdd (map) {
|
||
const widget = L.DomUtil.create('div');
|
||
widget.className = "leaflet-touch leaflet-bar leaflet-control d-none";
|
||
//widget.appendChild(document.getElementById("zoom-fit-best-icon"));
|
||
widget.innerHTML = `Loading…`;
|
||
widget.setAttribute("id", "loadingControl");
|
||
return widget;
|
||
},
|
||
|
||
onRemove (map) {
|
||
}
|
||
});
|
||
|
||
(new LoadingControl({position: "bottomright"})).addTo(map);
|
||
|
||
// Decode a position if one given in the hash
|
||
this.setHashMarker();
|
||
}
|
||
|
||
}
|
||
|
||
</script>
|