Refactor map tools and drop SocialParser runtime dependency.
Add filtered map API, creamy-caprice-style toolbar (dates, layers, search), Yandex ru tiles, and store Telegram session in MapMil/data instead of mounting SocialParser. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,116 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import L from "leaflet";
|
||||
import { onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import type { MapObject } from "../types/object";
|
||||
import { onMounted, onUnmounted, watch } from "vue";
|
||||
import { useLeafletMap } from "../composables/useLeafletMap";
|
||||
import type { MapObjectWithEvent } from "../types/map";
|
||||
|
||||
const props = defineProps<{
|
||||
objects: MapObject[];
|
||||
objects: MapObjectWithEvent[];
|
||||
selectedId: number | null;
|
||||
openPopupId: number | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [object: MapObject];
|
||||
select: [object: MapObjectWithEvent];
|
||||
contextmenu: [payload: {
|
||||
x: number;
|
||||
y: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
object: MapObject | null;
|
||||
object: MapObjectWithEvent | null;
|
||||
}];
|
||||
move: [payload: { object: MapObject; latitude: number; longitude: number }];
|
||||
move: [payload: { object: MapObjectWithEvent; latitude: number; longitude: number }];
|
||||
ready: [api: ReturnType<typeof useLeafletMap>["api"]];
|
||||
centerChange: [coords: { lat: number; lng: number }];
|
||||
}>();
|
||||
|
||||
const mapContainer = ref<HTMLElement | null>(null);
|
||||
const {
|
||||
mapContainer,
|
||||
map,
|
||||
centerCoords,
|
||||
initMap,
|
||||
destroyMap,
|
||||
flyTo,
|
||||
fitBounds,
|
||||
getMarkerIcons,
|
||||
getMarkersLayer,
|
||||
api,
|
||||
} = useLeafletMap();
|
||||
|
||||
let map: L.Map | null = null;
|
||||
let markersLayer: L.LayerGroup | null = null;
|
||||
const markerById = new Map<number, L.Marker>();
|
||||
|
||||
const defaultIcon = L.icon({
|
||||
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
|
||||
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
|
||||
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
shadowSize: [41, 41],
|
||||
});
|
||||
|
||||
const selectedIcon = L.icon({
|
||||
iconUrl: "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-orange.png",
|
||||
iconRetinaUrl: "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-orange.png",
|
||||
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
shadowSize: [41, 41],
|
||||
});
|
||||
|
||||
function isDraggable(obj: MapObject): boolean {
|
||||
return obj.id === props.selectedId;
|
||||
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 getMarkerIcon(obj: MapObject): L.Icon {
|
||||
if (obj.id === props.selectedId) return selectedIcon;
|
||||
return defaultIcon;
|
||||
}
|
||||
|
||||
function bindMarker(marker: L.Marker, obj: MapObject) {
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
const draggable = isDraggable(obj);
|
||||
if (marker.dragging) {
|
||||
if (draggable) {
|
||||
marker.dragging.enable();
|
||||
} else {
|
||||
marker.dragging.disable();
|
||||
}
|
||||
}
|
||||
|
||||
if (draggable) {
|
||||
marker.on("dragend", () => {
|
||||
const latlng = marker.getLatLng();
|
||||
emit("move", {
|
||||
object: obj,
|
||||
latitude: latlng.lat,
|
||||
longitude: latlng.lng,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function panToObject(obj: MapObject) {
|
||||
if (!map) return;
|
||||
const zoom = Math.max(map.getZoom(), 12);
|
||||
map.flyTo([obj.latitude, obj.longitude], zoom, { duration: 0.6 });
|
||||
}
|
||||
|
||||
function panToSelected() {
|
||||
if (props.selectedId === null) return;
|
||||
const obj = props.objects.find((item) => item.id === props.selectedId);
|
||||
if (obj) panToObject(obj);
|
||||
function isDraggable(obj: MapObjectWithEvent): boolean {
|
||||
return obj.id === props.selectedId && !obj.event_id;
|
||||
}
|
||||
|
||||
function syncMarkers() {
|
||||
if (!map || !markersLayer) return;
|
||||
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) {
|
||||
@@ -123,45 +73,67 @@ function syncMarkers() {
|
||||
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: getMarkerIcon(obj),
|
||||
draggable,
|
||||
});
|
||||
marker = L.marker([obj.latitude, obj.longitude], { icon, draggable });
|
||||
marker.bindPopup(buildPopupHtml(obj));
|
||||
marker.addTo(markersLayer);
|
||||
markerById.set(obj.id, marker);
|
||||
} else {
|
||||
const latlng = marker.getLatLng();
|
||||
const positionChanged =
|
||||
Math.abs(latlng.lat - obj.latitude) > 1e-8 ||
|
||||
Math.abs(latlng.lng - obj.longitude) > 1e-8;
|
||||
|
||||
if (positionChanged) {
|
||||
marker.setLatLng([obj.latitude, obj.longitude]);
|
||||
}
|
||||
|
||||
marker.setIcon(getMarkerIcon(obj));
|
||||
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();
|
||||
}
|
||||
if (draggable) marker.dragging.enable();
|
||||
else marker.dragging.disable();
|
||||
}
|
||||
}
|
||||
|
||||
bindMarker(marker, obj);
|
||||
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 || !mapContainer.value) return;
|
||||
|
||||
if (!map.value || !mapContainer.value) return;
|
||||
event.preventDefault();
|
||||
|
||||
const rect = mapContainer.value.getBoundingClientRect();
|
||||
const point = map.containerPointToLatLng(
|
||||
const point = map.value.containerPointToLatLng(
|
||||
L.point(event.clientX - rect.left, event.clientY - rect.top),
|
||||
);
|
||||
|
||||
@@ -175,27 +147,25 @@ function handleMapContextMenu(event: MouseEvent) {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!mapContainer.value) return;
|
||||
|
||||
map = L.map(mapContainer.value).setView([55.7558, 37.6173], 11);
|
||||
|
||||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
markersLayer = L.layerGroup().addTo(map);
|
||||
mapContainer.value.addEventListener("contextmenu", handleMapContextMenu);
|
||||
initMap(handleMapContextMenu);
|
||||
emit("ready", api);
|
||||
map.value?.on("moveend", () => {
|
||||
emit("centerChange", api.getCenter());
|
||||
});
|
||||
syncMarkers();
|
||||
panToSelected();
|
||||
setTimeout(() => map?.invalidateSize(), 0);
|
||||
|
||||
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(() => {
|
||||
mapContainer.value?.removeEventListener("contextmenu", handleMapContextMenu);
|
||||
map?.remove();
|
||||
map = null;
|
||||
markersLayer = null;
|
||||
destroyMap(handleMapContextMenu);
|
||||
markerById.clear();
|
||||
});
|
||||
|
||||
@@ -203,10 +173,27 @@ watch(
|
||||
() => [props.objects, props.selectedId] as const,
|
||||
() => {
|
||||
syncMarkers();
|
||||
panToSelected();
|
||||
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>
|
||||
@@ -243,7 +230,24 @@ watch(
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.map.has-selection :deep(.leaflet-marker-draggable:active) {
|
||||
cursor: grabbing;
|
||||
.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>
|
||||
|
||||
Reference in New Issue
Block a user