181 lines
4.4 KiB
Vue
181 lines
4.4 KiB
Vue
<script setup lang="ts">
|
|
import "leaflet/dist/leaflet.css";
|
|
import { onMounted, ref, watch } from "vue";
|
|
import { useConfigStore } from "@/stores/config";
|
|
import type { Coordinates } from "@/types";
|
|
import * as L from "leaflet";
|
|
import { parseToFloat } from "@/utils/Numbers";
|
|
import type { LatLngTuple } from "leaflet";
|
|
import { forConstraint } from "@/shared/validation/validator";
|
|
import CONSTRAINTS from "@/shared/validation/constraints";
|
|
|
|
const wrapper = ref<HTMLElement>();
|
|
const configStore = useConfigStore();
|
|
|
|
interface Props {
|
|
coordinates?: Coordinates;
|
|
editable?: boolean;
|
|
}
|
|
|
|
const props = defineProps<Props>();
|
|
const emit = defineEmits<{
|
|
(e: "coordinatesSelected", value: Coordinates): void;
|
|
}>();
|
|
|
|
let map: L.Map | null = null;
|
|
let marker: L.Marker | null = null;
|
|
|
|
onMounted(renderMap);
|
|
watch(props, updateMarker);
|
|
|
|
function getLayers(): {
|
|
layers: { [name: string]: L.Layer };
|
|
defaultLayers: L.Layer[];
|
|
} {
|
|
const layers: { [name: string]: L.Layer } = {};
|
|
const defaultLayers: L.Layer[] = [];
|
|
for (const [id, layerCfg] of Object.entries(
|
|
configStore.getConfig.coordsSelector.layers
|
|
)) {
|
|
const layerOptions = layerCfg.layerOptions;
|
|
|
|
const layer = L.tileLayer(layerCfg.url, {
|
|
id,
|
|
...layerOptions,
|
|
});
|
|
|
|
layers[layerCfg.name] = layer;
|
|
if (defaultLayers.length === 0) {
|
|
defaultLayers.push(layer);
|
|
}
|
|
}
|
|
return { layers, defaultLayers };
|
|
}
|
|
|
|
function getCoordinates(): LatLngTuple | undefined {
|
|
const coordinates = props.coordinates;
|
|
if (!coordinates) {
|
|
return undefined;
|
|
}
|
|
if (!forConstraint(CONSTRAINTS.node.coords, false)(coordinates)) {
|
|
return undefined;
|
|
}
|
|
const [lat, lng] = props.coordinates.split(" ").map(parseToFloat);
|
|
return [lat, lng];
|
|
}
|
|
|
|
function createMap(defaultLayers: L.Layer[], layers: { [p: string]: L.Layer }) {
|
|
const element = wrapper.value;
|
|
if (!element) {
|
|
throw new Error("Illegal state: Map wrapper element not set.");
|
|
}
|
|
|
|
const options: L.MapOptions = {
|
|
renderer: new L.Canvas(),
|
|
minZoom: 2,
|
|
crs: L.CRS.EPSG3857,
|
|
zoomControl: true,
|
|
worldCopyJump: true,
|
|
maxBounds: L.latLngBounds([90, -540.5], [-90, 540.5]),
|
|
attributionControl: true,
|
|
layers: defaultLayers,
|
|
};
|
|
map = L.map(element, options);
|
|
L.control.layers(layers).addTo(map);
|
|
|
|
if (props.editable) {
|
|
map.on("click", onClick);
|
|
}
|
|
}
|
|
|
|
function updateMarker() {
|
|
const coordinates = getCoordinates();
|
|
if (!coordinates) {
|
|
if (marker) {
|
|
marker.remove();
|
|
marker = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!map) {
|
|
console.error("Map is not initialized.");
|
|
return;
|
|
}
|
|
|
|
if (marker) {
|
|
marker.setLatLng(coordinates);
|
|
} else {
|
|
marker = L.marker(coordinates, {}).addTo(map);
|
|
}
|
|
|
|
if (!map.getBounds().contains(marker.getLatLng())) {
|
|
map.setView(marker.getLatLng(), map.getZoom());
|
|
}
|
|
}
|
|
|
|
function centerOnCoordinates() {
|
|
if (!map) {
|
|
console.error("Map is not initialized.");
|
|
return;
|
|
}
|
|
let { lat, lng, defaultZoom: zoom } = configStore.getConfig.coordsSelector;
|
|
|
|
const coordinates = getCoordinates();
|
|
|
|
if (coordinates) {
|
|
[lat, lng] = coordinates;
|
|
zoom = map.getMaxZoom() - 1;
|
|
}
|
|
map.setView([lat, lng], zoom);
|
|
}
|
|
|
|
function renderMap() {
|
|
const { layers, defaultLayers } = getLayers();
|
|
createMap(defaultLayers, layers);
|
|
if (
|
|
map &&
|
|
configStore.getConfig.otherCommunityInfo.showBorderForDebugging
|
|
) {
|
|
new L.Polygon(
|
|
configStore.getConfig.otherCommunityInfo.localCommunityPolygon
|
|
).addTo(map);
|
|
}
|
|
centerOnCoordinates();
|
|
updateMarker();
|
|
}
|
|
|
|
function onClick(event: L.LeafletMouseEvent) {
|
|
emit(
|
|
"coordinatesSelected",
|
|
`${event.latlng.lat} ${event.latlng.lng}` as Coordinates
|
|
);
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="map-container">
|
|
<div class="map" ref="wrapper"></div>
|
|
</div>
|
|
</template>
|
|
|
|
<style lang="scss">
|
|
@use "sass:math";
|
|
@import "../scss/variables";
|
|
.map-container {
|
|
position: relative;
|
|
|
|
width: 100%;
|
|
padding-top: math.percentage(math.div(1, $node-map-aspect-ratio));
|
|
|
|
.map {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
|
|
border-radius: $node-map-border-radius;
|
|
}
|
|
}
|
|
</style>
|