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 -1
View File
@@ -1,4 +1,4 @@
# Telegram (CP workers) — copy from SocialParser/.env # Telegram (CP workers)
TELEGRAM_API_ID=12345678 TELEGRAM_API_ID=12345678
TELEGRAM_API_HASH=your_api_hash_here TELEGRAM_API_HASH=your_api_hash_here
TELEGRAM_SESSION_PATH=/data/telegram.session TELEGRAM_SESSION_PATH=/data/telegram.session
+26 -18
View File
@@ -1,6 +1,6 @@
# MapMil Platform (ЦП → ЦА → ПИ) # MapMil Platform (ЦП → ЦА → ПИ)
Единая платформа на базе MapMil (ЦА аналитика) и SocialParser (ЦП парсинг). Единая платформа: ЦА (аналитика и карта) + ЦП (парсинг Telegram).
## Архитектура ## Архитектура
@@ -18,7 +18,7 @@ flowchart LR
| Центр | Контейнеры | Назначение | | Центр | Контейнеры | Назначение |
|-------|------------|------------| |-------|------------|------------|
| **ЦА** | `ca-db`, `ca-api`, `ca-frontend` | PostgreSQL, ingest API, карта, distribution API | | **ЦА** | `ca-db`, `ca-api`, `ca-frontend` | PostgreSQL, ingest API, карта, distribution API |
| **ЦП** | `cp-workers` | Парсинг Telegram (код из SocialParser) | | **ЦП** | `cp-workers` | Парсинг Telegram (`centers/parsing/workers`) |
| **Общее** | `redis` | Очередь заданий ЦА → ЦП | | **Общее** | `redis` | Очередь заданий ЦА → ЦП |
## Структура monorepo ## Структура monorepo
@@ -32,24 +32,24 @@ MapMil/
│ └── parsing/ │ └── parsing/
│ └── workers/ # CP workers (Telethon) │ └── workers/ # CP workers (Telethon)
├── contracts/ # Shared schemas (ingest, jobs) ├── contracts/ # Shared schemas (ingest, jobs)
├── data/ # telegram.session (symlink → SocialParser) ├── data/ # telegram.session (локально, не в git)
├── docker-compose.yml ├── docker-compose.yml
└── .env # TELEGRAM_* из SocialParser └── .env # TELEGRAM_API_ID, TELEGRAM_API_HASH, …
``` ```
## Быстрый старт ## Быстрый старт
1. Скопируйте `.env` из SocialParser (или создайте из `.env.example`): 1. Создайте `.env` из примера и укажите ключи Telegram:
```bash ```bash
cp ../SocialParser/.env .env cp .env.example .env
# отредактируйте TELEGRAM_API_ID и TELEGRAM_API_HASH
``` ```
2. Убедитесь, что сессия Telegram доступна: 2. Положите сессию Telegram в `data/telegram.session` (файл Telethon SQLite). Если мигрируете со старого SocialParser:
```bash ```bash
ls -la data/telegram.session cp ../SocialParser/data/telegram.session data/
# symlink → ../SocialParser/data/telegram.session
``` ```
3. Запуск: 3. Запуск:
@@ -66,7 +66,7 @@ docker compose up --build
| Раздел | Путь | Описание | | Раздел | Путь | Описание |
|--------|------|----------| |--------|------|----------|
| **Карта** | `/` | Интерактивная карта с объектами, таймлайном и CRUD. Поддерживает `?eventId=` для перехода к событию | | **Карта** | `/` | Интерактивная карта событий: навигация по датам (flatpickr), пресеты периода, фильтры региона/темы/источника, подложки Яндекс/OSM/Topo/ESRI, линейка, полноэкранный режим, центрирование по координатам и городам, поиск населённых пунктов (Nominatim). CRUD для ручных объектов (ПКМ). Поддерживает `?eventId=` |
| **Парсеры** | `/parsers` | Создание заданий Telegram-парсинга, таблица статусов с автообновлением (5 с), повтор failed-заданий | | **Парсеры** | `/parsers` | Создание заданий Telegram-парсинга, таблица статусов с автообновлением (5 с), повтор failed-заданий |
| **События** | `/events` | Фильтрация, пагинация, просмотр деталей, ссылка «На карте» для событий с координатами | | **События** | `/events` | Фильтрация, пагинация, просмотр деталей, ссылка «На карте» для событий с координатами |
| **Аналитика** | `/analytics` | KPI-карточки, график динамики ingest за 30 дней, топ населённых пунктов и регионов | | **Аналитика** | `/analytics` | KPI-карточки, график динамики ingest за 30 дней, топ населённых пунктов и регионов |
@@ -76,26 +76,34 @@ docker compose up --build
**Важно:** существующий файл сессии **не удаляется и не пересоздаётся**. **Важно:** существующий файл сессии **не удаляется и не пересоздаётся**.
- Оригинал: `SocialParser/data/telegram.session` - Локально: `data/telegram.session` (SQLite Telethon, в `.gitignore`)
- В репозитории MapMil: `data/telegram.session` — симлинк для локальной разработки - В Docker `cp-workers`: каталог `./data` монтируется как `/data` (read-write)
- В Docker `cp-workers`: каталог `../SocialParser/data` монтируется как `/data` (read-write; Telethon обновляет SQLite-сессию)
- Путь в контейнере: `/data/telegram.session` - Путь в контейнере: `/data/telegram.session`
- Переменные из `.env`: `TELEGRAM_API_ID`, `TELEGRAM_API_HASH`, `TELEGRAM_SESSION_PATH=/data/telegram.session` - Переменные из `.env`: `TELEGRAM_API_ID`, `TELEGRAM_API_HASH`, `TELEGRAM_SESSION_PATH=/data/telegram.session`
> Симлинк не работает внутри Docker — compose монтирует каталог `SocialParser/data` целиком. Файл сессии и `.env` не коммитятся.
Файл сессии и `.env` добавлены в `.gitignore` и не коммитятся.
## API ## API
### Карта (совместимость MapMil) ### Карта
| Метод | Путь | Описание | | Метод | Путь | Описание |
|-------|------|----------| |-------|------|----------|
| GET | `/api/health` | Health check | | GET | `/api/health` | Health check |
| GET | `/api/objects` | Объекты на карте (включая события с координатами) | | GET | `/api/map/objects` | Объекты на карте с данными события; фильтры: `date_from`, `date_to`, `on_date`, `region`, `topic`, `source_type`, `search`, `event_id` |
| GET | `/api/map/filters` | Справочники для фильтров: `regions`, `topics`, `source_types`, `available_dates`, `date_bounds` |
| GET | `/api/objects` | Объекты (legacy, без фильтров события) |
| POST | `/api/objects` | Создать объект вручную | | POST | `/api/objects` | Создать объект вручную |
#### Инструменты карты (UI)
- **Даты:** кнопки ❮❮/❮/❯/❯❯, календарь flatpickr, пресеты периода (1 неделя — 1 год / день)
- **Фильтры:** регион, тема, источник (из `/api/map/filters`)
- **Подложки:** Яндекс Карты / Спутник (ru_RU), OpenStreetMap, OpenTopoMap, ESRI Satellite
- **Инструменты:** линейка (PolylineMeasure), полноэкранный режим, координаты/города, поиск Nominatim
- **Точки:** маркеры из ingest + popup; ручные объекты — CRUD и drag
- **Обновление:** polling `/api/map/objects` каждые 30 с
### ЦА Admin ### ЦА Admin
| Метод | Путь | Описание | | Метод | Путь | Описание |
+2 -1
View File
@@ -4,7 +4,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from .database import Base, engine, get_db from .database import Base, engine, get_db
from .routers import admin, internal, objects, v1 from .routers import admin, internal, map, objects, v1
from .seed import seed_objects, seed_test_consumer from .seed import seed_objects, seed_test_consumer
from .storage import ensure_upload_dir from .storage import ensure_upload_dir
@@ -33,6 +33,7 @@ app.add_middleware(
) )
app.include_router(objects.router) app.include_router(objects.router)
app.include_router(map.router)
app.include_router(internal.router) app.include_router(internal.router)
app.include_router(admin.router) app.include_router(admin.router)
app.include_router(v1.router) app.include_router(v1.router)
+50
View File
@@ -0,0 +1,50 @@
from datetime import datetime
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from ..database import get_db
from ..schemas import MapFiltersRead, MapObjectWithEvent
from ..services.map_query import (
build_map_objects_query,
get_map_filters,
map_object_to_dict,
utc_day_end,
utc_day_start,
)
router = APIRouter(prefix="/api/map", tags=["map"])
@router.get("/objects", response_model=list[MapObjectWithEvent])
def list_map_objects(
date_from: datetime | None = None,
date_to: datetime | None = None,
on_date: datetime | None = Query(default=None, description="Show events for a single day"),
region: str | None = None,
topic: str | None = None,
source_type: str | None = None,
search: str | None = None,
event_id: int | None = None,
db: Session = Depends(get_db),
):
if on_date is not None:
date_from = utc_day_start(on_date)
date_to = utc_day_end(on_date)
query = build_map_objects_query(
db,
date_from=date_from,
date_to=date_to,
region=region,
topic=topic,
source_type=source_type,
search=search,
event_id=event_id,
)
return [map_object_to_dict(obj) for obj in query.all()]
@router.get("/filters", response_model=MapFiltersRead)
def map_filters(db: Session = Depends(get_db)):
return get_map_filters(db)
+23
View File
@@ -28,6 +28,29 @@ class MapObjectRead(BaseModel):
created_at: datetime created_at: datetime
class MapObjectWithEvent(MapObjectRead):
event_date: datetime | None = None
locality: str | None = None
region: str | None = None
topic: str | None = None
source_type: str | None = None
source_url: str | None = None
title: str | None = None
class MapDateBounds(BaseModel):
min: str | None = None
max: str | None = None
class MapFiltersRead(BaseModel):
regions: list[str]
topics: list[str]
source_types: list[str]
available_dates: list[str]
date_bounds: MapDateBounds
class MapObjectUpdate(BaseModel): class MapObjectUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=255) name: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = None description: str | None = None
+2 -44
View File
@@ -1,55 +1,13 @@
import os import os
from datetime import datetime, timezone
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .models import Consumer, ConsumerFilter, MapObject from .models import Consumer, ConsumerFilter
from .services.filtering import hash_api_key from .services.filtering import hash_api_key
SEED_OBJECTS = [
{
"name": "Красная площадь",
"description": "Главная площадь Москвы, исторический центр города.",
"type": "marker",
"latitude": 55.7539,
"longitude": 37.6208,
"created_at": datetime(2018, 5, 9, 12, 0, tzinfo=timezone.utc),
},
{
"name": "ВДНХ",
"description": "Выставка достижений народного хозяйства — крупный выставочный комплекс.",
"type": "zone",
"latitude": 55.8298,
"longitude": 37.6361,
"created_at": datetime(2020, 8, 15, 10, 30, tzinfo=timezone.utc),
},
{
"name": "МГУ",
"description": "Московский государственный университет имени М.В. Ломоносова.",
"type": "point",
"latitude": 55.7033,
"longitude": 37.5307,
"created_at": datetime(2022, 2, 12, 14, 0, tzinfo=timezone.utc),
},
{
"name": "Парк Горького",
"description": "Центральный парк культуры и отдыха имени М. Горького.",
"type": "other",
"latitude": 55.7312,
"longitude": 37.6013,
"created_at": datetime(2024, 6, 1, 9, 0, tzinfo=timezone.utc),
},
]
def seed_objects(db: Session) -> None: def seed_objects(db: Session) -> None:
if db.query(MapObject).count() > 0: """No demo map objects — map shows ingested events only."""
return
for item in SEED_OBJECTS:
db.add(MapObject(**item))
db.commit()
def seed_test_consumer(db: Session) -> None: def seed_test_consumer(db: Session) -> None:
@@ -0,0 +1,168 @@
from datetime import date, datetime, timezone
from sqlalchemy import func, or_
from sqlalchemy.orm import Query, Session, joinedload
from ..models import Event, MapObject
def _effective_date_column():
return func.coalesce(Event.event_date, Event.ingested_at, MapObject.created_at)
def build_map_objects_query(
db: Session,
*,
date_from: datetime | None = None,
date_to: datetime | None = None,
region: str | None = None,
topic: str | None = None,
source_type: str | None = None,
search: str | None = None,
event_id: int | None = None,
) -> Query:
query = (
db.query(MapObject)
.outerjoin(Event, MapObject.event_id == Event.id)
.options(joinedload(MapObject.event))
)
if event_id is not None:
query = query.filter(MapObject.event_id == event_id)
effective_date = _effective_date_column()
if date_from is not None:
query = query.filter(effective_date >= date_from)
if date_to is not None:
query = query.filter(effective_date <= date_to)
if region:
query = query.filter(Event.region == region)
if topic:
query = query.filter(Event.topic == topic)
if source_type:
query = query.filter(Event.source_type == source_type)
if search:
pattern = f"%{search}%"
query = query.filter(
or_(
MapObject.name.ilike(pattern),
MapObject.description.ilike(pattern),
Event.title.ilike(pattern),
Event.locality.ilike(pattern),
Event.description.ilike(pattern),
)
)
return query.order_by(effective_date.desc(), MapObject.id.desc())
def map_object_to_dict(obj: MapObject) -> dict:
event = obj.event
return {
"id": obj.id,
"name": obj.name,
"description": obj.description,
"type": obj.type,
"latitude": obj.latitude,
"longitude": obj.longitude,
"event_id": obj.event_id,
"created_at": obj.created_at,
"event_date": event.event_date if event else obj.created_at,
"locality": event.locality if event else None,
"region": event.region if event else None,
"topic": event.topic if event else None,
"source_type": event.source_type if event else None,
"source_url": event.source_url if event else None,
"title": event.title if event else obj.name,
}
def _date_to_iso(value: date | datetime | None) -> str | None:
if value is None:
return None
if isinstance(value, datetime):
return value.date().isoformat()
return value.isoformat()
def get_map_filters(db: Session) -> dict:
effective_date = _effective_date_column()
date_bounds = (
db.query(
func.min(effective_date).label("min_date"),
func.max(effective_date).label("max_date"),
)
.select_from(MapObject)
.outerjoin(Event, MapObject.event_id == Event.id)
.first()
)
regions = [
row[0]
for row in db.query(Event.region)
.filter(Event.region.isnot(None), Event.region != "")
.distinct()
.order_by(Event.region)
.all()
]
topics = [
row[0]
for row in db.query(Event.topic)
.filter(Event.topic.isnot(None), Event.topic != "")
.distinct()
.order_by(Event.topic)
.all()
]
source_types = [
row[0]
for row in db.query(Event.source_type)
.filter(Event.source_type.isnot(None), Event.source_type != "")
.distinct()
.order_by(Event.source_type)
.all()
]
available_dates = [
_date_to_iso(row[0])
for row in (
db.query(func.date(effective_date))
.select_from(MapObject)
.outerjoin(Event, MapObject.event_id == Event.id)
.filter(effective_date.isnot(None))
.distinct()
.order_by(func.date(effective_date))
.all()
)
if row[0] is not None
]
min_date = date_bounds.min_date if date_bounds else None
max_date = date_bounds.max_date if date_bounds else None
return {
"regions": regions,
"topics": topics,
"source_types": source_types,
"available_dates": available_dates,
"date_bounds": {
"min": min_date.isoformat() if min_date else None,
"max": max_date.isoformat() if max_date else None,
},
}
def utc_day_start(dt: datetime) -> datetime:
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.replace(hour=0, minute=0, second=0, microsecond=0)
def utc_day_end(dt: datetime) -> datetime:
start = utc_day_start(dt)
return start.replace(hour=23, minute=59, second=59, microsecond=999999)
+3
View File
@@ -10,9 +10,12 @@
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="" crossorigin=""
/> />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css" />
<link rel="stylesheet" href="https://ppete2.github.io/Leaflet.PolylineMeasure/Leaflet.PolylineMeasure.css" />
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script src="https://ppete2.github.io/Leaflet.PolylineMeasure/Leaflet.PolylineMeasure.js"></script>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>
File diff suppressed because it is too large Load Diff
+1
View File
@@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"chart.js": "^4.4.7", "chart.js": "^4.4.7",
"flatpickr": "^4.6.13",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-chartjs": "^5.3.2", "vue-chartjs": "^5.3.2",
+21
View File
@@ -0,0 +1,21 @@
import { request } from "./client";
import type { MapFilters, MapObjectWithEvent, MapQueryParams } from "../types/map";
function buildQuery(params: MapQueryParams): string {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== "") {
search.set(key, String(value));
}
}
const qs = search.toString();
return qs ? `?${qs}` : "";
}
export function fetchMapObjects(params: MapQueryParams = {}): Promise<MapObjectWithEvent[]> {
return request<MapObjectWithEvent[]>(`/map/objects${buildQuery(params)}`);
}
export function fetchMapFilters(): Promise<MapFilters> {
return request<MapFilters>("/map/filters");
}
@@ -4,6 +4,8 @@ defineProps<{
y: number; y: number;
target: "map" | "object"; target: "map" | "object";
objectName?: string; objectName?: string;
canEdit?: boolean;
canDelete?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -26,8 +28,9 @@ const emit = defineEmits<{
</template> </template>
<template v-else> <template v-else>
<button type="button" @click="emit('edit')">Редактировать</button> <button v-if="canEdit !== false" type="button" @click="emit('edit')">Редактировать</button>
<button type="button" class="danger" @click="emit('delete')">Удалить</button> <button v-if="canDelete !== false" type="button" class="danger" @click="emit('delete')">Удалить</button>
<p v-if="canEdit === false" class="hint">Событие из ingest только просмотр</p>
</template> </template>
</div> </div>
</template> </template>
@@ -78,4 +81,11 @@ button.danger {
button.danger:hover { button.danger:hover {
background: #fee2e2; background: #fee2e2;
} }
.hint {
margin: 0;
padding: 0.5rem 1rem;
font-size: 0.75rem;
color: #888;
}
</style> </style>
@@ -1,116 +1,66 @@
<script setup lang="ts"> <script setup lang="ts">
import L from "leaflet"; import L from "leaflet";
import { onMounted, onUnmounted, ref, watch } from "vue"; import { onMounted, onUnmounted, watch } from "vue";
import type { MapObject } from "../types/object"; import { useLeafletMap } from "../composables/useLeafletMap";
import type { MapObjectWithEvent } from "../types/map";
const props = defineProps<{ const props = defineProps<{
objects: MapObject[]; objects: MapObjectWithEvent[];
selectedId: number | null; selectedId: number | null;
openPopupId: number | null;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
select: [object: MapObject]; select: [object: MapObjectWithEvent];
contextmenu: [payload: { contextmenu: [payload: {
x: number; x: number;
y: number; y: number;
latitude: number; latitude: number;
longitude: 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 markerById = new Map<number, L.Marker>();
const defaultIcon = L.icon({ function buildPopupHtml(obj: MapObjectWithEvent): string {
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png", const title = obj.title || obj.name;
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png", const date = obj.event_date
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png", ? new Date(obj.event_date).toLocaleDateString("ru-RU")
iconSize: [25, 41], : "—";
iconAnchor: [12, 41], const locality = obj.locality || "—";
popupAnchor: [1, -34], const desc = (obj.description || "").slice(0, 200);
shadowSize: [41, 41], const link = obj.source_url
}); ? `<a href="${obj.source_url}" target="_blank" rel="noopener">Источник</a>`
: "";
const selectedIcon = L.icon({ return `<div class="map-popup"><strong>${title}</strong><br>${locality}<br>${date}<br>${desc}${link ? `<br>${link}` : ""}</div>`;
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 getMarkerIcon(obj: MapObject): L.Icon { function isDraggable(obj: MapObjectWithEvent): boolean {
if (obj.id === props.selectedId) return selectedIcon; return obj.id === props.selectedId && !obj.event_id;
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 syncMarkers() { 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)); const currentIds = new Set(props.objects.map((obj) => obj.id));
for (const [id, marker] of markerById) { for (const [id, marker] of markerById) {
@@ -123,45 +73,67 @@ function syncMarkers() {
for (const obj of props.objects) { for (const obj of props.objects) {
const draggable = isDraggable(obj); const draggable = isDraggable(obj);
let marker = markerById.get(obj.id); let marker = markerById.get(obj.id);
const icon = obj.id === props.selectedId ? selectedIcon : defaultIcon;
if (!marker) { if (!marker) {
marker = L.marker([obj.latitude, obj.longitude], { marker = L.marker([obj.latitude, obj.longitude], { icon, draggable });
icon: getMarkerIcon(obj), marker.bindPopup(buildPopupHtml(obj));
draggable,
});
marker.addTo(markersLayer); marker.addTo(markersLayer);
markerById.set(obj.id, marker); markerById.set(obj.id, marker);
} else { } 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.setLatLng([obj.latitude, obj.longitude]);
} marker.setIcon(icon);
marker.setPopupContent(buildPopupHtml(obj));
marker.setIcon(getMarkerIcon(obj));
if (marker.dragging) { if (marker.dragging) {
if (draggable) { if (draggable) marker.dragging.enable();
marker.dragging.enable(); else marker.dragging.disable();
} 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) { function handleMapContextMenu(event: MouseEvent) {
if (!map || !mapContainer.value) return; if (!map.value || !mapContainer.value) return;
event.preventDefault(); event.preventDefault();
const rect = mapContainer.value.getBoundingClientRect(); const rect = mapContainer.value.getBoundingClientRect();
const point = map.containerPointToLatLng( const point = map.value.containerPointToLatLng(
L.point(event.clientX - rect.left, event.clientY - rect.top), L.point(event.clientX - rect.left, event.clientY - rect.top),
); );
@@ -175,27 +147,25 @@ function handleMapContextMenu(event: MouseEvent) {
} }
onMounted(() => { onMounted(() => {
if (!mapContainer.value) return; initMap(handleMapContextMenu);
emit("ready", api);
map = L.map(mapContainer.value).setView([55.7558, 37.6173], 11); map.value?.on("moveend", () => {
emit("centerChange", api.getCenter());
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);
syncMarkers(); 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(() => { onUnmounted(() => {
mapContainer.value?.removeEventListener("contextmenu", handleMapContextMenu); destroyMap(handleMapContextMenu);
map?.remove();
map = null;
markersLayer = null;
markerById.clear(); markerById.clear();
}); });
@@ -203,10 +173,27 @@ watch(
() => [props.objects, props.selectedId] as const, () => [props.objects, props.selectedId] as const,
() => { () => {
syncMarkers(); syncMarkers();
panToSelected(); panToSelected(false);
}, },
{ deep: true }, { 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> </script>
<template> <template>
@@ -243,7 +230,24 @@ watch(
cursor: grab; cursor: grab;
} }
.map.has-selection :deep(.leaflet-marker-draggable:active) { .map-wrapper :deep(.map-popup) {
cursor: grabbing; 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> </style>
@@ -6,13 +6,18 @@ import {
formatFileSize, formatFileSize,
uploadObjectMedia, uploadObjectMedia,
} from "../api/objects"; } 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<{ 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 loadingMedia = ref(false);
const mediaError = ref(""); const mediaError = ref("");
const uploading = ref(false); const uploading = ref(false);
@@ -22,15 +27,18 @@ const typeLabel = computed(() =>
); );
const formattedDate = computed(() => { const formattedDate = computed(() => {
if (!props.object) return ""; if (!props.object?.event_date && !props.object?.created_at) return "";
return new Date(props.object.created_at).toLocaleString("ru-RU"); 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/"); return media.content_type.startsWith("image/");
} }
function isVideo(media: ObjectMedia): boolean { function isVideo(media: { content_type: string }) {
return media.content_type.startsWith("video/"); return media.content_type.startsWith("video/");
} }
@@ -42,7 +50,6 @@ async function loadMedia() {
loadingMedia.value = true; loadingMedia.value = true;
mediaError.value = ""; mediaError.value = "";
try { try {
mediaItems.value = await fetchObjectMedia(props.object.id); mediaItems.value = await fetchObjectMedia(props.object.id);
} catch (err) { } catch (err) {
@@ -55,16 +62,13 @@ async function loadMedia() {
async function handleUpload(event: Event) { async function handleUpload(event: Event) {
if (!props.object) return; if (!props.object) return;
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
const files = input.files ? Array.from(input.files) : []; const files = input.files ? Array.from(input.files) : [];
input.value = ""; input.value = "";
if (files.length === 0) return; if (files.length === 0) return;
uploading.value = true; uploading.value = true;
mediaError.value = ""; mediaError.value = "";
try { try {
for (const file of files) { for (const file of files) {
const uploaded = await uploadObjectMedia(props.object.id, file); const uploaded = await uploadObjectMedia(props.object.id, file);
@@ -79,7 +83,6 @@ async function handleUpload(event: Event) {
async function handleDelete(mediaId: number) { async function handleDelete(mediaId: number) {
mediaError.value = ""; mediaError.value = "";
try { try {
await deleteObjectMedia(mediaId); await deleteObjectMedia(mediaId);
mediaItems.value = mediaItems.value.filter((item) => item.id !== mediaId); mediaItems.value = mediaItems.value.filter((item) => item.id !== mediaId);
@@ -88,11 +91,7 @@ async function handleDelete(mediaId: number) {
} }
} }
watch( watch(() => props.object?.id, () => loadMedia(), { immediate: true });
() => props.object?.id,
() => loadMedia(),
{ immediate: true },
);
</script> </script>
<template> <template>
@@ -104,26 +103,52 @@ watch(
</div> </div>
<div v-else class="content"> <div v-else class="content">
<h3>{{ object.name }}</h3> <h3>{{ object.title || object.name }}</h3>
<dl> <dl>
<dt>Тип</dt> <dt>Тип</dt>
<dd>{{ typeLabel }}</dd> <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> <dt>Описание</dt>
<dd>{{ object.description || "—" }}</dd> <dd>{{ object.description || "—" }}</dd>
<dt>Координаты</dt> <dt>Координаты</dt>
<dd> <dd>
{{ object.latitude.toFixed(6) }}, {{ object.longitude.toFixed(6) }} {{ object.latitude.toFixed(6) }}, {{ object.longitude.toFixed(6) }}
<span class="drag-hint">Перетащите маркер на карте для перемещения</span> <span v-if="!isIngested" class="drag-hint">Перетащите маркер на карте для перемещения</span>
</dd> </dd>
<dt>Создан</dt> <dt>Дата события</dt>
<dd>{{ formattedDate }}</dd> <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> </dl>
<section class="media-section"> <section v-if="!isIngested" class="media-section">
<div class="media-header"> <div class="media-header">
<h4>Медиа</h4> <h4>Медиа</h4>
<label class="upload-btn"> <label class="upload-btn">
@@ -156,15 +181,11 @@ watch(
<a v-else :href="item.url" class="file-link" target="_blank" rel="noopener"> <a v-else :href="item.url" class="file-link" target="_blank" rel="noopener">
{{ item.original_name }} {{ item.original_name }}
</a> </a>
<div class="media-meta"> <div class="media-meta">
<span class="name" :title="item.original_name">{{ item.original_name }}</span> <span class="name" :title="item.original_name">{{ item.original_name }}</span>
<span class="size">{{ formatFileSize(item.size) }}</span> <span class="size">{{ formatFileSize(item.size) }}</span>
</div> </div>
<button type="button" class="delete-btn" @click="handleDelete(item.id)">Удалить</button>
<button type="button" class="delete-btn" @click="handleDelete(item.id)">
Удалить
</button>
</li> </li>
</ul> </ul>
</section> </section>
@@ -234,6 +255,21 @@ dd {
color: #2563eb; 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 { .media-section {
margin-top: 1.5rem; margin-top: 1.5rem;
padding-top: 1rem; padding-top: 1rem;
@@ -263,12 +299,6 @@ dd {
cursor: pointer; cursor: pointer;
} }
.upload-btn input:disabled + span,
.upload-btn:has(input:disabled) {
opacity: 0.6;
cursor: not-allowed;
}
.media-status { .media-status {
margin: 0; margin: 0;
font-size: 0.875rem; 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>
@@ -0,0 +1,288 @@
import L from "leaflet";
import { onUnmounted, ref, shallowRef } from "vue";
const DEFAULT_CENTER: L.LatLngExpression = [48.257381, 37.134785];
const DEFAULT_ZOOM = 10;
export interface LeafletMapApi {
flyTo: (lat: number, lng: number, zoom?: number) => void;
fitBounds: (bounds: L.LatLngBoundsExpression, options?: L.FitBoundsOptions) => void;
getCenter: () => { lat: number; lng: number };
getZoom: () => number;
invalidateSize: () => void;
setSearchMarker: (lat: number, lng: number, label?: string) => void;
clearSearchMarker: () => void;
}
export function useLeafletMap() {
const mapContainer = ref<HTMLElement | null>(null);
const map = shallowRef<L.Map | null>(null);
const centerCoords = ref({ lat: DEFAULT_CENTER[0] as number, lng: DEFAULT_CENTER[1] as number });
let markersLayer: L.LayerGroup | null = null;
let searchMarker: L.Marker | null = null;
let measureControl: L.Control | null = null;
let measureActive = false;
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],
});
const searchIcon = L.icon({
iconUrl: "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-green.png",
iconRetinaUrl: "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-green.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 createBaseLayers(): Record<string, L.TileLayer> {
const yandexAttribution =
'&copy; <a href="https://yandex.ru/maps" target="_blank" rel="noopener">Яндекс</a>';
return {
"Яндекс Карты": L.tileLayer(
"https://core-renderer-tiles.maps.yandex.net/tiles?l=map&x={x}&y={y}&z={z}&scale=1&lang=ru_RU",
{
attribution: yandexAttribution,
noWrap: true,
maxZoom: 19,
},
),
"Яндекс Спутник": L.tileLayer(
"https://core-renderer-tiles.maps.yandex.net/tiles?l=sat&x={x}&y={y}&z={z}&scale=1&lang=ru_RU",
{
attribution: yandexAttribution,
noWrap: true,
maxZoom: 19,
},
),
OpenStreetMap: L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19,
}),
OpenTopoMap: L.tileLayer("https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png", {
attribution:
"Kartendaten: &copy; OpenStreetMap, SRTM | &copy; OpenTopoMap (CC-BY-SA)",
maxZoom: 17,
}),
"ESRI Satellite": L.tileLayer(
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
{
attribution: "Tiles &copy; Esri",
maxZoom: 19,
},
),
};
}
function addFullscreenControl(leafletMap: L.Map) {
const control = L.control({ position: "topright" });
control.onAdd = () => {
const div = L.DomUtil.create("div", "leaflet-bar leaflet-control");
const link = L.DomUtil.create("a", "map-control-btn", div);
link.href = "#";
link.title = "Полноэкранный режим";
link.innerHTML = "⛶";
L.DomEvent.disableClickPropagation(div);
L.DomEvent.on(link, "click", (e) => {
L.DomEvent.preventDefault(e);
const el = mapContainer.value?.closest(".map-page") ?? mapContainer.value;
if (!el) return;
if (!document.fullscreenElement) {
void el.requestFullscreen?.();
} else {
void document.exitFullscreen?.();
}
});
return div;
};
control.addTo(leafletMap);
}
function addMeasureControl(leafletMap: L.Map) {
const control = L.control({ position: "topright" });
control.onAdd = () => {
const div = L.DomUtil.create("div", "leaflet-bar leaflet-control");
const link = L.DomUtil.create("a", "map-control-btn", div);
link.href = "#";
link.title = "Линейка";
link.innerHTML = "📏";
L.DomEvent.disableClickPropagation(div);
L.DomEvent.on(link, "click", (e) => {
L.DomEvent.preventDefault(e);
if (typeof L.control.polylineMeasure !== "function") return;
if (!measureControl) {
measureControl = L.control.polylineMeasure({
position: "topright",
unit: "kilometres",
clearMeasurementsOnStop: true,
showMeasurementsClearControl: true,
});
}
if (!measureActive) {
measureControl.addTo(leafletMap);
measureActive = true;
link.classList.add("active");
} else {
leafletMap.removeControl(measureControl);
measureActive = false;
link.classList.remove("active");
}
});
return div;
};
control.addTo(leafletMap);
}
function initMap(onContextMenu: (event: MouseEvent) => void): LeafletMapApi {
if (!mapContainer.value || map.value) {
return api;
}
const leafletMap = L.map(mapContainer.value, { preferCanvas: true }).setView(
DEFAULT_CENTER,
DEFAULT_ZOOM,
);
const baseLayers = createBaseLayers();
baseLayers["Яндекс Карты"].addTo(leafletMap);
L.control.layers(baseLayers, undefined, { collapsed: true, position: "topright" }).addTo(
leafletMap,
);
markersLayer = L.layerGroup().addTo(leafletMap);
addFullscreenControl(leafletMap);
addMeasureControl(leafletMap);
leafletMap.on("moveend", () => {
const c = leafletMap.getCenter();
centerCoords.value = { lat: c.lat, lng: c.lng };
});
mapContainer.value.addEventListener("contextmenu", onContextMenu);
map.value = leafletMap;
setTimeout(() => leafletMap.invalidateSize(), 0);
return api;
}
function destroyMap(onContextMenu: (event: MouseEvent) => void) {
mapContainer.value?.removeEventListener("contextmenu", onContextMenu);
if (measureControl && map.value && measureActive) {
map.value.removeControl(measureControl);
}
map.value?.remove();
map.value = null;
markersLayer = null;
searchMarker = null;
measureControl = null;
measureActive = false;
}
function flyTo(lat: number, lng: number, zoom?: number) {
if (!map.value) return;
const z = zoom ?? Math.max(map.value.getZoom(), 12);
map.value.flyTo([lat, lng], z, { duration: 0.6 });
}
function fitBounds(bounds: L.LatLngBoundsExpression, options?: L.FitBoundsOptions) {
map.value?.fitBounds(bounds, { padding: [40, 40], maxZoom: 14, ...options });
}
function getCenter() {
if (map.value) {
const c = map.value.getCenter();
return { lat: c.lat, lng: c.lng };
}
return { ...centerCoords.value };
}
function getZoom() {
return map.value?.getZoom() ?? DEFAULT_ZOOM;
}
function invalidateSize() {
map.value?.invalidateSize();
}
function setSearchMarker(lat: number, lng: number, label?: string) {
if (!map.value || !markersLayer) return;
if (searchMarker) {
markersLayer.removeLayer(searchMarker);
}
searchMarker = L.marker([lat, lng], { icon: searchIcon });
if (label) {
searchMarker.bindPopup(label);
}
searchMarker.addTo(markersLayer);
flyTo(lat, lng, 13);
searchMarker.openPopup();
}
function clearSearchMarker() {
if (searchMarker && markersLayer) {
markersLayer.removeLayer(searchMarker);
searchMarker = null;
}
}
function getMarkerIcons() {
return { defaultIcon, selectedIcon };
}
function getMarkersLayer() {
return markersLayer;
}
const api: LeafletMapApi = {
flyTo,
fitBounds,
getCenter,
getZoom,
invalidateSize,
setSearchMarker,
clearSearchMarker,
};
onUnmounted(() => {
destroyMap(() => undefined);
});
return {
mapContainer,
map,
centerCoords,
initMap,
destroyMap,
flyTo,
fitBounds,
getCenter,
getZoom,
invalidateSize,
setSearchMarker,
clearSearchMarker,
getMarkerIcons,
getMarkersLayer,
api,
};
}
@@ -0,0 +1,89 @@
const NOMINATIM_ENDPOINT = "https://nominatim.openstreetmap.org/search";
const REQUEST_DELAY_MS = 1000;
const ALLOWED_PLACE_TYPES = new Set([
"city", "town", "village", "suburb", "hamlet",
"neighbourhood", "neighborhood", "locality",
"isolated_dwelling", "allotments", "quarter",
]);
const ALLOWED_ADDRESS_TYPES = new Set([
"city", "town", "village", "hamlet", "municipality",
"locality", "suburb", "neighbourhood", "administrative",
]);
export interface PlaceSearchResult {
name: string;
lat: number;
lon: number;
}
let lastRequestTs = 0;
async function throttledFetch(url: string): Promise<Response> {
const now = Date.now();
const elapsed = now - lastRequestTs;
if (elapsed < REQUEST_DELAY_MS) {
await new Promise((resolve) => setTimeout(resolve, REQUEST_DELAY_MS - elapsed));
}
lastRequestTs = Date.now();
return fetch(url, {
headers: {
"Accept-Language": "ru,uk",
"User-Agent": "MapMil/1.0",
},
});
}
export async function searchPlaces(query: string): Promise<PlaceSearchResult[]> {
if (!query.trim()) return [];
const params = new URLSearchParams({
format: "json",
limit: "20",
addressdetails: "1",
"accept-language": "ru,uk",
countrycodes: "ua",
q: query.trim(),
bounded: "1",
viewbox: "22.128,44.386,40.080,52.379",
});
const response = await throttledFetch(`${NOMINATIM_ENDPOINT}?${params.toString()}`);
if (!response.ok) return [];
const data = (await response.json()) as Array<{
lat: string;
lon: string;
display_name: string;
class?: string;
type?: string;
addresstype?: string;
}>;
return data
.filter((item) => {
const placeClass = item.class ?? "";
const placeType = item.type ?? "";
const addressType = item.addresstype ?? "";
return (
(placeClass === "place" && ALLOWED_PLACE_TYPES.has(placeType)) ||
(placeClass === "boundary" && ALLOWED_ADDRESS_TYPES.has(addressType))
);
})
.map((item) => ({
name: item.display_name,
lat: Number(item.lat),
lon: Number(item.lon),
}));
}
export function parseCoordsInput(raw: string): { lat: number; lng: number } | null {
const parts = raw.split(/[,\s]+/).map((p) => p.trim()).filter(Boolean);
if (parts.length < 2) return null;
const lat = Number(parts[0]);
const lng = Number(parts[1]);
if (Number.isNaN(lat) || Number.isNaN(lng)) return null;
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return null;
return { lat, lng };
}
@@ -0,0 +1,17 @@
export interface CityPreset {
name: string;
latitude: number;
longitude: number;
zoom?: number;
}
export const CITY_PRESETS: CityPreset[] = [
{ name: "Сумы", latitude: 50.919052, longitude: 34.82872, zoom: 11 },
{ name: "Гуляйполе", latitude: 47.6517, longitude: 36.2569, zoom: 12 },
{ name: "Краматорск", latitude: 48.7233, longitude: 37.5564, zoom: 11 },
{ name: "Бахмут", latitude: 48.5959, longitude: 38.0002, zoom: 12 },
{ name: "Харьков", latitude: 49.9935, longitude: 36.2304, zoom: 10 },
{ name: "Донецк", latitude: 48.0159, longitude: 37.8028, zoom: 10 },
{ name: "Запорожье", latitude: 47.8388, longitude: 35.1396, zoom: 10 },
{ name: "Киев", latitude: 50.4501, longitude: 30.5234, zoom: 10 },
];
@@ -112,4 +112,8 @@ const pageTitle = computed(() => {
min-height: 0; min-height: 0;
overflow: auto; overflow: auto;
} }
.admin-main:has(.map-page) {
overflow: hidden;
}
</style> </style>
@@ -0,0 +1,7 @@
import "leaflet";
declare module "leaflet" {
namespace control {
function polylineMeasure(options?: Record<string, unknown>): Control;
}
}
+1
View File
@@ -2,5 +2,6 @@ import { createApp } from "vue";
import App from "./App.vue"; import App from "./App.vue";
import router from "./router"; import router from "./router";
import "./style.css"; import "./style.css";
import "./styles/map-toolbar.css";
createApp(App).use(router).mount("#app"); createApp(App).use(router).mount("#app");
@@ -0,0 +1,274 @@
.map-toolbar {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: #fff;
border-bottom: 1px solid #e0e0e0;
flex-shrink: 0;
}
.date-navigator {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
}
.nav-btn,
.filter-btn {
width: 30px;
height: 30px;
border: 1px solid #ccc;
border-radius: 4px;
background: #f0f0f0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.85rem;
padding: 0;
}
.nav-btn:hover,
.filter-btn:hover {
background: #e0e0e0;
}
.date-selector {
width: 90px;
}
.date-picker-input {
width: 100%;
height: 30px;
padding: 0 6px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 0.85rem;
text-align: center;
box-sizing: border-box;
}
.filter-btn-container {
position: relative;
}
.filter-btn.active {
border-color: #007bff;
background: #e6f2ff;
}
.dropdown-content {
position: absolute;
top: 100%;
left: 0;
z-index: 1000;
background: #fff;
border: 1px solid #ddd;
border-radius: 5px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
margin-top: 4px;
min-width: 140px;
}
.range-option {
display: block;
width: 100%;
padding: 8px 14px;
border: none;
background: none;
text-align: left;
cursor: pointer;
font-size: 0.85rem;
}
.range-option:hover {
background: #f0f8ff;
}
.range-option.active {
background: #007bff;
color: #fff;
}
.filter-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.filter-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.filter-select {
height: 30px;
padding: 0 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 0.85rem;
max-width: 200px;
}
.toolbar-status {
margin-left: auto;
font-size: 0.8rem;
color: #888;
}
.coords-tools {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.75rem;
background: #fafafa;
border-bottom: 1px solid #eee;
font-size: 0.85rem;
}
.coords-label,
.current-center-label {
color: #555;
}
.coord-input,
.search-input {
height: 28px;
padding: 0 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 0.85rem;
width: 150px;
}
.city-select {
height: 28px;
padding: 0 6px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 0.85rem;
}
.current-coords {
font-family: ui-monospace, monospace;
background: #f5f5f5;
padding: 2px 6px;
border-radius: 3px;
}
.icon-btn {
width: 28px;
height: 28px;
border: 1px solid #ccc;
border-radius: 4px;
background: #f0f0f0;
cursor: pointer;
font-size: 0.85rem;
}
.place-search {
display: flex;
align-items: center;
gap: 0.35rem;
position: relative;
}
.search-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1000;
list-style: none;
margin: 4px 0 0;
padding: 0;
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
max-height: 200px;
overflow-y: auto;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.result-btn {
display: block;
width: 100%;
padding: 8px 10px;
border: none;
background: none;
text-align: left;
cursor: pointer;
font-size: 0.8rem;
}
.result-btn:hover {
background: #f0f8ff;
}
.map-page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.map-page:fullscreen {
background: #fff;
}
.map-tools-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.75rem;
background: #fafafa;
border-bottom: 1px solid #eee;
}
.map-content {
position: relative;
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
.map-status {
padding: 0.35rem 0.75rem;
font-size: 0.8rem;
color: #666;
background: #fff;
border-bottom: 1px solid #eee;
}
.map-status.error {
color: #c62828;
}
@media (max-width: 768px) {
.filter-controls {
display: none;
width: 100%;
}
.filter-controls.open {
display: flex;
}
.mobile-toggle {
display: flex;
}
}
@media (min-width: 769px) {
.mobile-toggle {
display: none;
}
}
@@ -0,0 +1,39 @@
import type { MapObject, ObjectType } from "./object";
export interface MapObjectWithEvent extends MapObject {
event_date: string | null;
locality: string | null;
region: string | null;
topic: string | null;
source_type: string | null;
source_url: string | null;
title: string | null;
}
export interface MapDateBounds {
min: string | null;
max: string | null;
}
export interface MapFilters {
regions: string[];
topics: string[];
source_types: string[];
available_dates: string[];
date_bounds: MapDateBounds;
}
export type DateRangePreset = "week" | "month" | "3months" | "6months" | "year" | "all";
export interface MapQueryParams {
on_date?: string;
date_from?: string;
date_to?: string;
region?: string;
topic?: string;
source_type?: string;
search?: string;
event_id?: number;
}
export type { ObjectType };
@@ -1,91 +1,127 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue"; import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { useRoute } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { fetchMapFilters, fetchMapObjects } from "../api/map";
import { import {
createObject, createObject,
deleteObject, deleteObject,
fetchObjects,
updateObject, updateObject,
uploadObjectMedia, uploadObjectMedia,
} from "../api/objects"; } from "../api/objects";
import { fetchEvents } from "../api/admin";
import ContextMenu from "../components/ContextMenu.vue"; import ContextMenu from "../components/ContextMenu.vue";
import CreateObjectModal from "../components/CreateObjectModal.vue"; import CreateObjectModal from "../components/CreateObjectModal.vue";
import EditObjectModal from "../components/EditObjectModal.vue"; import EditObjectModal from "../components/EditObjectModal.vue";
import MapView from "../components/MapView.vue"; import MapView from "../components/MapView.vue";
import CoordsTools from "../components/map/CoordsTools.vue";
import MapToolbar from "../components/map/MapToolbar.vue";
import PlaceSearch from "../components/map/PlaceSearch.vue";
import ObjectPanel from "../components/ObjectPanel.vue"; import ObjectPanel from "../components/ObjectPanel.vue";
import TimelineBar from "../components/TimelineBar.vue"; import type { LeafletMapApi } from "../composables/useLeafletMap";
import type { MapObject, MapObjectCreate, ObjectType } from "../types/object"; import type { DateRangePreset, MapFilters, MapObjectWithEvent, MapQueryParams } from "../types/map";
import type { MapObjectCreate, ObjectType } from "../types/object";
const route = useRoute(); const route = useRoute();
const router = useRouter();
const objects = ref<MapObject[]>([]); const objects = ref<MapObjectWithEvent[]>([]);
const selectedObject = ref<MapObject | null>(null); const mapFilters = ref<MapFilters | null>(null);
const selectedObject = ref<MapObjectWithEvent | null>(null);
const loading = ref(true); const loading = ref(true);
const error = ref(""); const error = ref("");
const timelinePosition = ref(Date.now()); const mapApi = ref<LeafletMapApi | null>(null);
const centerCoords = ref({ lat: 48.257381, lng: 37.134785 });
const openPopupId = ref<number | null>(null);
const contextMenu = ref<{ const selectedDate = ref<string | null>(null);
visible: boolean; const rangePreset = ref<DateRangePreset>("all");
x: number; const region = ref("");
y: number; const topic = ref("");
latitude: number; const sourceType = ref("");
longitude: number;
target: "map" | "object"; let pollTimer: ReturnType<typeof setInterval> | null = null;
object: MapObject | null; let mapViewRef = ref<InstanceType<typeof MapView> | null>(null);
}>({
const contextMenu = ref({
visible: false, visible: false,
x: 0, x: 0,
y: 0, y: 0,
latitude: 0, latitude: 0,
longitude: 0, longitude: 0,
target: "map", target: "map" as "map" | "object",
object: null, object: null as MapObjectWithEvent | null,
}); });
const createModal = ref({ const createModal = ref({ visible: false, latitude: 0, longitude: 0 });
visible: false, const editModal = ref({ visible: false, object: null as MapObjectWithEvent | null });
latitude: 0,
longitude: 0,
});
const editModal = ref({
visible: false,
object: null as MapObject | null,
});
const selectedId = computed(() => selectedObject.value?.id ?? null); const selectedId = computed(() => selectedObject.value?.id ?? null);
function objectTime(obj: MapObject): number { function subtractDays(isoDate: string, days: number): string {
return new Date(obj.created_at).getTime(); const d = new Date(isoDate);
d.setUTCDate(d.getUTCDate() - days);
return d.toISOString().slice(0, 10);
} }
function replaceObject(updated: MapObject) { function endOfDayIso(isoDate: string): string {
objects.value = objects.value.map((obj) => (obj.id === updated.id ? updated : obj)); return `${isoDate}T23:59:59.999Z`;
if (selectedObject.value?.id === updated.id) {
selectedObject.value = updated;
}
} }
const timelineBounds = computed(() => { function startOfDayIso(isoDate: string): string {
if (objects.value.length === 0) { return `${isoDate}T00:00:00.000Z`;
const now = Date.now(); }
return { min: now, max: now };
function buildQueryParams(): MapQueryParams {
const params: MapQueryParams = {};
if (region.value) params.region = region.value;
if (topic.value) params.topic = topic.value;
if (sourceType.value) params.source_type = sourceType.value;
const eventId = route.query.eventId;
if (eventId) {
params.event_id = Number(eventId);
return params;
} }
const times = objects.value.map(objectTime); if (!selectedDate.value) return params;
return {
min: Math.min(...times), if (rangePreset.value === "all") {
max: Math.max(...times), params.on_date = selectedDate.value;
return params;
}
const end = selectedDate.value;
const ranges: Record<DateRangePreset, number> = {
week: 7,
month: 30,
"3months": 90,
"6months": 180,
year: 365,
all: 0,
}; };
}); const days = ranges[rangePreset.value];
params.date_from = startOfDayIso(subtractDays(end, days));
params.date_to = endOfDayIso(end);
return params;
}
const visibleObjects = computed(() => async function loadFilters() {
objects.value.filter((obj) => objectTime(obj) <= timelinePosition.value), mapFilters.value = await fetchMapFilters();
); if (!selectedDate.value && mapFilters.value.available_dates.length > 0) {
selectedDate.value = mapFilters.value.available_dates.at(-1) ?? null;
}
}
function syncTimelineToMax() { async function loadObjects() {
timelinePosition.value = timelineBounds.value.max; loading.value = true;
error.value = "";
try {
objects.value = await fetchMapObjects(buildQueryParams());
selectByEventId(route.query.eventId as string | undefined);
} catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось загрузить объекты";
} finally {
loading.value = false;
}
} }
function selectByEventId(eventId: string | undefined) { function selectByEventId(eventId: string | undefined) {
@@ -96,51 +132,27 @@ function selectByEventId(eventId: string | undefined) {
const match = objects.value.find((obj) => obj.event_id === id); const match = objects.value.find((obj) => obj.event_id === id);
if (match) { if (match) {
selectedObject.value = match; selectedObject.value = match;
timelinePosition.value = Math.max(timelinePosition.value, objectTime(match)); openPopupId.value = match.id;
return; if (match.event_date) {
selectedDate.value = match.event_date.slice(0, 10);
} }
void fetchEvents({ limit: 500 }).then((response) => {
const event = response.items.find((item) => item.id === id);
if (!event || event.latitude == null || event.longitude == null) {
error.value = "Событие не найдено на карте";
return;
}
const eventTime = event.event_date
? new Date(event.event_date).getTime()
: Date.now();
timelinePosition.value = Math.max(timelinePosition.value, eventTime);
const byCoords = objects.value.find(
(obj) =>
Math.abs(obj.latitude - event.latitude!) < 1e-4 &&
Math.abs(obj.longitude - event.longitude!) < 1e-4,
);
if (byCoords) {
selectedObject.value = byCoords;
} else { } else {
error.value = "Объект карты для события ещё не создан"; error.value = "Событие не найдено на карте";
}
});
}
async function loadObjects() {
loading.value = true;
error.value = "";
try {
objects.value = await fetchObjects();
syncTimelineToMax();
selectByEventId(route.query.eventId as string | undefined);
} catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось загрузить объекты";
} finally {
loading.value = false;
} }
} }
function handleSelectObject(obj: MapObject) { function onMapReady(api: LeafletMapApi) {
mapApi.value = api;
centerCoords.value = api.getCenter();
}
function onCenterChange(coords: { lat: number; lng: number }) {
centerCoords.value = coords;
}
function handleSelectObject(obj: MapObjectWithEvent) {
selectedObject.value = obj; selectedObject.value = obj;
openPopupId.value = obj.id;
} }
function handleMapContextMenu(payload: { function handleMapContextMenu(payload: {
@@ -148,7 +160,7 @@ function handleMapContextMenu(payload: {
y: number; y: number;
latitude: number; latitude: number;
longitude: number; longitude: number;
object: MapObject | null; object: MapObjectWithEvent | null;
}) { }) {
contextMenu.value = { contextMenu.value = {
visible: true, visible: true,
@@ -159,10 +171,7 @@ function handleMapContextMenu(payload: {
target: payload.object ? "object" : "map", target: payload.object ? "object" : "map",
object: payload.object, object: payload.object,
}; };
if (payload.object) selectedObject.value = payload.object;
if (payload.object) {
selectedObject.value = payload.object;
}
} }
function closeContextMenu() { function closeContextMenu() {
@@ -183,18 +192,13 @@ function closeCreateModal() {
} }
function openEditModal() { function openEditModal() {
if (!contextMenu.value.object) return; if (!contextMenu.value.object || contextMenu.value.object.event_id) return;
editModal.value = { visible: true, object: contextMenu.value.object };
editModal.value = {
visible: true,
object: contextMenu.value.object,
};
closeContextMenu(); closeContextMenu();
} }
function closeEditModal() { function closeEditModal() {
editModal.value.visible = false; editModal.value = { visible: false, object: null };
editModal.value.object = null;
} }
async function handleCreateObject(payload: { async function handleCreateObject(payload: {
@@ -212,16 +216,13 @@ async function handleCreateObject(payload: {
longitude: createModal.value.longitude, longitude: createModal.value.longitude,
created_at: payload.created_at, created_at: payload.created_at,
}; };
const created = await createObject(data); const created = await createObject(data);
for (const file of payload.files) { for (const file of payload.files) {
await uploadObjectMedia(created.id, file); await uploadObjectMedia(created.id, file);
} }
await loadObjects();
objects.value = [...objects.value, created]; const match = objects.value.find((o) => o.id === created.id);
selectedObject.value = created; if (match) selectedObject.value = match;
timelinePosition.value = objectTime(created);
closeCreateModal(); closeCreateModal();
} }
@@ -232,103 +233,116 @@ async function handleEditObject(payload: {
created_at: string; created_at: string;
}) { }) {
if (!editModal.value.object) return; if (!editModal.value.object) return;
await updateObject(editModal.value.object.id, payload);
const updated = await updateObject(editModal.value.object.id, payload); await loadObjects();
replaceObject(updated);
timelinePosition.value = objectTime(updated);
closeEditModal(); closeEditModal();
} }
async function handleDeleteObject() { async function handleDeleteObject() {
const object = contextMenu.value.object; const object = contextMenu.value.object;
if (!object) return; if (!object || object.event_id) return;
if (!window.confirm(`Удалить объект «${object.name}»?`)) return;
const confirmed = window.confirm(`Удалить объект «${object.name}»?`);
if (!confirmed) return;
closeContextMenu(); closeContextMenu();
try { try {
await deleteObject(object.id); await deleteObject(object.id);
objects.value = objects.value.filter((item) => item.id !== object.id); if (selectedObject.value?.id === object.id) selectedObject.value = null;
await loadObjects();
if (selectedObject.value?.id === object.id) {
selectedObject.value = null;
}
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось удалить объект"; error.value = err instanceof Error ? err.message : "Не удалось удалить объект";
} }
} }
async function handleMoveObject(payload: { async function handleMoveObject(payload: {
object: MapObject; object: MapObjectWithEvent;
latitude: number; latitude: number;
longitude: number; longitude: number;
}) { }) {
if (payload.object.event_id) return;
try { try {
const updated = await updateObject(payload.object.id, { await updateObject(payload.object.id, {
latitude: payload.latitude, latitude: payload.latitude,
longitude: payload.longitude, longitude: payload.longitude,
}); });
replaceObject(updated); await loadObjects();
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось переместить объект"; error.value = err instanceof Error ? err.message : "Не удалось переместить объект";
} }
} }
watch(visibleObjects, (visible) => { function openInEvents() {
if ( if (!selectedObject.value?.event_id) return;
selectedObject.value && void router.push({ path: "/events", query: { highlight: String(selectedObject.value.event_id) } });
!visible.some((obj) => obj.id === selectedObject.value?.id) }
) {
selectedObject.value = null;
}
});
watch( watch(
() => route.query.eventId, () => route.query.eventId,
(eventId) => selectByEventId(eventId as string | undefined), (eventId) => {
if (eventId) void loadObjects();
else selectByEventId(undefined);
},
); );
onMounted(loadObjects); watch(mapViewRef, (view) => {
if (view?.centerCoords) {
centerCoords.value = view.centerCoords;
}
});
onMounted(async () => {
await loadFilters();
await loadObjects();
pollTimer = setInterval(() => void loadObjects(), 30000);
});
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer);
});
</script> </script>
<template> <template>
<div class="map-page" @click="closeContextMenu"> <div class="map-page" @click="closeContextMenu">
<div class="map-status"> <MapToolbar
<span v-if="loading" class="status">Загрузка...</span> v-model:selected-date="selectedDate"
<span v-else-if="error" class="status error">{{ error }}</span> v-model:range-preset="rangePreset"
<span v-else class="status"> v-model:region="region"
{{ visibleObjects.length }} / {{ objects.length }} объектов на карте v-model:topic="topic"
</span> v-model:source-type="sourceType"
:filters="mapFilters"
:loading="loading"
@apply="loadObjects"
/>
<div class="map-tools-row">
<CoordsTools :map-api="mapApi" :center-coords="centerCoords" />
<PlaceSearch :map-api="mapApi" />
</div> </div>
<div v-if="error" class="map-status error">{{ error }}</div>
<div v-else class="map-status">{{ objects.length }} объектов на карте</div>
<div class="map-content"> <div class="map-content">
<MapView <MapView
:objects="visibleObjects" ref="mapViewRef"
:objects="objects"
:selected-id="selectedId" :selected-id="selectedId"
:open-popup-id="openPopupId"
@ready="onMapReady"
@center-change="onCenterChange"
@select="handleSelectObject" @select="handleSelectObject"
@contextmenu="handleMapContextMenu" @contextmenu="handleMapContextMenu"
@move="handleMoveObject" @move="handleMoveObject"
/> />
<ObjectPanel :object="selectedObject" /> <ObjectPanel :object="selectedObject" @open-events="openInEvents" />
</div> </div>
<TimelineBar
v-if="!loading && objects.length > 0"
v-model="timelinePosition"
:min="timelineBounds.min"
:max="timelineBounds.max"
:objects="objects"
:visible-count="visibleObjects.length"
/>
<ContextMenu <ContextMenu
v-if="contextMenu.visible" v-if="contextMenu.visible"
:x="contextMenu.x" :x="contextMenu.x"
:y="contextMenu.y" :y="contextMenu.y"
:target="contextMenu.target" :target="contextMenu.target"
:object-name="contextMenu.object?.name" :object-name="contextMenu.object?.name"
:can-edit="!contextMenu.object?.event_id"
:can-delete="!contextMenu.object?.event_id"
@create="openCreateModal" @create="openCreateModal"
@edit="openEditModal" @edit="openEditModal"
@delete="handleDeleteObject" @delete="handleDeleteObject"
@@ -350,34 +364,3 @@ onMounted(loadObjects);
/> />
</div> </div>
</template> </template>
<style scoped>
.map-page {
display: flex;
flex-direction: column;
height: 100%;
}
.map-status {
padding: 0.4rem 1.25rem;
background: #fafafa;
border-bottom: 1px solid #eee;
font-size: 0.875rem;
}
.status {
color: #666;
}
.status.error {
color: #c62828;
}
.map-content {
position: relative;
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
</style>
+1 -2
View File
@@ -52,8 +52,7 @@ services:
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0
INTERNAL_TOKEN: dev-internal-token INTERNAL_TOKEN: dev-internal-token
volumes: volumes:
# Mount SocialParser data dir (session + SQLite journal); same file as on host - ./data:/data
- ../SocialParser/data:/data
depends_on: depends_on:
- redis - redis
- ca-api - ca-api