Keep map reads open; gate admin UI/nav and object mutations behind env-based admin credentials, and default parser batch limit to 10. Co-authored-by: Cursor <cursoragent@cursor.com>
258 lines
6.0 KiB
Vue
258 lines
6.0 KiB
Vue
<script setup lang="ts">
|
|
import L from "leaflet";
|
|
import { onMounted, onUnmounted, watch } from "vue";
|
|
import { useLeafletMap } from "../composables/useLeafletMap";
|
|
import type { MapObjectWithEvent } from "../types/map";
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
objects: MapObjectWithEvent[];
|
|
selectedId: number | null;
|
|
openPopupId: number | null;
|
|
editable?: boolean;
|
|
}>(),
|
|
{ editable: false },
|
|
);
|
|
|
|
const emit = defineEmits<{
|
|
select: [object: MapObjectWithEvent];
|
|
contextmenu: [payload: {
|
|
x: number;
|
|
y: number;
|
|
latitude: number;
|
|
longitude: number;
|
|
object: MapObjectWithEvent | null;
|
|
}];
|
|
move: [payload: { object: MapObjectWithEvent; latitude: number; longitude: number }];
|
|
ready: [api: ReturnType<typeof useLeafletMap>["api"]];
|
|
centerChange: [coords: { lat: number; lng: number }];
|
|
}>();
|
|
|
|
const {
|
|
mapContainer,
|
|
map,
|
|
centerCoords,
|
|
initMap,
|
|
destroyMap,
|
|
flyTo,
|
|
fitBounds,
|
|
getMarkerIcons,
|
|
getMarkersLayer,
|
|
api,
|
|
} = useLeafletMap();
|
|
|
|
const markerById = new Map<number, L.Marker>();
|
|
|
|
function buildPopupHtml(obj: MapObjectWithEvent): string {
|
|
const title = obj.title || obj.name;
|
|
const date = obj.event_date
|
|
? new Date(obj.event_date).toLocaleDateString("ru-RU")
|
|
: "—";
|
|
const locality = obj.locality || "—";
|
|
const desc = (obj.description || "").slice(0, 200);
|
|
const link = obj.source_url
|
|
? `<a href="${obj.source_url}" target="_blank" rel="noopener">Источник</a>`
|
|
: "";
|
|
return `<div class="map-popup"><strong>${title}</strong><br>${locality}<br>${date}<br>${desc}${link ? `<br>${link}` : ""}</div>`;
|
|
}
|
|
|
|
function isDraggable(obj: MapObjectWithEvent): boolean {
|
|
return props.editable && obj.id === props.selectedId && !obj.event_id;
|
|
}
|
|
|
|
function syncMarkers() {
|
|
const markersLayer = getMarkersLayer();
|
|
if (!markersLayer) return;
|
|
|
|
const { defaultIcon, selectedIcon } = getMarkerIcons();
|
|
const currentIds = new Set(props.objects.map((obj) => obj.id));
|
|
|
|
for (const [id, marker] of markerById) {
|
|
if (!currentIds.has(id)) {
|
|
markersLayer.removeLayer(marker);
|
|
markerById.delete(id);
|
|
}
|
|
}
|
|
|
|
for (const obj of props.objects) {
|
|
const draggable = isDraggable(obj);
|
|
let marker = markerById.get(obj.id);
|
|
const icon = obj.id === props.selectedId ? selectedIcon : defaultIcon;
|
|
|
|
if (!marker) {
|
|
marker = L.marker([obj.latitude, obj.longitude], { icon, draggable });
|
|
marker.bindPopup(buildPopupHtml(obj));
|
|
marker.addTo(markersLayer);
|
|
markerById.set(obj.id, marker);
|
|
} else {
|
|
marker.setLatLng([obj.latitude, obj.longitude]);
|
|
marker.setIcon(icon);
|
|
marker.setPopupContent(buildPopupHtml(obj));
|
|
if (marker.dragging) {
|
|
if (draggable) marker.dragging.enable();
|
|
else marker.dragging.disable();
|
|
}
|
|
}
|
|
|
|
marker.off("click");
|
|
marker.off("contextmenu");
|
|
marker.off("dragend");
|
|
|
|
marker.on("click", () => emit("select", obj));
|
|
|
|
marker.on("contextmenu", (event: L.LeafletMouseEvent) => {
|
|
L.DomEvent.stopPropagation(event.originalEvent);
|
|
L.DomEvent.preventDefault(event.originalEvent);
|
|
emit("contextmenu", {
|
|
x: event.originalEvent.clientX,
|
|
y: event.originalEvent.clientY,
|
|
latitude: obj.latitude,
|
|
longitude: obj.longitude,
|
|
object: obj,
|
|
});
|
|
});
|
|
|
|
if (draggable) {
|
|
marker.on("dragend", () => {
|
|
const latlng = marker!.getLatLng();
|
|
emit("move", { object: obj, latitude: latlng.lat, longitude: latlng.lng });
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function panToSelected(openPopup = false) {
|
|
if (props.selectedId === null) return;
|
|
const obj = props.objects.find((item) => item.id === props.selectedId);
|
|
if (!obj) return;
|
|
flyTo(obj.latitude, obj.longitude);
|
|
if (openPopup) {
|
|
const marker = markerById.get(obj.id);
|
|
marker?.openPopup();
|
|
}
|
|
}
|
|
|
|
function handleMapContextMenu(event: MouseEvent) {
|
|
if (!map.value || !mapContainer.value) return;
|
|
event.preventDefault();
|
|
|
|
const rect = mapContainer.value.getBoundingClientRect();
|
|
const point = map.value.containerPointToLatLng(
|
|
L.point(event.clientX - rect.left, event.clientY - rect.top),
|
|
);
|
|
|
|
emit("contextmenu", {
|
|
x: event.clientX,
|
|
y: event.clientY,
|
|
latitude: point.lat,
|
|
longitude: point.lng,
|
|
object: null,
|
|
});
|
|
}
|
|
|
|
onMounted(() => {
|
|
initMap(handleMapContextMenu);
|
|
emit("ready", api);
|
|
map.value?.on("moveend", () => {
|
|
emit("centerChange", api.getCenter());
|
|
});
|
|
syncMarkers();
|
|
|
|
if (props.objects.length > 0) {
|
|
const bounds = L.latLngBounds(props.objects.map((o) => [o.latitude, o.longitude]));
|
|
fitBounds(bounds);
|
|
}
|
|
|
|
if (props.selectedId !== null) {
|
|
panToSelected(true);
|
|
}
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
destroyMap(handleMapContextMenu);
|
|
markerById.clear();
|
|
});
|
|
|
|
watch(
|
|
() => [props.objects, props.selectedId, props.editable] as const,
|
|
() => {
|
|
syncMarkers();
|
|
panToSelected(false);
|
|
},
|
|
{ deep: true },
|
|
);
|
|
|
|
watch(
|
|
() => props.openPopupId,
|
|
(id) => {
|
|
if (id === null) return;
|
|
const marker = markerById.get(id);
|
|
if (marker) {
|
|
flyTo(
|
|
marker.getLatLng().lat,
|
|
marker.getLatLng().lng,
|
|
);
|
|
marker.openPopup();
|
|
}
|
|
},
|
|
);
|
|
|
|
defineExpose({ api, centerCoords, flyTo, fitBounds, map });
|
|
</script>
|
|
|
|
<template>
|
|
<div class="map-wrapper">
|
|
<div
|
|
ref="mapContainer"
|
|
class="map"
|
|
:class="{ 'has-selection': selectedId !== null }"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.map-wrapper {
|
|
position: relative;
|
|
z-index: 0;
|
|
flex: 1;
|
|
min-width: 0;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
isolation: isolate;
|
|
}
|
|
|
|
.map {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.map-wrapper :deep(.leaflet-container) {
|
|
z-index: 0;
|
|
}
|
|
|
|
.map.has-selection :deep(.leaflet-marker-draggable) {
|
|
cursor: grab;
|
|
}
|
|
|
|
.map-wrapper :deep(.map-popup) {
|
|
font-size: 0.85rem;
|
|
line-height: 1.4;
|
|
max-width: 260px;
|
|
}
|
|
|
|
.map-wrapper :deep(.map-control-btn) {
|
|
display: block;
|
|
width: 30px;
|
|
height: 30px;
|
|
line-height: 30px;
|
|
text-align: center;
|
|
text-decoration: none;
|
|
color: #333;
|
|
font-size: 16px;
|
|
}
|
|
|
|
.map-wrapper :deep(.map-control-btn.active) {
|
|
background: #e6f2ff;
|
|
}
|
|
</style>
|