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:
@@ -4,6 +4,8 @@ defineProps<{
|
||||
y: number;
|
||||
target: "map" | "object";
|
||||
objectName?: string;
|
||||
canEdit?: boolean;
|
||||
canDelete?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -26,8 +28,9 @@ const emit = defineEmits<{
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<button type="button" @click="emit('edit')">Редактировать</button>
|
||||
<button type="button" class="danger" @click="emit('delete')">Удалить</button>
|
||||
<button v-if="canEdit !== false" type="button" @click="emit('edit')">Редактировать</button>
|
||||
<button v-if="canDelete !== false" type="button" class="danger" @click="emit('delete')">Удалить</button>
|
||||
<p v-if="canEdit === false" class="hint">Событие из ingest — только просмотр</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -78,4 +81,11 @@ button.danger {
|
||||
button.danger:hover {
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -6,13 +6,18 @@ import {
|
||||
formatFileSize,
|
||||
uploadObjectMedia,
|
||||
} from "../api/objects";
|
||||
import { MEDIA_ACCEPT, OBJECT_TYPE_LABELS, type MapObject, type ObjectMedia } from "../types/object";
|
||||
import { MEDIA_ACCEPT, OBJECT_TYPE_LABELS } from "../types/object";
|
||||
import type { MapObjectWithEvent } from "../types/map";
|
||||
|
||||
const props = defineProps<{
|
||||
object: MapObject | null;
|
||||
object: MapObjectWithEvent | null;
|
||||
}>();
|
||||
|
||||
const mediaItems = ref<ObjectMedia[]>([]);
|
||||
const emit = defineEmits<{
|
||||
openEvents: [];
|
||||
}>();
|
||||
|
||||
const mediaItems = ref<Awaited<ReturnType<typeof fetchObjectMedia>>>([]);
|
||||
const loadingMedia = ref(false);
|
||||
const mediaError = ref("");
|
||||
const uploading = ref(false);
|
||||
@@ -22,15 +27,18 @@ const typeLabel = computed(() =>
|
||||
);
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
if (!props.object) return "";
|
||||
return new Date(props.object.created_at).toLocaleString("ru-RU");
|
||||
if (!props.object?.event_date && !props.object?.created_at) return "—";
|
||||
const raw = props.object.event_date ?? props.object.created_at;
|
||||
return new Date(raw).toLocaleString("ru-RU");
|
||||
});
|
||||
|
||||
function isImage(media: ObjectMedia): boolean {
|
||||
const isIngested = computed(() => Boolean(props.object?.event_id));
|
||||
|
||||
function isImage(media: { content_type: string }) {
|
||||
return media.content_type.startsWith("image/");
|
||||
}
|
||||
|
||||
function isVideo(media: ObjectMedia): boolean {
|
||||
function isVideo(media: { content_type: string }) {
|
||||
return media.content_type.startsWith("video/");
|
||||
}
|
||||
|
||||
@@ -42,7 +50,6 @@ async function loadMedia() {
|
||||
|
||||
loadingMedia.value = true;
|
||||
mediaError.value = "";
|
||||
|
||||
try {
|
||||
mediaItems.value = await fetchObjectMedia(props.object.id);
|
||||
} catch (err) {
|
||||
@@ -55,16 +62,13 @@ async function loadMedia() {
|
||||
|
||||
async function handleUpload(event: Event) {
|
||||
if (!props.object) return;
|
||||
|
||||
const input = event.target as HTMLInputElement;
|
||||
const files = input.files ? Array.from(input.files) : [];
|
||||
input.value = "";
|
||||
|
||||
if (files.length === 0) return;
|
||||
|
||||
uploading.value = true;
|
||||
mediaError.value = "";
|
||||
|
||||
try {
|
||||
for (const file of files) {
|
||||
const uploaded = await uploadObjectMedia(props.object.id, file);
|
||||
@@ -79,7 +83,6 @@ async function handleUpload(event: Event) {
|
||||
|
||||
async function handleDelete(mediaId: number) {
|
||||
mediaError.value = "";
|
||||
|
||||
try {
|
||||
await deleteObjectMedia(mediaId);
|
||||
mediaItems.value = mediaItems.value.filter((item) => item.id !== mediaId);
|
||||
@@ -88,11 +91,7 @@ async function handleDelete(mediaId: number) {
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.object?.id,
|
||||
() => loadMedia(),
|
||||
{ immediate: true },
|
||||
);
|
||||
watch(() => props.object?.id, () => loadMedia(), { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -104,26 +103,52 @@ watch(
|
||||
</div>
|
||||
|
||||
<div v-else class="content">
|
||||
<h3>{{ object.name }}</h3>
|
||||
<h3>{{ object.title || object.name }}</h3>
|
||||
|
||||
<dl>
|
||||
<dt>Тип</dt>
|
||||
<dd>{{ typeLabel }}</dd>
|
||||
|
||||
<template v-if="object.locality">
|
||||
<dt>Населённый пункт</dt>
|
||||
<dd>{{ object.locality }}</dd>
|
||||
</template>
|
||||
|
||||
<template v-if="object.region">
|
||||
<dt>Регион</dt>
|
||||
<dd>{{ object.region }}</dd>
|
||||
</template>
|
||||
|
||||
<dt>Описание</dt>
|
||||
<dd>{{ object.description || "—" }}</dd>
|
||||
|
||||
<dt>Координаты</dt>
|
||||
<dd>
|
||||
{{ object.latitude.toFixed(6) }}, {{ object.longitude.toFixed(6) }}
|
||||
<span class="drag-hint">Перетащите маркер на карте для перемещения</span>
|
||||
<span v-if="!isIngested" class="drag-hint">Перетащите маркер на карте для перемещения</span>
|
||||
</dd>
|
||||
|
||||
<dt>Создан</dt>
|
||||
<dt>Дата события</dt>
|
||||
<dd>{{ formattedDate }}</dd>
|
||||
|
||||
<template v-if="object.source_type">
|
||||
<dt>Источник</dt>
|
||||
<dd>
|
||||
{{ object.source_type }}
|
||||
<a v-if="object.source_url" :href="object.source_url" target="_blank" rel="noopener">ссылка</a>
|
||||
</dd>
|
||||
</template>
|
||||
|
||||
<template v-if="object.event_id">
|
||||
<dt>Событие</dt>
|
||||
<dd>
|
||||
#{{ object.event_id }}
|
||||
<button type="button" class="link-btn" @click="emit('openEvents')">Открыть в Событиях</button>
|
||||
</dd>
|
||||
</template>
|
||||
</dl>
|
||||
|
||||
<section class="media-section">
|
||||
<section v-if="!isIngested" class="media-section">
|
||||
<div class="media-header">
|
||||
<h4>Медиа</h4>
|
||||
<label class="upload-btn">
|
||||
@@ -156,15 +181,11 @@ watch(
|
||||
<a v-else :href="item.url" class="file-link" target="_blank" rel="noopener">
|
||||
{{ item.original_name }}
|
||||
</a>
|
||||
|
||||
<div class="media-meta">
|
||||
<span class="name" :title="item.original_name">{{ item.original_name }}</span>
|
||||
<span class="size">{{ formatFileSize(item.size) }}</span>
|
||||
</div>
|
||||
|
||||
<button type="button" class="delete-btn" @click="handleDelete(item.id)">
|
||||
Удалить
|
||||
</button>
|
||||
<button type="button" class="delete-btn" @click="handleDelete(item.id)">Удалить</button>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
@@ -234,6 +255,21 @@ dd {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
display: inline;
|
||||
margin-left: 0.5rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: #1565c0;
|
||||
cursor: pointer;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.link-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.media-section {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
@@ -263,12 +299,6 @@ dd {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-btn input:disabled + span,
|
||||
.upload-btn:has(input:disabled) {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.media-status {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { MapObject } from "../types/object";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: number;
|
||||
min: number;
|
||||
max: number;
|
||||
objects: MapObject[];
|
||||
visibleCount: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: number];
|
||||
}>();
|
||||
|
||||
const range = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit("update:modelValue", value),
|
||||
});
|
||||
|
||||
const formattedTime = computed(() =>
|
||||
new Date(props.modelValue).toLocaleString("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
);
|
||||
|
||||
const formattedMin = computed(() =>
|
||||
new Date(props.min).toLocaleDateString("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
}),
|
||||
);
|
||||
|
||||
const formattedMax = computed(() =>
|
||||
new Date(props.max).toLocaleDateString("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
}),
|
||||
);
|
||||
|
||||
const markers = computed(() => {
|
||||
if (props.max <= props.min) return [];
|
||||
|
||||
const span = props.max - props.min;
|
||||
return props.objects.map((obj) => {
|
||||
const time = new Date(obj.created_at).getTime();
|
||||
const percent = ((time - props.min) / span) * 100;
|
||||
return {
|
||||
id: obj.id,
|
||||
name: obj.name,
|
||||
percent: Math.min(100, Math.max(0, percent)),
|
||||
visible: time <= props.modelValue,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const hasRange = computed(() => props.max > props.min);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="timeline">
|
||||
<div class="timeline-header">
|
||||
<span class="label">Таймлайн появления</span>
|
||||
<span class="current-time">{{ formattedTime }}</span>
|
||||
<span class="count">{{ visibleCount }} / {{ objects.length }} объектов</span>
|
||||
</div>
|
||||
|
||||
<div class="slider-wrap">
|
||||
<span class="edge-label">{{ formattedMin }}</span>
|
||||
|
||||
<div class="slider-track">
|
||||
<div
|
||||
v-for="marker in markers"
|
||||
:key="marker.id"
|
||||
class="object-marker"
|
||||
:class="{ visible: marker.visible }"
|
||||
:style="{ left: `${marker.percent}%` }"
|
||||
:title="marker.name"
|
||||
/>
|
||||
|
||||
<input
|
||||
v-model.number="range"
|
||||
class="slider"
|
||||
type="range"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="hasRange ? Math.max(1, Math.floor((max - min) / 500)) : 1"
|
||||
:disabled="!hasRange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span class="edge-label">{{ formattedMax }}</span>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.timeline {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
flex-shrink: 0;
|
||||
padding: 0.75rem 1.25rem 1rem;
|
||||
background: #fff;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.timeline-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.625rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.current-time {
|
||||
color: #2563eb;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.count {
|
||||
margin-left: auto;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.slider-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.edge-label {
|
||||
flex-shrink: 0;
|
||||
width: 5.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.edge-label:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.edge-label:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.slider-track {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.object-marker {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-left: -4px;
|
||||
margin-top: -4px;
|
||||
border-radius: 50%;
|
||||
background: #bbb;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
transition: background 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
.object-marker.visible {
|
||||
background: #2563eb;
|
||||
transform: scale(1.25);
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
accent-color: #2563eb;
|
||||
}
|
||||
|
||||
.slider:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { CITY_PRESETS } from "../../config/cities";
|
||||
import type { LeafletMapApi } from "../../composables/useLeafletMap";
|
||||
import { parseCoordsInput } from "../../composables/usePlaceSearch";
|
||||
|
||||
const props = defineProps<{
|
||||
mapApi: LeafletMapApi | null;
|
||||
centerCoords: { lat: number; lng: number };
|
||||
}>();
|
||||
|
||||
const coordsInput = ref("");
|
||||
const selectedCity = ref("");
|
||||
|
||||
function formatCoords(lat: number, lng: number): string {
|
||||
return `${lat.toFixed(6)}, ${lng.toFixed(6)}`;
|
||||
}
|
||||
|
||||
async function copyText(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function centerOnInput() {
|
||||
const parsed = parseCoordsInput(coordsInput.value);
|
||||
if (!parsed || !props.mapApi) return;
|
||||
props.mapApi.flyTo(parsed.lat, parsed.lng, 13);
|
||||
}
|
||||
|
||||
function centerOnCity() {
|
||||
const city = CITY_PRESETS.find((c) => c.name === selectedCity.value);
|
||||
if (!city || !props.mapApi) return;
|
||||
props.mapApi.flyTo(city.latitude, city.longitude, city.zoom ?? 12);
|
||||
}
|
||||
|
||||
function copyCenter() {
|
||||
void copyText(formatCoords(props.centerCoords.lat, props.centerCoords.lng));
|
||||
}
|
||||
|
||||
function copyInput() {
|
||||
void copyText(coordsInput.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="coords-tools">
|
||||
<label class="coords-label">Центрировать на:</label>
|
||||
<input
|
||||
v-model="coordsInput"
|
||||
type="text"
|
||||
class="coord-input"
|
||||
placeholder="48.65, 37.67"
|
||||
@keydown.enter="centerOnInput"
|
||||
/>
|
||||
<button type="button" class="icon-btn" title="Перейти" @click="centerOnInput">➜</button>
|
||||
<button type="button" class="icon-btn" title="Копировать" @click="copyInput">⎘</button>
|
||||
|
||||
<select v-model="selectedCity" class="city-select" @change="centerOnCity">
|
||||
<option value="" disabled>🏘 Город</option>
|
||||
<option v-for="city in CITY_PRESETS" :key="city.name" :value="city.name">
|
||||
{{ city.name }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<span class="current-center-label">Текущий центр:</span>
|
||||
<span class="current-coords">{{ formatCoords(centerCoords.lat, centerCoords.lng) }}</span>
|
||||
<button type="button" class="icon-btn" title="Копировать центр" @click="copyCenter">⎘</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import flatpickr from "flatpickr";
|
||||
import { Russian } from "flatpickr/dist/l10n/ru.js";
|
||||
import type { DateRangePreset, MapFilters } from "../../types/map";
|
||||
|
||||
const RANGE_OPTIONS: { value: DateRangePreset; label: string }[] = [
|
||||
{ value: "week", label: "1 неделя" },
|
||||
{ value: "month", label: "1 месяц" },
|
||||
{ value: "3months", label: "3 месяца" },
|
||||
{ value: "6months", label: "6 месяцев" },
|
||||
{ value: "year", label: "1 год" },
|
||||
{ value: "all", label: "Все" },
|
||||
];
|
||||
|
||||
const props = defineProps<{
|
||||
filters: MapFilters | null;
|
||||
selectedDate: string | null;
|
||||
rangePreset: DateRangePreset;
|
||||
region: string;
|
||||
topic: string;
|
||||
sourceType: string;
|
||||
loading?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:selectedDate": [value: string | null];
|
||||
"update:rangePreset": [value: DateRangePreset];
|
||||
"update:region": [value: string];
|
||||
"update:topic": [value: string];
|
||||
"update:sourceType": [value: string];
|
||||
apply: [];
|
||||
}>();
|
||||
|
||||
const dateInput = ref<HTMLInputElement | null>(null);
|
||||
const rangeOpen = ref(false);
|
||||
const filtersOpen = ref(false);
|
||||
let picker: flatpickr.Instance | null = null;
|
||||
|
||||
const availableDates = computed(() => props.filters?.available_dates ?? []);
|
||||
|
||||
function isoToDisplay(iso: string): string {
|
||||
const [y, m, d] = iso.split("-");
|
||||
return `${d}.${m}.${y.slice(-2)}`;
|
||||
}
|
||||
|
||||
function displayToIso(display: string): string | null {
|
||||
const match = display.match(/^(\d{2})\.(\d{2})\.(\d{2,4})$/);
|
||||
if (!match) return null;
|
||||
const year = match[3].length === 2 ? `20${match[3]}` : match[3];
|
||||
return `${year}-${match[2]}-${match[1]}`;
|
||||
}
|
||||
|
||||
function navigateDate(direction: "first" | "prev" | "next" | "last") {
|
||||
const dates = availableDates.value;
|
||||
if (dates.length === 0) return;
|
||||
|
||||
const current = props.selectedDate ?? dates[dates.length - 1];
|
||||
const idx = dates.indexOf(current);
|
||||
|
||||
let next: string;
|
||||
if (direction === "first") next = dates[0];
|
||||
else if (direction === "last") next = dates[dates.length - 1];
|
||||
else if (direction === "prev") next = dates[Math.max(0, idx <= 0 ? 0 : idx - 1)];
|
||||
else next = dates[Math.min(dates.length - 1, idx < 0 ? dates.length - 1 : idx + 1)];
|
||||
|
||||
emit("update:selectedDate", next);
|
||||
emit("apply");
|
||||
}
|
||||
|
||||
function selectRange(preset: DateRangePreset) {
|
||||
emit("update:rangePreset", preset);
|
||||
rangeOpen.value = false;
|
||||
emit("apply");
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
emit("update:region", "");
|
||||
emit("update:topic", "");
|
||||
emit("update:sourceType", "");
|
||||
emit("apply");
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!dateInput.value) return;
|
||||
picker = flatpickr(dateInput.value, {
|
||||
locale: Russian,
|
||||
dateFormat: "d.m.y",
|
||||
allowInput: true,
|
||||
disableMobile: true,
|
||||
defaultDate: props.selectedDate ? isoToDisplay(props.selectedDate) : undefined,
|
||||
onChange: (_dates, dateStr) => {
|
||||
const iso = displayToIso(dateStr);
|
||||
if (iso) {
|
||||
emit("update:selectedDate", iso);
|
||||
emit("update:rangePreset", "all");
|
||||
emit("apply");
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
picker?.destroy();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.selectedDate,
|
||||
(iso) => {
|
||||
if (!picker || !iso) return;
|
||||
picker.setDate(isoToDisplay(iso), false);
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="map-toolbar">
|
||||
<div class="date-navigator">
|
||||
<button type="button" class="nav-btn" title="Первая дата" @click="navigateDate('first')">❮❮</button>
|
||||
<button type="button" class="nav-btn" title="Предыдущий" @click="navigateDate('prev')">❮</button>
|
||||
<div class="date-selector">
|
||||
<input ref="dateInput" type="text" class="date-picker-input" placeholder="DD.MM.YY" />
|
||||
</div>
|
||||
<button type="button" class="nav-btn" title="Следующий" @click="navigateDate('next')">❯</button>
|
||||
<button type="button" class="nav-btn" title="Последняя дата" @click="navigateDate('last')">❯❯</button>
|
||||
|
||||
<div class="filter-btn-container">
|
||||
<button
|
||||
type="button"
|
||||
class="filter-btn"
|
||||
title="Период"
|
||||
:class="{ active: rangePreset !== 'all' }"
|
||||
@click="rangeOpen = !rangeOpen"
|
||||
>⏳</button>
|
||||
<div v-if="rangeOpen" class="dropdown-content">
|
||||
<button
|
||||
v-for="opt in RANGE_OPTIONS"
|
||||
:key="opt.value"
|
||||
type="button"
|
||||
class="range-option"
|
||||
:class="{ active: rangePreset === opt.value }"
|
||||
@click="selectRange(opt.value)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-row">
|
||||
<button type="button" class="filter-btn mobile-toggle" @click="filtersOpen = !filtersOpen">📁</button>
|
||||
|
||||
<div class="filter-controls" :class="{ open: filtersOpen }">
|
||||
<select
|
||||
:value="region"
|
||||
class="filter-select"
|
||||
@change="emit('update:region', ($event.target as HTMLSelectElement).value); emit('apply')"
|
||||
>
|
||||
<option value="">Все регионы</option>
|
||||
<option v-for="r in filters?.regions ?? []" :key="r" :value="r">{{ r }}</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
:value="topic"
|
||||
class="filter-select"
|
||||
@change="emit('update:topic', ($event.target as HTMLSelectElement).value); emit('apply')"
|
||||
>
|
||||
<option value="">Все темы</option>
|
||||
<option v-for="t in filters?.topics ?? []" :key="t" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
:value="sourceType"
|
||||
class="filter-select"
|
||||
@change="emit('update:sourceType', ($event.target as HTMLSelectElement).value); emit('apply')"
|
||||
>
|
||||
<option value="">Все источники</option>
|
||||
<option v-for="s in filters?.source_types ?? []" :key="s" :value="s">{{ s }}</option>
|
||||
</select>
|
||||
|
||||
<button type="button" class="btn btn-sm" @click="resetFilters">Сбросить</button>
|
||||
</div>
|
||||
|
||||
<span v-if="loading" class="toolbar-status">Загрузка...</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import type { LeafletMapApi } from "../../composables/useLeafletMap";
|
||||
import { searchPlaces, type PlaceSearchResult } from "../../composables/usePlaceSearch";
|
||||
|
||||
const props = defineProps<{
|
||||
mapApi: LeafletMapApi | null;
|
||||
}>();
|
||||
|
||||
const query = ref("");
|
||||
const results = ref<PlaceSearchResult[]>([]);
|
||||
const searching = ref(false);
|
||||
const open = ref(false);
|
||||
|
||||
async function runSearch() {
|
||||
if (!query.value.trim()) return;
|
||||
searching.value = true;
|
||||
try {
|
||||
results.value = await searchPlaces(query.value);
|
||||
open.value = results.value.length > 0;
|
||||
} finally {
|
||||
searching.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectPlace(place: PlaceSearchResult) {
|
||||
props.mapApi?.setSearchMarker(place.lat, place.lon, place.name);
|
||||
open.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="place-search">
|
||||
<input
|
||||
v-model="query"
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Поиск населённого пункта"
|
||||
@keydown.enter="runSearch"
|
||||
/>
|
||||
<button type="button" class="icon-btn" title="Найти" :disabled="searching" @click="runSearch">
|
||||
🔍
|
||||
</button>
|
||||
<ul v-if="open" class="search-results">
|
||||
<li v-for="(place, idx) in results" :key="idx">
|
||||
<button type="button" class="result-btn" @click="selectPlace(place)">
|
||||
{{ place.name }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user