Refactor map tools and drop SocialParser runtime dependency.
Add filtered map API, creamy-caprice-style toolbar (dates, layers, search), Yandex ru tiles, and store Telegram session in MapMil/data instead of mounting SocialParser. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
| Метод | Путь | Описание |
|
| Метод | Путь | Описание |
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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>
|
||||||
|
|||||||
+1476
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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: '© <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 =
|
||||||
|
'© <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: '© <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: © OpenStreetMap, SRTM | © 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 © 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user