401 lines
10 KiB
Vue
401 lines
10 KiB
Vue
<template>
|
|
<div class="relative my-2">
|
|
<div style="height: 70vh" id="mapMountPoint" />
|
|
<vue-bottom-sheet
|
|
v-if="activeElement"
|
|
ref="myBottomSheet"
|
|
class="md:hidden"
|
|
max-height="70%"
|
|
:background-scrollable="false"
|
|
>
|
|
<event-card
|
|
v-if="instanceOfIEvent(activeElement)"
|
|
:event="(activeElement as IEvent)"
|
|
:has-border="false"
|
|
view-mode="column"
|
|
:options="{
|
|
isRemoteEvent: activeElement.__typename === 'EventResult',
|
|
isLoggedIn,
|
|
}"
|
|
/>
|
|
<group-card
|
|
v-else
|
|
:group="(activeElement as IGroup)"
|
|
:has-border="false"
|
|
view-mode="column"
|
|
:isRemoteGroup="activeElement.__typename === 'GroupResult'"
|
|
:isLoggedIn="isLoggedIn"
|
|
/>
|
|
</vue-bottom-sheet>
|
|
<div
|
|
class="absolute hidden md:block bottom-0 md:top-8 right-0 h-48 w-full md:w-80 overflow-y-visible text-white [box-shadow:0 6px 9px 2px rgba(119,119,119,.75)] -my-4 px-2 z-[1100]"
|
|
v-if="activeElement"
|
|
>
|
|
<event-card
|
|
v-if="instanceOfIEvent(activeElement)"
|
|
:event="(activeElement as IEvent)"
|
|
view-mode="column"
|
|
:has-border="false"
|
|
:options="{
|
|
isRemoteEvent: activeElement.__typename === 'EventResult',
|
|
isLoggedIn,
|
|
}"
|
|
/>
|
|
<group-card
|
|
v-else
|
|
:group="(activeElement as IGroup)"
|
|
:has-border="false"
|
|
view-mode="column"
|
|
:isRemoteGroup="activeElement.__typename === 'GroupResult'"
|
|
:isLoggedIn="isLoggedIn"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import "leaflet/dist/leaflet.css";
|
|
import {
|
|
computed,
|
|
createApp,
|
|
DefineComponent,
|
|
h,
|
|
onBeforeUnmount,
|
|
onMounted,
|
|
ref,
|
|
watch,
|
|
} from "vue";
|
|
import VueBottomSheet from "@/components/Map/VueBottomSheet.vue";
|
|
import {
|
|
map,
|
|
LatLngBounds,
|
|
tileLayer,
|
|
marker,
|
|
divIcon,
|
|
Map,
|
|
Marker,
|
|
} from "leaflet";
|
|
import { MarkerClusterGroup } from "leaflet.markercluster/src";
|
|
import { IGroup } from "@/types/actor";
|
|
import { IEvent, instanceOfIEvent } from "@/types/event.model";
|
|
import { ContentType } from "@/types/enums";
|
|
import Calendar from "vue-material-design-icons/Calendar.vue";
|
|
import AccountMultiple from "vue-material-design-icons/AccountMultiple.vue";
|
|
import GroupCard from "@/components/Group/GroupCard.vue";
|
|
import EventCard from "@/components/Event/EventCard.vue";
|
|
import debounce from "lodash/debounce";
|
|
import { Paginate } from "@/types/paginate";
|
|
import { TypeNamed } from "@/types/apollo";
|
|
|
|
const mapElement = ref<Map>();
|
|
const markers = ref<MarkerClusterGroup>();
|
|
const myBottomSheet = ref<typeof VueBottomSheet>();
|
|
|
|
const props = defineProps<{
|
|
contentType: ContentType;
|
|
events: Paginate<TypeNamed<IEvent>>;
|
|
groups: Paginate<TypeNamed<IGroup>>;
|
|
latitude?: number;
|
|
longitude?: number;
|
|
isLoggedIn: boolean | undefined;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
(
|
|
e: "map-updated",
|
|
{ bounds, zoom }: { bounds: LatLngBounds; zoom: number }
|
|
): void;
|
|
}>();
|
|
|
|
const activeElement = ref<TypeNamed<IEvent> | TypeNamed<IGroup> | null>(null);
|
|
|
|
const events = computed(() => props.events?.elements ?? []);
|
|
const groups = computed(() => props.groups?.elements ?? []);
|
|
|
|
watch([events, groups], update);
|
|
|
|
function update() {
|
|
if (!mapElement.value || !mapElement.value.getBounds) return;
|
|
const rawBounds: LatLngBounds = mapElement.value.getBounds();
|
|
const bounds: LatLngBounds = mapElement.value.wrapLatLngBounds(rawBounds);
|
|
if (
|
|
bounds.getNorthWest().lat === 0 ||
|
|
bounds.getNorthWest().lat === bounds.getSouthEast().lat
|
|
)
|
|
return;
|
|
const zoom = mapElement.value.getZoom();
|
|
emit("map-updated", { bounds, zoom });
|
|
}
|
|
|
|
onBeforeUnmount(() => {
|
|
if (mapElement.value) {
|
|
mapElement.value.remove();
|
|
}
|
|
});
|
|
|
|
const initialView = computed<[[number, number], number]>(() => {
|
|
if (props.latitude && props.longitude) {
|
|
return [[props.latitude, props.longitude], 12];
|
|
}
|
|
return [[0, 0], 3];
|
|
});
|
|
|
|
watch(initialView, ([latlng, zoom]) => {
|
|
setLatLng(latlng, zoom);
|
|
});
|
|
|
|
const setLatLng = (latlng: [number, number], zoom: number) => {
|
|
console.debug("setting view to ", latlng, zoom);
|
|
mapElement.value?.setView(latlng, zoom);
|
|
};
|
|
|
|
onMounted(async () => {
|
|
mapElement.value = map("mapMountPoint");
|
|
setLatLng(...initialView.value);
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-ignore
|
|
// eslint-disable-next-line no-underscore-dangle
|
|
mapElement.value._onResize();
|
|
mapElement.value.on("click", () => {
|
|
activeElement.value = null;
|
|
if (myBottomSheet.value) {
|
|
myBottomSheet.value.close();
|
|
}
|
|
});
|
|
// mapElement.value.on('load', function () {
|
|
// console.log('load event')
|
|
// setTimeout(() => {
|
|
// console.log('invalidate size')
|
|
// mapElement.value.invalidateSize()
|
|
// }, 1000)
|
|
// })
|
|
markers.value = new MarkerClusterGroup({ chunkedLoading: true });
|
|
|
|
mapElement.value.on("zoom", debounce(update, 1000));
|
|
mapElement.value.on("moveend", debounce(update, 1000));
|
|
|
|
tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
|
attribution:
|
|
'© <a href="https://osm.org/copyright">OpenStreetMap</a> contributors',
|
|
className: "map-tiles",
|
|
}).addTo(mapElement.value);
|
|
});
|
|
|
|
const categoryToColorClass = (element: IEvent | IGroup): string => {
|
|
if (instanceOfIEvent(element)) {
|
|
return "marker-event";
|
|
}
|
|
return "marker-group";
|
|
};
|
|
|
|
const pointToLayer = (
|
|
element: TypeNamed<IEvent> | TypeNamed<IGroup>,
|
|
latlng: { lat: number; lng: number }
|
|
): Marker => {
|
|
const icon = divIcon({
|
|
html: `<div class="marker-container ${categoryToColorClass(element)}">
|
|
<div class="pin-icon-container">
|
|
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M20 10C20 14.4183 12 22 12 22C12 22 4 14.4183 4 10C4 5.58172 7.58172 2 12 2C16.4183 2 20 5.58172 20 10Z" stroke="currentColor" stroke-width="1.5"></path>
|
|
</svg>
|
|
</div>
|
|
<div class="element-icon-container text-black">
|
|
${instanceOfIEvent(element) ? calendarHTML : AccountMultipleHTML}
|
|
</div>
|
|
</div>`,
|
|
iconSize: [50, 50],
|
|
iconAnchor: [25, 50],
|
|
// iconSize: [
|
|
// MARKER_TOUCH_TARGET_SIZE * 0.5,
|
|
// MARKER_TOUCH_TARGET_SIZE * 0.5,
|
|
// ],
|
|
});
|
|
|
|
return marker(latlng, { icon }).on("click", () => {
|
|
activeElement.value = element;
|
|
if (myBottomSheet.value) {
|
|
myBottomSheet.value.open();
|
|
}
|
|
});
|
|
};
|
|
|
|
// https://stackoverflow.com/a/68319134/10204399
|
|
const vueComponentToHTML = (
|
|
component: DefineComponent,
|
|
localProps: Record<string, any> = {}
|
|
) => {
|
|
const tempApp = createApp({
|
|
render() {
|
|
return h(component, localProps);
|
|
},
|
|
});
|
|
|
|
// in Vue 3 we need real element to mount to unlike in Vue 2 where mount() could be called without argument...
|
|
const el = document.createElement("div");
|
|
const mountedApp = tempApp.mount(el);
|
|
|
|
const html = mountedApp.$el.outerHTML as string;
|
|
// tempApp.unmount();
|
|
return html;
|
|
};
|
|
|
|
const calendarHTML = vueComponentToHTML(Calendar);
|
|
const AccountMultipleHTML = vueComponentToHTML(AccountMultiple);
|
|
|
|
update();
|
|
|
|
const eventMarkers = computed(() => {
|
|
return events.value?.reduce((acc, event) => {
|
|
if (event.physicalAddress?.geom) {
|
|
const [lng, lat] = event.physicalAddress.geom.split(";");
|
|
return [
|
|
...acc,
|
|
pointToLayer(event, {
|
|
lng: Number.parseFloat(lng),
|
|
lat: Number.parseFloat(lat),
|
|
}),
|
|
];
|
|
}
|
|
return acc;
|
|
}, [] as Marker[]);
|
|
});
|
|
|
|
const groupMarkers = computed(() => {
|
|
return groups.value?.reduce((acc: Marker[], group: TypeNamed<IGroup>) => {
|
|
if (group.physicalAddress?.geom) {
|
|
const [lng, lat] = group.physicalAddress.geom.split(";");
|
|
return [
|
|
...acc,
|
|
pointToLayer(group, {
|
|
lng: Number.parseFloat(lng),
|
|
lat: Number.parseFloat(lat),
|
|
}),
|
|
];
|
|
}
|
|
return acc;
|
|
}, [] as Marker[]);
|
|
});
|
|
|
|
watch([markers, eventMarkers, groupMarkers], () => {
|
|
if (!markers.value) return;
|
|
console.debug(
|
|
"something changed in the search map",
|
|
markers.value,
|
|
eventMarkers.value,
|
|
groupMarkers.value
|
|
);
|
|
markers.value?.clearLayers();
|
|
if (props.contentType !== ContentType.GROUPS) {
|
|
eventMarkers.value?.forEach((markerToAdd) => {
|
|
console.debug("adding event marker layer to markers");
|
|
markers.value.addLayer(markerToAdd);
|
|
});
|
|
}
|
|
if (props.contentType !== ContentType.EVENTS) {
|
|
groupMarkers.value?.forEach((markerToAdd) => {
|
|
console.debug("adding group marker layer to markers");
|
|
markers.value.addLayer(markerToAdd);
|
|
});
|
|
}
|
|
mapElement.value?.addLayer(markers.value);
|
|
});
|
|
</script>
|
|
<style>
|
|
/*
|
|
* https://github.com/mapbox/supercluster/blob/f073fade1caae0b2b1beffd013b74ff024ff413b/demo/cluster.css
|
|
*/
|
|
.marker-cluster-small {
|
|
background-color: rgba(181, 226, 140, 0.6);
|
|
}
|
|
|
|
.marker-cluster-small div {
|
|
background-color: rgba(110, 204, 57, 0.6);
|
|
}
|
|
|
|
.marker-cluster-medium {
|
|
background-color: rgba(241, 211, 87, 0.6);
|
|
}
|
|
|
|
.marker-cluster-medium div {
|
|
background-color: rgba(240, 194, 12, 0.6);
|
|
}
|
|
|
|
.marker-cluster-large {
|
|
background-color: rgba(253, 156, 115, 0.6);
|
|
}
|
|
|
|
.marker-cluster-large div {
|
|
background-color: rgba(241, 128, 23, 0.6);
|
|
}
|
|
|
|
.marker-cluster {
|
|
background-clip: padding-box;
|
|
border-radius: 20px;
|
|
}
|
|
|
|
.marker-cluster div {
|
|
width: 30px;
|
|
height: 30px;
|
|
margin-left: 5px;
|
|
margin-top: 5px;
|
|
|
|
text-align: center;
|
|
border-radius: 15px;
|
|
font: 12px "Helvetica Neue", Arial, Helvetica, sans-serif;
|
|
}
|
|
|
|
.marker-cluster span {
|
|
line-height: 30px;
|
|
}
|
|
|
|
:root {
|
|
--map-tiles-filter: brightness(0.6) invert(1) contrast(3) hue-rotate(200deg)
|
|
saturate(0.3) brightness(0.7);
|
|
}
|
|
|
|
@media (prefers-color-scheme: dark) {
|
|
.map-tiles {
|
|
filter: var(--map-tiles-filter, none);
|
|
}
|
|
}
|
|
|
|
.leaflet-div-icon {
|
|
background: none !important;
|
|
border: none !important;
|
|
}
|
|
|
|
.marker-container {
|
|
position: relative;
|
|
}
|
|
|
|
.marker-container.marker-event .pin-icon-container svg {
|
|
fill: yellow;
|
|
color: black;
|
|
}
|
|
|
|
.marker-container.marker-group .pin-icon-container svg {
|
|
fill: lightblue;
|
|
color: white;
|
|
}
|
|
|
|
.pin-icon-container {
|
|
position: absolute;
|
|
width: 50px;
|
|
height: 50px;
|
|
}
|
|
|
|
.pin-icon-container svg path {
|
|
stroke-width: 1;
|
|
}
|
|
|
|
.pin-icon-container svg {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.element-icon-container {
|
|
position: absolute;
|
|
transform: translate(12px, 8px);
|
|
}
|
|
</style>
|