Files
dougal-software/lib/www/client/source/src/views/Map.vue
D. Berge 4d87506720 Show a map marker if position given in URL hash.
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.
2021-05-17 17:14:35 +02:00

806 lines
24 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.

<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: '&copy; <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: &copy; <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>