diff --git a/.env.example b/.env.example
index a8a8970..02d246f 100644
--- a/.env.example
+++ b/.env.example
@@ -1 +1,2 @@
VITE_API_BASE_URL=http://localhost:8000/api/v1
+VITE_YANDEX_MAPS_API_KEY=
diff --git a/README.md b/README.md
index ae6f1b6..483bda7 100644
--- a/README.md
+++ b/README.md
@@ -83,7 +83,7 @@
- `React + TypeScript + Vite`
- `zustand`
- `react-konva`
-- `leaflet / react-leaflet`
+- `Yandex Maps JS API 2.1`
## Требования
@@ -116,6 +116,7 @@ http://localhost:5173
```env
VITE_API_BASE_URL=https://api.dev.parktrack.live/api/v1
+VITE_YANDEX_MAPS_API_KEY=your-yandex-maps-api-key
```
Если переменная не задана:
@@ -125,6 +126,16 @@ VITE_API_BASE_URL=https://api.dev.parktrack.live/api/v1
Ручного поля `API Base` в интерфейсе нет: конечный пользователь не должен настраивать backend-адрес внутри UI.
+## Yandex Maps
+
+Карты камер и геометрии зон работают через Yandex Maps JS API 2.1. Для staging / production нужно передавать ключ на этапе сборки:
+
+```env
+VITE_YANDEX_MAPS_API_KEY=your-yandex-maps-api-key
+```
+
+Для совместимости также поддерживается старое имя `VITE_YMAPS_API_KEY`, но новое значение `VITE_YANDEX_MAPS_API_KEY` предпочтительнее.
+
## Как приложение работает с backend
Фронтенд ожидает backend ParkTrack API с ресурсами:
diff --git a/package-lock.json b/package-lock.json
index e2f3c35..610a8e7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,16 +10,13 @@
"dependencies": {
"clsx": "^2.1.1",
"konva": "^9.3.16",
- "leaflet": "^1.9.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-konva": "^18.2.10",
- "react-leaflet": "^4.2.1",
"use-image": "^1.1.3",
"zustand": "^4.5.5"
},
"devDependencies": {
- "@types/leaflet": "^1.9.21",
"@types/node": "^24.8.1",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
@@ -59,7 +56,6 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -752,17 +748,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
- "node_modules/@react-leaflet/core": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
- "integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==",
- "license": "Hippocratic-2.1",
- "peerDependencies": {
- "leaflet": "^1.9.0",
- "react": "^18.0.0",
- "react-dom": "^18.0.0"
- }
- },
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -1130,30 +1115,12 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@types/geojson": {
- "version": "7946.0.16",
- "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
- "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/leaflet": {
- "version": "1.9.21",
- "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
- "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/geojson": "*"
- }
- },
"node_modules/@types/node": {
"version": "24.8.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.8.1.tgz",
"integrity": "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"undici-types": "~7.14.0"
}
@@ -1169,7 +1136,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz",
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -1245,7 +1211,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746",
@@ -1464,15 +1429,7 @@
"url": "https://github.com/sponsors/lavrton"
}
],
- "license": "MIT",
- "peer": true
- },
- "node_modules/leaflet": {
- "version": "1.9.4",
- "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
- "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
- "license": "BSD-2-Clause",
- "peer": true
+ "license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
@@ -1570,7 +1527,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -1583,7 +1539,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -1623,20 +1578,6 @@
"react-dom": ">=18.0.0"
}
},
- "node_modules/react-leaflet": {
- "version": "4.2.1",
- "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
- "integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
- "license": "Hippocratic-2.1",
- "dependencies": {
- "@react-leaflet/core": "^2.1.0"
- },
- "peerDependencies": {
- "leaflet": "^1.9.0",
- "react": "^18.0.0",
- "react-dom": "^18.0.0"
- }
- },
"node_modules/react-reconciler": {
"version": "0.29.2",
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz",
@@ -1811,7 +1752,6 @@
"integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
diff --git a/package.json b/package.json
index 524ab0d..b78f149 100644
--- a/package.json
+++ b/package.json
@@ -11,16 +11,13 @@
"dependencies": {
"clsx": "^2.1.1",
"konva": "^9.3.16",
- "leaflet": "^1.9.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-konva": "^18.2.10",
- "react-leaflet": "^4.2.1",
"use-image": "^1.1.3",
"zustand": "^4.5.5"
},
"devDependencies": {
- "@types/leaflet": "^1.9.21",
"@types/node": "^24.8.1",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
diff --git a/src/components/CameraMapSelector.tsx b/src/components/CameraMapSelector.tsx
index 2f06db6..f7832d8 100644
--- a/src/components/CameraMapSelector.tsx
+++ b/src/components/CameraMapSelector.tsx
@@ -1,48 +1,95 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
import { useStore } from '@/store/useStore';
import { api, Camera } from '@/api/client';
import { Button, Field, Input } from './UiKit';
-import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet';
-import L, { LatLng, LatLngExpression } from 'leaflet';
import { useFeedbackStore } from '@/feedback/feedbackStore';
+import { yandexPoint, type YandexPoint } from '@/maps/yandex';
+import { useYandexMap } from '@/maps/useYandexMap';
-const cameraIcon = L.divIcon({
- className: 'camera-marker-selected',
- html: '
',
- iconSize: [18, 18],
- iconAnchor: [9, 9]
-});
-
-function ClickHandler({ onClick }: { onClick: (pos: LatLng) => void }) {
- useMapEvents({
- click(e) {
- onClick(e.latlng);
- }
- });
- return null;
+type MapPoint = {
+ lat: number;
+ lng: number;
+};
+
+function hasCoordinates(latitude?: number | null, longitude?: number | null): latitude is number {
+ return typeof latitude === 'number'
+ && Number.isFinite(latitude)
+ && typeof longitude === 'number'
+ && Number.isFinite(longitude);
}
-function MapAutoFocus({ point, camera }: { point: LatLng | null; camera: Camera | null }) {
- const map = useMap();
+function toYandexPoint(point: MapPoint): YandexPoint {
+ return yandexPoint(point.lat, point.lng);
+}
+
+function CameraLocationMap({
+ center,
+ point,
+ camera,
+ onPointChange
+}: {
+ center: YandexPoint;
+ point: MapPoint | null;
+ camera: Camera | null;
+ onPointChange: (point: MapPoint) => void;
+}) {
+ const mapRef = useRef(null);
+ const { ymaps, map, loading, error } = useYandexMap(mapRef, { center, zoom: 15 });
useEffect(() => {
+ if (!map) return;
if (point) {
- map.setView(point, 17);
+ map.setCenter(toYandexPoint(point), 17, { duration: 200 });
return;
}
if (hasCoordinates(camera?.latitude, camera?.longitude)) {
- map.setView([camera.latitude, camera.longitude], 17);
+ map.setCenter(yandexPoint(camera.latitude, camera.longitude), 17, { duration: 200 });
}
- }, [point, camera, map]);
+ }, [map, point, camera?.camera_id]);
- return null;
-}
+ useEffect(() => {
+ if (!map) return;
+ const onClick = (event: any) => {
+ const coords = event.get('coords') as YandexPoint;
+ onPointChange({ lat: coords[0], lng: coords[1] });
+ };
+ map.events.add('click', onClick);
+ return () => {
+ map.events.remove('click', onClick);
+ };
+ }, [map, onPointChange]);
-function hasCoordinates(latitude?: number | null, longitude?: number | null): latitude is number {
- return typeof latitude === 'number'
- && Number.isFinite(latitude)
- && typeof longitude === 'number'
- && Number.isFinite(longitude);
+ useEffect(() => {
+ if (!ymaps || !map || !point) return;
+ const placemark = new ymaps.Placemark(
+ toYandexPoint(point),
+ {
+ hintContent: 'Положение камеры'
+ },
+ {
+ preset: 'islands#circleDotIcon',
+ iconColor: '#ff7a45',
+ draggable: true
+ }
+ );
+
+ placemark.events.add('dragend', () => {
+ const coords = placemark.geometry.getCoordinates() as YandexPoint;
+ onPointChange({ lat: coords[0], lng: coords[1] });
+ });
+
+ map.geoObjects.add(placemark);
+ return () => {
+ map.geoObjects.remove(placemark);
+ };
+ }, [ymaps, map, point, onPointChange]);
+
+ return (
+
+ {loading &&
Загрузка Яндекс.Карт...
}
+ {error &&
{error}
}
+
+ );
}
export default function CameraMapSelector() {
@@ -52,7 +99,7 @@ export default function CameraMapSelector() {
const [camera, setCamera] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState();
- const [point, setPoint] = useState(null);
+ const [point, setPoint] = useState(null);
const [latInput, setLatInput] = useState('');
const [lngInput, setLngInput] = useState('');
@@ -74,8 +121,7 @@ export default function CameraMapSelector() {
if (cancelled) return;
setCamera(cam);
if (hasCoordinates(cam.latitude, cam.longitude)) {
- const newPoint = new L.LatLng(cam.latitude, cam.longitude);
- setPoint(newPoint);
+ setPoint({ lat: cam.latitude, lng: cam.longitude });
setLatInput(cam.latitude.toString());
setLngInput(cam.longitude.toString());
} else {
@@ -113,7 +159,7 @@ export default function CameraMapSelector() {
const lat = parseFloat(value);
if (!isNaN(lat) && lat >= -90 && lat <= 90) {
const lng = point ? point.lng : (parseFloat(lngInput) || 30.3141);
- setPoint(new L.LatLng(lat, lng));
+ setPoint({ lat, lng });
}
}
@@ -122,16 +168,16 @@ export default function CameraMapSelector() {
const lng = parseFloat(value);
if (!isNaN(lng) && lng >= -180 && lng <= 180) {
const lat = point ? point.lat : (parseFloat(latInput) || 59.9386);
- setPoint(new L.LatLng(lat, lng));
+ setPoint({ lat, lng });
}
}
- const center: LatLngExpression = useMemo(() => {
- if (point) return point;
+ const center = useMemo(() => {
+ if (point) return toYandexPoint(point);
if (hasCoordinates(camera?.latitude, camera?.longitude)) {
- return [camera.latitude, camera.longitude];
+ return yandexPoint(camera.latitude, camera.longitude);
}
- return [59.9386, 30.3141];
+ return yandexPoint(59.9386, 30.3141);
}, [camera, point]);
async function onSave() {
@@ -170,7 +216,7 @@ export default function CameraMapSelector() {
Текущие координаты: {camera.latitude?.toFixed(6)}, {camera.longitude?.toFixed(6)}
)}
- {loading && Загрузка…
}
+ {loading && Загрузка...
}
{error && {error}
}
@@ -204,15 +250,12 @@ export default function CameraMapSelector() {
-
-
-
-
- {point && }
-
+
>
);
diff --git a/src/components/CamerasPage.tsx b/src/components/CamerasPage.tsx
index 8406527..6edcfb6 100644
--- a/src/components/CamerasPage.tsx
+++ b/src/components/CamerasPage.tsx
@@ -3,20 +3,11 @@ import { useStore } from '@/store/useStore';
import { api, Camera, CameraSnapshot, CreateCameraRequest } from '@/api/client';
import { Button, Field, Input, Select, Textarea } from './UiKit';
import { BulkActionBar, BulkSelectionCheckbox } from './BulkActionBar';
-import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
-import L, { LatLngExpression } from 'leaflet';
import { navigate } from '@/router/routes';
import { useFeedbackStore } from '@/feedback/feedbackStore';
import { useSessionStore } from '@/auth/sessionStore';
-
-const defaultIcon = L.icon({
- iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
- shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
- iconAnchor: [12, 41],
- popupAnchor: [1, -34],
- shadowAnchor: [12, 41]
-});
-L.Marker.prototype.options.icon = defaultIcon;
+import { fitYandexMap, yandexPoint, type YandexPoint } from '@/maps/yandex';
+import { useYandexMap } from '@/maps/useYandexMap';
function hasCoordinates(latitude?: number | null, longitude?: number | null): latitude is number {
return typeof latitude === 'number'
@@ -25,33 +16,22 @@ function hasCoordinates(latitude?: number | null, longitude?: number | null): la
&& Number.isFinite(longitude);
}
-function MapAutoCenter({ cameras, selectedId }: { cameras: Camera[]; selectedId?: number }) {
- const map = useMap();
-
- useEffect(() => {
- if (!cameras.length) return;
- const selected = cameras.find(c => c.camera_id === selectedId && hasCoordinates(c.latitude, c.longitude));
- if (selected) {
- map.setView([selected.latitude, selected.longitude], 17);
- return;
- }
- const pts = cameras
- .filter(c => hasCoordinates(c.latitude, c.longitude))
- .map(c => [c.latitude, c.longitude] as [number, number]);
- if (!pts.length) return;
- const bounds = L.latLngBounds(pts);
- map.fitBounds(bounds.pad(0.2));
- }, [cameras, selectedId, map]);
-
- return null;
-}
-
type SnapshotState = {
loading: boolean;
error?: string;
data?: CameraSnapshot;
};
+function snapshotCacheKey(cameraId: number, annotated: boolean) {
+ return `${cameraId}:${annotated ? 'annotated' : 'frame'}`;
+}
+
+function revokeSnapshotData(data?: CameraSnapshot) {
+ if (data?.image_url?.startsWith('blob:')) {
+ URL.revokeObjectURL(data.image_url);
+ }
+}
+
type CameraEditorState = {
title: string;
source: string;
@@ -103,6 +83,130 @@ function normalizeEditor(editor: CameraEditorState) {
};
}
+function escapeHtml(value: string) {
+ return value
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+}
+
+function cameraMapPoint(camera?: Camera): YandexPoint | null {
+ if (!camera || !hasCoordinates(camera.latitude, camera.longitude)) return null;
+ return yandexPoint(camera.latitude, camera.longitude);
+}
+
+function YandexCamerasMap({
+ cameras,
+ selectedId,
+ hoverId,
+ onSelect,
+ onHover,
+ onOpenLabeler
+}: {
+ cameras: Camera[];
+ selectedId?: number;
+ hoverId?: number;
+ onSelect: (cameraId: number) => void;
+ onHover: (cameraId?: number) => void;
+ onOpenLabeler: (camera: Camera) => void;
+}) {
+ const mapRef = useRef(null);
+ const selectedCamera = cameras.find(camera => camera.camera_id === selectedId);
+ const firstCamera = cameras.find(camera => hasCoordinates(camera.latitude, camera.longitude));
+ const center = cameraMapPoint(selectedCamera) ?? cameraMapPoint(firstCamera) ?? yandexPoint(59.9386, 30.3141);
+ const { ymaps, map, loading, error } = useYandexMap(mapRef, {
+ center,
+ zoom: selectedCamera ? 17 : 14
+ });
+
+ useEffect(() => {
+ if (!map) return;
+ const selectedPoint = cameraMapPoint(selectedCamera);
+ if (selectedPoint) {
+ map.setCenter(selectedPoint, 17, { duration: 200 });
+ return;
+ }
+
+ const points = cameras
+ .map(cameraMapPoint)
+ .filter((point): point is YandexPoint => Boolean(point));
+ if (points.length) {
+ fitYandexMap(map, points, 14);
+ return;
+ }
+ map.setCenter(yandexPoint(59.9386, 30.3141), 14, { duration: 200 });
+ }, [map, cameras, selectedCamera?.camera_id]);
+
+ useEffect(() => {
+ if (!ymaps || !map) return;
+
+ const collection = new ymaps.GeoObjectCollection();
+ cameras
+ .filter(camera => hasCoordinates(camera.latitude, camera.longitude))
+ .forEach(camera => {
+ const isSelected = camera.camera_id === selectedId;
+ const isHover = camera.camera_id === hoverId;
+ const isActive = camera.is_active !== false;
+ const color = !isActive ? '#ff4d4f' : isSelected ? '#ff7a45' : isHover ? '#ffd666' : '#2f54eb';
+ const placemark = new ymaps.Placemark(
+ yandexPoint(camera.latitude, camera.longitude),
+ {
+ hintContent: camera.title,
+ balloonContent: `
+
+
${escapeHtml(camera.title)}
+
ID: ${camera.camera_id}
+
+
+ `
+ },
+ {
+ preset: 'islands#circleDotIcon',
+ iconColor: color
+ }
+ );
+
+ placemark.events.add('click', () => onSelect(camera.camera_id));
+ placemark.events.add('mouseenter', () => onHover(camera.camera_id));
+ placemark.events.add('mouseleave', () => onHover(undefined));
+ collection.add(placemark);
+ });
+
+ map.geoObjects.add(collection);
+ return () => {
+ map.geoObjects.remove(collection);
+ };
+ }, [ymaps, map, cameras, selectedId, hoverId, onSelect, onHover]);
+
+ useEffect(() => {
+ function onBalloonAction(event: MouseEvent) {
+ const target = event.target instanceof Element
+ ? event.target.closest('[data-yandex-camera-labeler]')
+ : null;
+ if (!target) return;
+ const cameraId = Number((target as HTMLElement).dataset.yandexCameraLabeler);
+ const camera = cameras.find(item => item.camera_id === cameraId);
+ if (camera) {
+ onOpenLabeler(camera);
+ }
+ }
+
+ document.addEventListener('click', onBalloonAction);
+ return () => document.removeEventListener('click', onBalloonAction);
+ }, [cameras, onOpenLabeler]);
+
+ return (
+
+ {loading &&
Загрузка Яндекс.Карт...
}
+ {error &&
{error}
}
+
+ );
+}
+
type MediaSize = {
width: number;
height: number;
@@ -182,8 +286,12 @@ export default function CamerasPage() {
isActive: 'all'
});
const snapshotPreviewRef = useRef(null);
+ const snapshotCacheRef = useRef