Files
sim-location-frontend/src/components/leaflet/MarkerMenu.vue
T
2026-06-10 11:00:10 -04:00

423 lines
13 KiB
Vue

<script setup lang="ts">
import { onBeforeMount, ref } from 'vue';
import { useQuasar } from 'quasar';
import SetLocationDialog from 'components/SetLocationDialog.vue';
import EditFavoriteDialog from 'components/EditFavoriteDialog.vue';
import { useAddLocation } from 'composables/useAddLocation';
import { actionCircle } from 'components/leaflet/actionCircle';
import { useLeafletStore } from 'stores/leaflet';
import { useLocationStore } from 'stores/location';
import { useFavoriteStore } from 'stores/favorite';
import { storeToRefs } from 'pinia';
import * as L from 'leaflet';
import { LPopup } from '@vue-leaflet/vue-leaflet';
import { Icon, PinStar } from 'leaflet-extra-markers';
import { startIcon, endIcon, iconElement } from 'constants/icons';
import type { LatLngExpression } from 'leaflet';
import type { Favorite, FavoriteCommands, GeoCacheResponse } from 'components/models';
import FormattedAddress from 'components/FormattedAddress.vue';
const props = defineProps({
latLng: {
type: Object as () => LatLngExpression,
required: true,
},
});
const isFavorite = ref(false);
const favorite = ref();
const loading = ref(true);
const $q = useQuasar();
const favoriteStore = useFavoriteStore();
const locationStore = useLocationStore();
const leafletStore = useLeafletStore();
const { clickedLatLng, routeSet, layerControl, layerGroups } = storeToRefs(leafletStore);
type AddLocationDialogPayload = {
delay: number | undefined;
address?: string;
};
async function addDialogLocation(latlng: L.LatLng, { delay, address }: AddLocationDialogPayload) {
const revGeo = !address;
const { notificationType, responseMessage } = await useAddLocation(
{ lat: Number(latlng.lat), lng: Number(latlng.lng) },
revGeo,
delay,
address,
undefined,
undefined,
);
$q.notify({ type: notificationType, message: responseMessage });
console.log(
'Confirmed location add: latitude: ' +
latlng.lat +
', longitude: ' +
latlng.lng +
', delay: ' +
delay,
);
}
function handleAddLocation() {
if (props.latLng) {
const latlng = props.latLng;
closeAllPopups();
$q.dialog({
component: SetLocationDialog,
componentProps: {
lat: Number(L.latLng(latlng).lat),
lng: Number(L.latLng(latlng).lng),
},
})
.onOk((payload: AddLocationDialogPayload) => {
void addDialogLocation(L.latLng(latlng), payload);
})
.onCancel(() => {
console.log('Dialog cancelled');
})
.onDismiss(() => {
console.log('Dialog dismissed');
});
}
}
async function addFavorite() {
console.log('Adding favorite for', props.latLng);
if (props.latLng) {
const defaultRadius = 50;
const latlng = props.latLng;
layerControl.value.favorites = true;
layerControl.value.marker = false;
let geoCacheResp: GeoCacheResponse | undefined = await locationStore.getCachedLocation(latlng);
if (geoCacheResp) {
console.log('Using cached location for favorite:', geoCacheResp);
} else {
console.log('No cached location found, creating new favorite');
}
if (layerGroups.value.favorites) {
closeAllPopups();
const favoriteIcon = new Icon({
color: '#f5bb39',
accentColor: 'rbga(0,0,0,0.25)',
contentHtml: '<icon class="q-icon mdi mdi-star" />',
contentColor: 'black',
scale: 1,
svg: PinStar,
});
const favoriteMarker = L.marker(L.latLng(latlng), {
icon: favoriteIcon,
draggable: true,
contextmenu: false,
contextmenuItems: [],
});
const circle = actionCircle(L.latLng(latlng), {
radius: defaultRadius,
color: 'black',
fillColor: '#f5bb39',
fillOpacity: 0.25,
onAccept: (instance) => {
console.log(
'Confirm favorite coordinates: ' +
instance.getLatLng().toString() +
', radius: ' +
instance.getRadius() +
', geoCacheResp: ',
);
$q.notify(`Adding favorite...${instance.getLatLng().toString()}`);
$q.dialog({
component: EditFavoriteDialog,
componentProps: {
lat: Number(instance.getLatLng().lat),
lng: Number(instance.getLatLng().lng),
radius: instance.getRadius(),
operation: 'set',
geoCacheResp: geoCacheResp,
},
})
.onOk((response: { operation: FavoriteCommands; data: Favorite }) => {
void favoriteStore.favoriteControl({
command: response.operation,
favorite: response.data,
});
newFavGrp.clearLayers();
layerControl.value.marker = true;
})
.onCancel(() => {
console.log('Dialog cancelled');
})
.onDismiss(() => {
console.log('Dialog dismissed');
});
},
onClear: () => {
console.log('Clear favorite radius');
newFavGrp.clearLayers();
layerControl.value.marker = true;
},
});
const handleLatLng = (latlng: L.LatLngExpression, dist: number = 50): L.LatLngExpression => {
const lat = L.latLng(latlng).lat;
const lng = L.latLng(latlng).lng;
const brearing = 270;
const brng = (brearing * Math.PI) / 180;
const R = 6371e3;
const lat1 = (lat * Math.PI) / 180;
const lon1 = (lng * Math.PI) / 180;
const lat2 = Math.asin(
Math.sin(lat1) * Math.cos(dist / R) +
Math.cos(lat1) * Math.sin(dist / R) * Math.cos(brng),
);
const lon2 =
lon1 +
Math.atan2(
Math.sin(brng) * Math.sin(dist / R) * Math.cos(lat1),
Math.cos(dist / R) - Math.sin(lat1) * Math.sin(lat2),
);
return [(lat2 * 180) / Math.PI, (lon2 * 180) / Math.PI];
};
const handleIcon = L.divIcon({
html: '<i class="q-icon mdi mdi-hand-pointing-right text-black" style="font-size: 32px;" />',
className: 'custom-marker-with-buttons',
iconSize: [32, 32],
iconAnchor: [28, 16],
});
const handleMarker = L.marker(handleLatLng(latlng), {
draggable: true,
icon: handleIcon,
contextmenu: false,
contextmenuItems: [],
});
const newFavGrp: L.FeatureGroup = L.featureGroup([favoriteMarker, circle, handleMarker]);
leafletStore.addToFavLayer(newFavGrp);
let startPosition: L.LatLng;
handleMarker.on('dragstart', function (e) {
startPosition = e.target.getLatLng();
});
handleMarker.on('drag', function (e) {
const newLatLng = e.target.getLatLng();
const circleLatLng = circle.getLatLng();
let lng: number;
if (newLatLng.lng > circleLatLng.lng) {
lng = circleLatLng.lng;
} else {
lng = newLatLng.lng;
}
const forcedLatLng = L.latLng({ lat: startPosition.lat, lng: lng });
handleMarker.setLatLng(forcedLatLng);
const newRadius = leafletStore.getDistance(circleLatLng, forcedLatLng);
if (newRadius) {
circle.setRadius(newRadius);
}
});
favoriteMarker.on('drag', function (e) {
const newLatLng = e.target.getLatLng();
circle.setLatLng(newLatLng);
handleMarker.setLatLng(handleLatLng(newLatLng, circle.getRadius()));
});
favoriteMarker.on('dragend', function (e) {
const newLatLng = e.target.getLatLng();
void (async () => {
try {
geoCacheResp = await locationStore.getCachedLocation(newLatLng);
console.log('Marker dragend');
} catch (error) {
console.error('Error handling marker dragend:', error);
}
})();
});
}
}
leafletStore.zoom = 20;
}
/*
function clearFavorite() {
closeAllPopups();
if (!props.latLng) return;
const latlng = L.latLng(props.latLng);
let geoCacheResp: GeoCacheResponse | undefined;
void (async () => {
try {
geoCacheResp = await locationStore.getCachedLocation(latlng);
} catch (error) {
console.error('Error finding cached location:', error);
}
if (geoCacheResp?.favorite) {
$q.notify(`Clearing favorite...${geoCacheResp.favorite.name?.toString()}`);
$q.dialog({
component: EditFavoriteDialog,
componentProps: {
lat: Number(latlng.lat),
lng: Number(latlng.lng),
radius: geoCacheResp.favorite.radius,
operation: 'clear',
geoCacheResp: geoCacheResp,
},
})
.onOk((response: { operation: FavoriteCommands; data: Favorite }) => {
void favoriteStore.favoriteControl({
command: response.operation,
favorite: response.data,
});
})
.onCancel(() => {
console.log('Dialog cancelled');
})
.onDismiss(() => {
console.log('Dialog dismissed');
});
}
})();
}
*/
function closeAllPopups() {
leafletStore.closeAllPopups();
}
const setStartRoute = () => {
leafletStore.layerControl.routing = true;
if (!clickedLatLng.value) return;
closeAllPopups();
leafletStore.routeSet.start = clickedLatLng.value;
console.log('setStartRoute: ', routeSet.value.start);
};
const setEndRoute = () => {
leafletStore.layerControl.routing = true;
if (!clickedLatLng.value) return;
leafletStore.routeSet.end = clickedLatLng.value;
closeAllPopups();
console.log('setEndRoute: ', leafletStore.routeSet.end);
leafletStore.updateRouteWaypoints();
console.log('updating Route');
};
onBeforeMount(async () => {
try {
console.log('Fetching geoCacheResp...');
const geoCacheResp = await locationStore.getCachedLocation(props.latLng);
console.log('geoCacheResp: ', geoCacheResp);
if (geoCacheResp?.favorite) {
favorite.value = geoCacheResp.favorite;
}
isFavorite.value = !!geoCacheResp?.favorite;
} catch (error) {
console.error('Error fetching cached location:', error);
throw error;
} finally {
loading.value = false;
}
});
</script>
<template>
<L-Popup :options="{ closeOnClick: true, closeButton: false, className: 'marker-popup' }">
<q-list dense seperator style="min-width: 100px" class="bg-grey-10">
<q-item v-if="isFavorite" class="text-center">
<q-item-section>
<q-item-label>{{ favorite.name }}</q-item-label>
<q-item-label caption lines="2" v-if="favorite.address"
><FormattedAddress :address="favorite.address"></FormattedAddress
></q-item-label>
</q-item-section>
</q-item>
<q-separator v-if="isFavorite" class="q-my-sm" />
<q-item clickable v-ripple @click="handleAddLocation">
<q-item-section avatar>
<q-avatar icon="mdi-map-marker" color="accent" text-color="white" size="sm" />
</q-item-section>
<q-item-section>Add Location</q-item-section>
</q-item>
<q-item clickable v-ripple @click="setStartRoute">
<q-item-section avatar>
<q-avatar color="accent" size="sm">
<q-icon class="small-icon" v-html="iconElement(startIcon).outerHTML" />
</q-avatar>
</q-item-section>
<q-item-section>Set Route Start</q-item-section>
</q-item>
<q-item clickable v-ripple @click="setEndRoute">
<q-item-section avatar>
<q-avatar color="accent" size="sm">
<q-icon class="small-icon" v-html="iconElement(endIcon).outerHTML" />
</q-avatar>
</q-item-section>
<q-item-section>Set Route End</q-item-section>
</q-item>
<q-item v-if="loading">
<q-item-section avatar>
<q-spinner-hourglass color="accent" size="2em" />
</q-item-section>
<q-item-section>Loading...</q-item-section>
</q-item>
<q-item clickable v-ripple @click="addFavorite" v-else-if="!isFavorite && !loading">
<q-item-section avatar>
<q-avatar icon="mdi-star-outline" color="accent" text-color="yellow" size="sm" />
</q-item-section>
<q-item-section>Set Favorite</q-item-section>
</q-item>
<q-item clickable v-ripple @click="addFavorite" v-else-if="isFavorite && !loading">
<q-item-section avatar>
<q-avatar icon="mdi-star-outline" color="accent" text-color="yellow" size="sm" />
</q-item-section>
<q-item-section>Edit Favorite</q-item-section>
</q-item>
</q-list>
</L-Popup>
</template>
<style lang="sass">
.marker-popup .leaflet-popup-content-wrapper, .marker-popup .leaflet-popup-tip
background-color: $dark
.marker-popup .leaflet-popup-content
margin: 13px 10px 13px 10px
.leaflet-circle-actions
pointer-events: none
.leaflet-circle-actions button
border: 2px solid rgba(0,0,0,0.2)
background-clip: padding-box
cursor: pointer
text-align: center
font-weight: bold
box-shadow: 0 1px 5px rgba(0,0,0,0.4)
.circle-btn-accept
background-color: $positive
color: black
.circle-btn-clear
background-color: $negative
color: black
.leaflet-circle-actions button:hover
background-color: #f4f4f4
.leaflet-overlay-pane svg
position: absolute
.small-icon svg
transform: scale(0.6)
transform-origin: center
bottom: 0
right: 0
.small-icon
position: absolute
margin-left: 14px
margin-top: 44px
</style>