ldc-volena/resources/ts/fo/components/LeafletMapVueComponent.vue
2026-04-24 15:25:52 +02:00

322 lines
9.1 KiB
Vue

<template>
<div
ref="maps"
:class="wrapperClasses.concat(' leafleat-wrapper-class')"
>
<div
ref="marker"
class="d-none marker-pin bg-white text-primary px-3 py-1 d-flex justify-content-center align-items-center f-barlow rounded-pill border border-1 border-primary"
>
<img
class="marker-logo mx-1"
src=""
alt=""
>
<span>
<span class="mx-1 marker-title fw-bold text-uppercase text-break" />
</span>
</div>
</div>
</template>
<script lang="ts">
import { DivIcon, Icon, Map, Marker, TileLayer, type LatLngExpression, type LatLngTuple } from "leaflet";
// @ts-ignore
import "overlapping-marker-spiderfier-leaflet";
import type { OverlappingMarkerSpiderfier } from "ts-overlapping-marker-spiderfier";
// @ts-ignore
import "leaflet.fullscreen";
import { defineComponent } from "vue";
import trans from "../../modules/trans";
type Site = {
name: string;
slug: string;
/** WYSIWYG text */
description: string;
employees: number;
coords: {
lat: number,
lng: number,
},
logo: {
src: string,
alt: string;
title: string;
},
divIcon: DivIcon,
pinIcon: DivIcon,
marker: Marker|null
};
export default defineComponent({
name: "LeafletMapVueComponent",
mixins: [trans],
data(): {
wrapperClasses: string[];
map: Map;
oms: OverlappingMarkerSpiderfier;
initialPosition: LatLngExpression | null;
initialZoom: number;
siteList: Array<Site>;
markerTextLength: number;
controlSliderId: string|null;
} {
return {
wrapperClasses:[],
map: {} as Map,
oms: {} as OverlappingMarkerSpiderfier,
initialPosition: null,
initialZoom: 9,
siteList: [],
markerTextLength: 20,
controlSliderId: null
};
},
computed: {},
mounted() {
const json = String(this.$attrs.json ?? "{}"),
data = JSON.parse(json);
this.wrapperClasses = data.wrapperClasses ?? [];
if(data.coordinates) {
this.initialPosition = [
Number.parseFloat(data.coordinates.lat),
Number.parseFloat(data.coordinates.lng)
];
}
if(data.initialZoom) {
this.initialZoom = data.initialZoom;
}
if(data.controlSliderId) {
this.controlSliderId = String(data.controlSliderId);
}
this.parseSiteModelList(data.siteModelList);
this.$nextTick(() => {
this.displayMap();
this.setupMarkers();
});
},
methods: {
displayMap() {
const mapWrapper = this.$refs.maps as HTMLDivElement;
this.map = new Map(mapWrapper, {
// @ts-ignore
fullscreenControl: true,
fullscreenControlOptions: {
position: "topleft"
}
});
(new TileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "&copy; <a href=\"http://www.openstreetmap.org/copyright\">OpenStreetMap</a>"
})).addTo(this.map as Map);
if(!this.initialPosition) {
// * Auto set viewbox
(this.map as Map).fitBounds(this.siteList.map(site => Object.values(site.coords)) as LatLngTuple[]);
} else {
(this.map as Map).setView(this.initialPosition, this.initialZoom);
}
// * Setup OMS
// @ts-ignore
this.oms = new OverlappingMarkerSpiderfier(this.map as Map, {
// spiderfy markers within this range of the one clicked, in px
nearbyDistance: 120,
// circleSpiralSwitchover: 250,
/**
* show spiral instead of circle from this marker count upwards
* 0 -> always spiral; Infinity -> always circle
*/
circleSpiralSwitchover: 12,
// related to size of spiral (experiment!)
circleFootSeparation: 80,
// related to size of spiral (experiment!)
circleStartAngle: (Math.PI * 2) / 24,
// ditto
spiralLengthStart: 130,
// ditto
spiralLengthFactor: 5,
});
this.oms.addListener("click", this.selectedMarker);
this.oms.addListener("spiderfy", this.clearSelectedMarkers);
this.map.on("zoomend", this.updateMarkers);
},
parseSiteModelList(siteModelList: LaravelModelList) {
const bodyFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
siteModelList.forEach((site) => {
this.siteList.push({
name: String(site.name),
slug: String(site.slug),
description: String(site.description),
employees: Number.parseInt(String(site.employees)),
coords: {
lat: Number.parseFloat(String(site.latitude)),
lng: Number.parseFloat(String(site.longitude)),
},
logo: {
src: String(site.logo),
alt: String(site.logo_alt),
title: String(site.logo_title),
},
marker: null,
divIcon: new DivIcon({
className: "leaf-div-icon",
html: this.fetchNewMarkerElement(
String(site.name),
{
src: String(site.logo),
alt: String(site.logo_alt),
title: String(site.logo_title),
}
),
iconSize: [10*bodyFontSize, 3*bodyFontSize],
}),
pinIcon: new DivIcon({
html: "<i class=\"fa-solid fa-location-dot\" style=\"font-size:24px; color: #103251;\"></i>",
className: "",
iconSize: [24, 18]
})
});
});
},
fetchNewMarkerElement(
title: string,
logo: {src: string,alt: string,title: string}
): HTMLDivElement {
const marker = (this.$refs.marker as HTMLDivElement).cloneNode(true) as HTMLDivElement;
const logoEl = marker.querySelector(".marker-logo") as HTMLImageElement|null;
marker.querySelector(".marker-title")!.textContent = this.truncateString(title, this.markerTextLength);
logoEl!.src = `/${logo.src}`;
logoEl!.alt = logo.alt;
logoEl!.title = logo.title;
marker.classList.remove("d-none");
return marker;
},
setupMarkers() {
this.siteList.forEach((site) => {
const mapZoom = this.map.getZoom();
const marker = new Marker(site.coords, {
title: site.name,
alt: site.name,
icon: mapZoom >= 8 ? site.pinIcon as Icon : site.divIcon as Icon
});
this.map.addLayer(marker);
// @ts-ignore
this.oms.addMarker(marker);
site.marker = marker;
});
},
updateMarkers() {
this.siteList.forEach((site) => {
let newMarker = null;
const mapZoom = this.map.getZoom();
if (!site.marker) {
throw new Error("Missing Marker");
}
if (mapZoom >= 12) {
newMarker = site.divIcon;
} else {
newMarker = site.pinIcon;
}
site.marker.setIcon(newMarker as Icon);
});
},
truncateString(str: string, limit: number, end: string = "..") {
if (str.length <= limit) {
return str;
}
return str.slice(0, limit) + end;
},
selectedMarker(marker: Marker) {
this.clearSelectedMarkers();
const siteIndex = this.siteList.findIndex(site => site!.marker!.options.title === marker.options.title);
if(siteIndex === -1) {
throw new Error("Could not fetch related Site");
}
const pin = marker.getElement()!.firstChild as HTMLElement | null;
if(!pin) {
throw new Error("Missing Marker Pin");
}
if (pin.tagName !== "svg") {
pin.classList.add("bg-primary", "text-white");
pin.classList.remove("bg-white", "text-primary");
}
if(this.controlSliderId) {
const slider = window.__SLIDERS.get(this.controlSliderId);
if(!slider) {
throw new Error(`Missing slider to control ${this.controlSliderId}`);
}
slider.swiper.slideTo(siteIndex);
}
},
clearSelectedMarkers() {
this.siteList.forEach(site => {
const pin = site.marker!.getElement()!.firstChild as HTMLElement | null;
if(!pin) {
throw new Error("Missing Marker Pin");
}
if (pin.tagName !== "svg") {
pin.classList.add("bg-white", "text-primary");
pin.classList.remove("bg-primary", "text-white");
}
});
}
},
});
</script>
<style>
@import "../../../../node_modules/leaflet.fullscreen/Control.FullScreen.css";
</style>
<style lang="scss" scoped>
@import "bootstrap/scss/functions";
@import "bootstrap/scss/variables";
@import "bootstrap/scss/variables-dark";
@import "bootstrap/scss/maps";
@import "bootstrap/scss/mixins";
@import "bootstrap/scss/vendor/rfs";
.leafleat-wrapper-class {
min-height: 30rem;
@include media-breakpoint-up(sm) {
min-height: 35rem;
}
@include media-breakpoint-up(md) {
min-height: 40rem;
}
iframe {
width: 100%;
height: 100%;
}
.marker-pin {
width: 10rem;
height: 3rem;
}
.marker-logo {
object-fit: contain;
height: auto;
width: 2rem;
}
.marker-title {
@include rfs(0.8rem, font-size);
overflow: hidden;
display: -webkit-box;
/* number of lines to show */
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
}
</style>