Files
MapMil/centers/analytics/frontend/src/components/MapView.vue
T
gitrusprusandCursor 8ab606747f Add public map with JWT admin login and protect admin APIs.
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>
2026-08-16 23:04:21 +03:00

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>