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:
2026-06-30 20:56:30 +03:00
co-authored by Cursor
parent 2c16bc0b56
commit 1d6d54ab9f
28 changed files with 3182 additions and 616 deletions
@@ -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: '&copy; <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>