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>(new Map()); + const snapshotRequestsRef = useRef>>(new Map()); + const snapshotCacheAliveRef = useRef(true); const [snapshot, setSnapshot] = useState({ loading: false }); const [snapshotReloadKey, setSnapshotReloadKey] = useState(0); + const [snapshotAnnotated, setSnapshotAnnotated] = useState(true); const [isSnapshotFullscreen, setIsSnapshotFullscreen] = useState(false); const [editor, setEditor] = useState(null); const [saveState, setSaveState] = useState({ loading: false }); @@ -201,6 +309,74 @@ export default function CamerasPage() { return JSON.stringify(normalizeEditor(editor)) !== JSON.stringify(normalizeEditor(cameraToEditor(selectedCamera))); }, [selectedCamera, editor]); + useEffect(() => { + return () => { + snapshotCacheAliveRef.current = false; + for (const cachedSnapshot of snapshotCacheRef.current.values()) { + revokeSnapshotData(cachedSnapshot); + } + snapshotCacheRef.current.clear(); + snapshotRequestsRef.current.clear(); + }; + }, []); + + function fetchSnapshot(cameraId: number, annotated: boolean) { + const key = snapshotCacheKey(cameraId, annotated); + const cached = snapshotCacheRef.current.get(key); + if (cached) return Promise.resolve(cached); + + const inFlight = snapshotRequestsRef.current.get(key); + if (inFlight) return inFlight; + + let request: Promise; + request = api.getSnapshot(cameraId, { + annotated, + fallback_to_raw: true + }).then(data => { + if (!snapshotCacheAliveRef.current) { + revokeSnapshotData(data); + return data; + } + + if (snapshotRequestsRef.current.get(key) !== request) { + revokeSnapshotData(data); + return data; + } + + const previous = snapshotCacheRef.current.get(key); + if (previous && previous.image_url !== data.image_url) { + revokeSnapshotData(previous); + } + snapshotCacheRef.current.set(key, data); + return data; + }).finally(() => { + if (snapshotRequestsRef.current.get(key) === request) { + snapshotRequestsRef.current.delete(key); + } + }); + + snapshotRequestsRef.current.set(key, request); + return request; + } + + function prefetchOtherSnapshotMode(cameraId: number, annotated: boolean) { + const otherMode = !annotated; + const key = snapshotCacheKey(cameraId, otherMode); + if (snapshotCacheRef.current.has(key) || snapshotRequestsRef.current.has(key)) return; + fetchSnapshot(cameraId, otherMode).catch(() => undefined); + } + + function refreshCurrentSnapshot() { + if (selectedCamera) { + const key = snapshotCacheKey(selectedCamera.camera_id, snapshotAnnotated); + const cached = snapshotCacheRef.current.get(key); + revokeSnapshotData(cached); + snapshotCacheRef.current.delete(key); + snapshotRequestsRef.current.delete(key); + } + setSnapshotReloadKey(key => key + 1); + } + async function loadCameras() { setLoading(true); setError(undefined); @@ -278,7 +454,6 @@ export default function CamerasPage() { useEffect(() => { let cancelled = false; - let lastImageUrl: string | undefined; async function loadSnapshot() { if (!selectedCamera) { @@ -286,15 +461,20 @@ export default function CamerasPage() { return; } + const key = snapshotCacheKey(selectedCamera.camera_id, snapshotAnnotated); + const cached = snapshotCacheRef.current.get(key); + if (cached) { + setSnapshot({ loading: false, data: cached }); + prefetchOtherSnapshotMode(selectedCamera.camera_id, snapshotAnnotated); + return; + } + setSnapshot({ loading: true }); try { - const data = await api.getSnapshot(selectedCamera.camera_id, { - annotated: true, - fallback_to_raw: true - }); + const data = await fetchSnapshot(selectedCamera.camera_id, snapshotAnnotated); if (!cancelled) { - lastImageUrl = data.image_url; setSnapshot({ loading: false, data }); + prefetchOtherSnapshotMode(selectedCamera.camera_id, snapshotAnnotated); } } catch (e: any) { if (!cancelled) { @@ -306,11 +486,8 @@ export default function CamerasPage() { loadSnapshot(); return () => { cancelled = true; - if (lastImageUrl?.startsWith('blob:')) { - URL.revokeObjectURL(lastImageUrl); - } }; - }, [selectedCamera?.camera_id, snapshotReloadKey]); + }, [selectedCamera?.camera_id, snapshotAnnotated, snapshotReloadKey]); useEffect(() => { if (!selectedCamera) { @@ -322,12 +499,6 @@ export default function CamerasPage() { setSaveState({ loading: false }); }, [selectedCamera?.camera_id]); - const center: LatLngExpression = useMemo(() => { - const first = cameras.find(c => hasCoordinates(c.latitude, c.longitude)); - if (first) return [first.latitude, first.longitude]; - return [59.9386, 30.3141]; - }, [cameras]); - function openLabeler(cam: Camera) { setLabelerReturnRoute('cameras'); setCamera(String(cam.camera_id)); @@ -744,8 +915,29 @@ export default function CamerasPage() { {saveState.error &&
{saveState.error}
}
-

Распознанные автомобили

+
+

{snapshotAnnotated ? 'Распознанные автомобили' : 'Текущий кадр'}

+
+ {snapshotAnnotated ? 'Разметка включена' : 'Разметка скрыта'} +
+
+
+ + +
{snapshot.data?.image_url && ( @@ -846,52 +1038,14 @@ export default function CamerasPage() {
- - - - {cameras.filter(c => hasCoordinates(c.latitude, c.longitude)).map(cam => { - const isActive = cam.camera_id === selectedId; - const isHover = cam.camera_id === hoverId; - const isCameraActive = cam.is_active !== false; - let color = '#2f54eb'; - if (!isCameraActive) color = '#ff4d4f'; - else if (isActive) color = '#ff7a45'; - else if (isHover) color = '#ffd666'; - - const icon = L.divIcon({ - className: 'camera-marker', - html: `
`, - iconSize: [isActive ? 18 : 12, isActive ? 18 : 12], - iconAnchor: [9, 9] - }); - - return ( - setSelectedId(cam.camera_id), - mouseover: () => setHoverId(cam.camera_id), - mouseout: () => setHoverId(id => (id === cam.camera_id ? undefined : id)) - }} - icon={icon} - > - -
-
{cam.title}
-
ID: {cam.camera_id}
-
- -
-
-
-
- ); - })} -
+
); diff --git a/src/components/ZoneMapSelector.tsx b/src/components/ZoneMapSelector.tsx index 0d78d43..276f7f1 100644 --- a/src/components/ZoneMapSelector.tsx +++ b/src/components/ZoneMapSelector.tsx @@ -1,18 +1,14 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useStore } from '@/store/useStore'; import { Button } from './UiKit'; -import { MapContainer, TileLayer, Polygon, Polyline, Marker, useMapEvents, useMap } from 'react-leaflet'; -import L, { LatLng, LatLngExpression } from 'leaflet'; import { useFeedbackStore } from '@/feedback/feedbackStore'; +import { fitYandexMap, yandexPoint, type YandexPoint } from '@/maps/yandex'; +import { useYandexMap } from '@/maps/useYandexMap'; -type LatLngTuple = [number, number]; - -const zonePointIcon = L.divIcon({ - className: 'zone-point-marker', - html: '
', - iconSize: [28, 28], - iconAnchor: [14, 14] -}); +type MapPoint = { + lat: number; + lng: number; +}; function hasCoordinates(latitude?: number | null, longitude?: number | null): latitude is number { return typeof latitude === 'number' @@ -21,98 +17,239 @@ function hasCoordinates(latitude?: number | null, longitude?: number | null): la && Number.isFinite(longitude); } -function ClickHandler({ onClick }: { onClick: (pos: LatLng) => void }) { - useMapEvents({ - click(e) { - onClick(e.latlng); - } - }); - return null; +function toYandexPoint(point: MapPoint): YandexPoint { + return yandexPoint(point.lat, point.lng); } -function MapAutoFit({ points, fitVersion }: { points: LatLng[]; fitVersion: number }) { - const map = useMap(); - - useEffect(() => { - if (fitVersion === 0) return; - if (points.length === 0) return; - const bounds = L.latLngBounds(points); - map.fitBounds(bounds.pad(0.2)); - }, [fitVersion, map]); +function fromYandexPoint(point: YandexPoint): MapPoint { + return { lat: point[0], lng: point[1] }; +} - return null; +function stopYandexEventPropagation(event: any) { + if (typeof event?.stopPropagation === 'function') event.stopPropagation(); } -function DraggableZoneShape({ +function YandexZoneGeometryMap({ + center, points, - onMove, - onMoveEnd, + fitVersion, + onMapClick, + onPointsCommit, onInteractionStart }: { - points: LatLng[]; - onMove: (points: LatLng[]) => void; - onMoveEnd: (points: LatLng[]) => void; + center: YandexPoint; + points: MapPoint[]; + fitVersion: number; + onMapClick: (point: MapPoint) => void; + onPointsCommit: (points: MapPoint[]) => void; onInteractionStart: () => void; }) { - const map = useMap(); - const line = points.map(p => [p.lat, p.lng] as LatLngTuple); - const isComplete = points.length === 4; - const dragLine = isComplete ? [...line, line[0]] : line; - - function onMouseDown(e: any) { - if (points.length < 2) return; - L.DomEvent.stop(e); - onInteractionStart(); - map.dragging.disable(); - - const start = e.latlng as LatLng; - const origin = points.map(point => new L.LatLng(point.lat, point.lng)); - let latest = origin; - - const onMouseMove = (moveEvent: any) => { - const current = moveEvent.latlng as LatLng; - const latDelta = current.lat - start.lat; - const lngDelta = current.lng - start.lng; - latest = origin.map(point => new L.LatLng(point.lat + latDelta, point.lng + lngDelta)); - onMove(latest); + const mapRef = useRef(null); + const pointsRef = useRef(points); + const { ymaps, map, loading, error } = useYandexMap(mapRef, { center, zoom: 16 }); + + useEffect(() => { + pointsRef.current = points; + }, [points]); + + useEffect(() => { + if (!map || points.length > 0) return; + map.setCenter(center, 16, { duration: 200 }); + }, [map, center, points.length]); + + useEffect(() => { + const pointsToFit = pointsRef.current; + if (!map || fitVersion === 0 || pointsToFit.length === 0) return; + fitYandexMap(map, pointsToFit.map(toYandexPoint), 16); + }, [map, fitVersion]); + + useEffect(() => { + if (!map) return; + const onClick = (event: any) => { + const coords = event.get('coords') as YandexPoint | undefined; + if (!coords) return; + onMapClick(fromYandexPoint(coords)); + }; + map.events.add('click', onClick); + return () => { + map.events.remove('click', onClick); }; + }, [map, onMapClick]); - const onMouseUp = () => { - map.off('mousemove', onMouseMove); - map.off('mouseup', onMouseUp); - map.dragging.enable(); + useEffect(() => { + if (!ymaps || !map) return; + + const collection = new ymaps.GeoObjectCollection(); + const isComplete = points.length === 4; + const placemarks: any[] = []; + let shape: any; + let dragLine: any; + + const visibleLineCoordinates = (nextPoints: MapPoint[]) => nextPoints.map(toYandexPoint); + const dragLineCoordinates = (nextPoints: MapPoint[]) => { + const coordinates = visibleLineCoordinates(nextPoints); + return isComplete && coordinates.length > 0 + ? [...coordinates, coordinates[0]] + : coordinates; + }; + + const updateMapGeometryFromDrag = (nextPoints: MapPoint[], source?: any) => { + const coordinates = visibleLineCoordinates(nextPoints); + if (shape && shape !== source) { + shape.geometry.setCoordinates(isComplete ? [coordinates] : coordinates); + } + if (dragLine && dragLine !== source) { + dragLine.geometry.setCoordinates(dragLineCoordinates(nextPoints)); + } + placemarks.forEach((placemark, index) => { + if (placemark === source) return; + const point = nextPoints[index]; + if (point) { + placemark.geometry.setCoordinates(toYandexPoint(point)); + } + }); + }; + + const pointsFromDraggedGeometry = (geoObject: any) => { + const coordinates = geoObject.geometry.getCoordinates(); + const line = Array.isArray(coordinates?.[0]?.[0]) + ? coordinates[0] + : coordinates; + return line + .slice(0, points.length) + .map((coordinate: YandexPoint) => fromYandexPoint(coordinate)); + }; + + const onDragStart = (event: any) => { + stopYandexEventPropagation(event); onInteractionStart(); - onMoveEnd(latest); + map.behaviors.disable(['drag']); }; - map.on('mousemove', onMouseMove); - map.on('mouseup', onMouseUp); - } + const onShapeDrag = (source: any) => { + const latest = pointsFromDraggedGeometry(source); + if (latest.length !== points.length) return; + updateMapGeometryFromDrag(latest, source); + }; + + const onPointDrag = (pointIndex: number, placemark: any) => { + const latest = points.map(point => ({ ...point })); + const current = placemark.geometry.getCoordinates() as YandexPoint | undefined; + if (!current) return; + latest[pointIndex] = fromYandexPoint(current); + updateMapGeometryFromDrag(latest, placemark); + }; + + const onShapeDragEnd = (source: any) => { + const latest = pointsFromDraggedGeometry(source); + if (latest.length === points.length) { + updateMapGeometryFromDrag(latest, source); + onPointsCommit(latest); + } + map.behaviors.enable(['drag']); + onInteractionStart(); + }; + + const onPointDragEnd = (pointIndex: number, placemark: any) => { + const latest = points.map(point => ({ ...point })); + const current = placemark.geometry.getCoordinates() as YandexPoint | undefined; + if (current) { + latest[pointIndex] = fromYandexPoint(current); + updateMapGeometryFromDrag(latest, placemark); + onPointsCommit(latest); + } + map.behaviors.enable(['drag']); + onInteractionStart(); + }; + + if (points.length >= 2) { + const coordinates = points.map(toYandexPoint); + shape = isComplete + ? new ymaps.Polygon( + [coordinates], + {}, + { + strokeColor: '#ff7a45', + strokeOpacity: 0.92, + strokeWidth: 2, + fillColor: '#ff7a453d', + fillOpacity: 0.24, + zIndex: 250, + draggable: true + } + ) + : new ymaps.Polyline( + coordinates, + {}, + { + strokeColor: '#ff7a45', + strokeOpacity: 0.92, + strokeWidth: 2, + strokeStyle: 'dash', + zIndex: 250, + draggable: true + } + ); + + dragLine = new ymaps.Polyline( + dragLineCoordinates(points), + {}, + { + strokeColor: '#ff7a45', + strokeOpacity: 0.01, + strokeWidth: 22, + zIndex: 200, + cursor: 'move', + draggable: true + } + ); + + shape.events.add('dragstart', onDragStart); + shape.events.add('drag', () => onShapeDrag(shape)); + shape.events.add('dragend', () => onShapeDragEnd(shape)); + dragLine.events.add('dragstart', onDragStart); + dragLine.events.add('drag', () => onShapeDrag(dragLine)); + dragLine.events.add('dragend', () => onShapeDragEnd(dragLine)); + collection.add(shape); + collection.add(dragLine); + } + + points.forEach((point, index) => { + const placemark = new ymaps.Placemark( + toYandexPoint(point), + { + hintContent: `Точка ${index + 1}` + }, + { + preset: 'islands#circleIcon', + iconColor: '#ffd43b', + zIndex: 1000, + zIndexHover: 1100, + zIndexActive: 1200, + cursor: 'move', + draggable: true + } + ); + + placemark.events.add('dragstart', onDragStart); + placemark.events.add('drag', () => onPointDrag(index, placemark)); + placemark.events.add('dragend', () => onPointDragEnd(index, placemark)); + placemarks.push(placemark); + collection.add(placemark); + }); + + map.geoObjects.add(collection); + return () => { + map.behaviors.enable(['drag']); + map.geoObjects.remove(collection); + }; + }, [ymaps, map, points, onInteractionStart, onMapClick, onPointsCommit]); return ( - <> - {isComplete ? ( - `${p.lat},${p.lng}`).join(';')}`} - positions={line} - pathOptions={{ color: '#ff7a45', fillOpacity: 0.24, weight: 2, className: 'zone-map-visible-line' }} - eventHandlers={{ mousedown: onMouseDown }} - /> - ) : ( - `${p.lat},${p.lng}`).join(';')}`} - positions={line} - pathOptions={{ color: '#ff7a45', dashArray: '10, 5', weight: 2, className: 'zone-map-visible-line' }} - eventHandlers={{ mousedown: onMouseDown }} - /> - )} - `${p.lat},${p.lng}`).join(';')}`} - positions={dragLine} - pathOptions={{ color: '#ff7a45', opacity: 0.01, weight: 18, lineCap: 'round', lineJoin: 'round', className: 'zone-map-drag-line' }} - eventHandlers={{ mousedown: onMouseDown }} - /> - +
+ {loading &&
Загрузка Яндекс.Карт...
} + {error &&
{error}
} +
); } @@ -128,7 +265,7 @@ export default function ZoneMapSelector() { const saveZone = useStore(state => state.saveZone); const zone = zones.find(z => String(z.id) === String(activeZoneId)); - const [points, setPoints] = useState([]); + const [points, setPoints] = useState([]); const [fitVersion, setFitVersion] = useState(0); const [loading, setLoading] = useState(false); const [error, setError] = useState(); @@ -139,35 +276,32 @@ export default function ZoneMapSelector() { setPoints([]); return; } - // Load existing zone points, but only if they have unique coordinates - // (ignore if all points have same coords, which happens when camera coords were used as default) const existing = zone.points - .filter(p => typeof p.latitude === 'number' && typeof p.longitude === 'number') - .slice(0, 4) as any[]; - - const uniqueCoords = new Set(existing.map(p => `${p.latitude},${p.longitude}`)); - + .filter(point => typeof point.latitude === 'number' && typeof point.longitude === 'number') + .slice(0, 4); + const uniqueCoords = new Set(existing.map(point => `${point.latitude},${point.longitude}`)); + if (existing.length === 4 && uniqueCoords.size > 1) { - setPoints(existing.map(p => new L.LatLng(p.latitude!, p.longitude!))); + setPoints(existing.map(point => ({ lat: point.latitude!, lng: point.longitude! }))); setFitVersion(version => version + 1); } else { setPoints([]); } }, [zone]); - const center: LatLngExpression = useMemo(() => { + const center = useMemo(() => { if (points.length > 0) { - const lat = points.reduce((s, p) => s + p.lat, 0) / points.length; - const lng = points.reduce((s, p) => s + p.lng, 0) / points.length; - return [lat, lng]; + const lat = points.reduce((sum, point) => sum + point.lat, 0) / points.length; + const lng = points.reduce((sum, point) => sum + point.lng, 0) / points.length; + return yandexPoint(lat, lng); } if (hasCoordinates(cameraMeta?.latitude, cameraMeta?.longitude)) { - return [cameraMeta.latitude, cameraMeta.longitude]; + return yandexPoint(cameraMeta.latitude, cameraMeta.longitude); } - return [59.9386, 30.3141]; + return yandexPoint(59.9386, 30.3141); }, [points, cameraMeta]); - function onMapClick(pos: LatLng) { + function onMapClick(pos: MapPoint) { if (Date.now() < suppressMapClickUntilRef.current) return; if (points.length >= 4) return; setPoints(prev => [...prev, pos]); @@ -178,23 +312,28 @@ export default function ZoneMapSelector() { suppressMapClickUntilRef.current = Date.now() + 350; } - function syncZonePoints(nextPoints: LatLng[]) { + function syncZonePoints(nextPoints: MapPoint[]) { if (!zone || nextPoints.length !== 4) return; - const updatedZonePoints = zone.points.map((pt, i) => { - if (i < 4) { - const p = nextPoints[i]; - return { ...pt, latitude: p.lat, longitude: p.lng }; + const updatedZonePoints = zone.points.map((point, index) => { + if (index < 4) { + const nextPoint = nextPoints[index]; + return { ...point, latitude: nextPoint.lat, longitude: nextPoint.lng }; } - return pt; + return point; }) as any; updateZone(zone.id, { points: updatedZonePoints }); } + function commitMapPoints(nextPoints: MapPoint[]) { + setPoints(nextPoints); + syncZonePoints(nextPoints); + } + function onReset() { setPoints([]); if (zone) { - const resetPoints = zone.points.map((pt, i) => { - return { ...pt, latitude: null, longitude: null }; + const resetPoints = zone.points.map(point => { + return { ...point, latitude: null, longitude: null }; }) as any; updateZone(zone.id, { points: resetPoints }); } @@ -213,12 +352,12 @@ export default function ZoneMapSelector() { setLoading(true); setError(undefined); - const updatedPoints = zone.points.map((pt, idx) => { - if (idx < 4) { - const p = points[idx]; - return { ...pt, latitude: p.lat, longitude: p.lng }; + const updatedPoints = zone.points.map((point, index) => { + if (index < 4) { + const nextPoint = points[index]; + return { ...point, latitude: nextPoint.lat, longitude: nextPoint.lng }; } - return pt; + return point; }) as any; updateZone(zone.id, { points: updatedPoints }); @@ -257,7 +396,7 @@ export default function ZoneMapSelector() {
Вместимость: {zone.capacity}
)} - {loading &&
Сохранение…
} + {loading &&
Сохранение...
} {error &&
{error}
}
@@ -272,51 +411,14 @@ export default function ZoneMapSelector() {
- - - - - {points.length >= 2 && ( - { - setPoints(nextPoints); - syncZonePoints(nextPoints); - }} - /> - )} - {points.map((p, idx) => ( - { - L.DomEvent.stopPropagation(e.originalEvent); - suppressMapClick(); - }, - dragend: (e) => { - suppressMapClick(); - const newPos = e.target.getLatLng(); - setPoints(prev => { - const updatedPoints = prev.map((pt, i) => i === idx ? new L.LatLng(newPos.lat, newPos.lng) : pt); - syncZonePoints(updatedPoints); - return updatedPoints; - }); - } - }} - /> - ))} - +
); diff --git a/src/main.tsx b/src/main.tsx index b566104..9707d82 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,7 +1,6 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; -import 'leaflet/dist/leaflet.css'; ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/src/maps/useYandexMap.ts b/src/maps/useYandexMap.ts new file mode 100644 index 0000000..e372263 --- /dev/null +++ b/src/maps/useYandexMap.ts @@ -0,0 +1,63 @@ +import { RefObject, useEffect, useMemo, useState } from 'react'; +import { loadYandexMaps, YandexMapInstance, YandexMapsApi, YandexPoint } from './yandex'; + +type UseYandexMapOptions = { + center: YandexPoint; + zoom: number; + controls?: string[]; +}; + +type YandexMapState = { + ymaps?: YandexMapsApi; + map?: YandexMapInstance; + loading: boolean; + error?: string; +}; + +export function useYandexMap( + containerRef: RefObject, + { center, zoom, controls = ['zoomControl'] }: UseYandexMapOptions +) { + const [state, setState] = useState({ loading: true }); + const centerKey = useMemo(() => center.join(','), [center]); + + useEffect(() => { + let cancelled = false; + let map: YandexMapInstance | undefined; + + setState({ loading: true }); + loadYandexMaps() + .then(ymaps => { + if (cancelled || !containerRef.current) return; + map = new ymaps.Map(containerRef.current, { + center, + zoom, + controls + }); + map.behaviors.enable(['drag', 'scrollZoom', 'dblClickZoom', 'multiTouch']); + setState({ ymaps, map, loading: false }); + }) + .catch(error => { + if (!cancelled) { + setState({ + loading: false, + error: String(error?.message || error) + }); + } + }); + + return () => { + cancelled = true; + if (map) { + map.destroy(); + } + }; + }, []); + + useEffect(() => { + if (!state.map) return; + state.map.setCenter(center, zoom, { duration: 200 }); + }, [state.map, centerKey, zoom]); + + return state; +} diff --git a/src/maps/yandex.ts b/src/maps/yandex.ts new file mode 100644 index 0000000..847a04f --- /dev/null +++ b/src/maps/yandex.ts @@ -0,0 +1,129 @@ +export type YandexMapsApi = any; +export type YandexMapInstance = any; +export type YandexGeoObject = any; +export type YandexPoint = [number, number]; + +declare global { + interface Window { + ymaps?: YandexMapsApi; + __parktrackYandexMapsPromise?: Promise; + } +} + +const SCRIPT_ID = 'parktrack-yandex-maps-api'; + +function getYandexMapsApiKey() { + return import.meta.env.VITE_YANDEX_MAPS_API_KEY?.trim() + || import.meta.env.VITE_YMAPS_API_KEY?.trim() + || ''; +} + +function buildYandexMapsUrl() { + const params = new URLSearchParams({ + lang: 'ru_RU', + load: 'package.full' + }); + const apiKey = getYandexMapsApiKey(); + if (apiKey) { + params.set('apikey', apiKey); + } + return `https://api-maps.yandex.ru/2.1/?${params.toString()}`; +} + +function waitYandexReady() { + return new Promise((resolve, reject) => { + if (!window.ymaps) { + reject(new Error('Yandex Maps API script loaded, but ymaps is unavailable.')); + return; + } + window.ymaps.ready(() => resolve(window.ymaps)); + }); +} + +export function loadYandexMaps() { + if (typeof window === 'undefined') { + return Promise.reject(new Error('Yandex Maps API can be loaded only in browser.')); + } + + if (window.ymaps) { + return waitYandexReady(); + } + + if (window.__parktrackYandexMapsPromise) { + return window.__parktrackYandexMapsPromise; + } + + window.__parktrackYandexMapsPromise = new Promise((resolve, reject) => { + const resolveReady = () => { + waitYandexReady().then(resolve).catch(reject); + }; + + const rejectLoad = () => { + reject(new Error('Не удалось загрузить Яндекс.Карты. Проверьте сеть и VITE_YANDEX_MAPS_API_KEY.')); + }; + + const existingScript = document.getElementById(SCRIPT_ID) as HTMLScriptElement | null; + if (existingScript) { + existingScript.addEventListener('load', resolveReady, { once: true }); + existingScript.addEventListener('error', rejectLoad, { once: true }); + return; + } + + const script = document.createElement('script'); + script.id = SCRIPT_ID; + script.async = true; + script.src = buildYandexMapsUrl(); + script.addEventListener('load', resolveReady, { once: true }); + script.addEventListener('error', rejectLoad, { once: true }); + document.head.appendChild(script); + }); + + return window.__parktrackYandexMapsPromise; +} + +export function yandexPoint(latitude: number, longitude: number): YandexPoint { + return [latitude, longitude]; +} + +export function hasYandexPoint(value?: YandexPoint | null): value is YandexPoint { + return Boolean( + value + && Number.isFinite(value[0]) + && Number.isFinite(value[1]) + ); +} + +export function yandexBounds(points: YandexPoint[]): [YandexPoint, YandexPoint] | null { + if (!points.length) return null; + + let minLat = points[0][0]; + let maxLat = points[0][0]; + let minLng = points[0][1]; + let maxLng = points[0][1]; + + for (const [lat, lng] of points) { + minLat = Math.min(minLat, lat); + maxLat = Math.max(maxLat, lat); + minLng = Math.min(minLng, lng); + maxLng = Math.max(maxLng, lng); + } + + return [[minLat, minLng], [maxLat, maxLng]]; +} + +export function fitYandexMap(map: YandexMapInstance, points: YandexPoint[], fallbackZoom = 16) { + if (!points.length) return; + + if (points.length === 1) { + map.setCenter(points[0], fallbackZoom, { duration: 200 }); + return; + } + + const bounds = yandexBounds(points); + if (!bounds) return; + map.setBounds(bounds, { + checkZoomRange: true, + duration: 200, + zoomMargin: 42 + }); +} diff --git a/src/styles.css b/src/styles.css index ccb8997..aef7023 100644 --- a/src/styles.css +++ b/src/styles.css @@ -30,6 +30,41 @@ body { background: var(--bg); color: var(--text); font-family: Inter, system-ui, .topbar { grid-area: topbar; border-bottom: 1px solid var(--border); background: var(--panel); padding: 10px 12px; } .sidebar { grid-area: sidebar; border-right: 1px solid var(--border); background: var(--panel); padding: 12px; overflow: auto; } .canvas { grid-area: canvas; position: relative; background: #101712; } +.yandex-map-host { + position: relative; + width: 100%; + height: 100%; + min-height: 320px; +} +.map-status-overlay { + position: absolute; + inset: 14px auto auto 14px; + z-index: 20; + padding: 8px 12px; + border: 1px solid rgba(24, 131, 58, 0.22); + border-radius: 8px; + background: rgba(255, 255, 255, 0.94); + color: #102015; + box-shadow: 0 10px 24px rgba(16, 32, 21, 0.16); +} +.map-status-overlay.error { + border-color: #f3b8b4; + color: var(--danger); +} +.yandex-map-balloon { + max-width: 220px; + display: grid; + gap: 6px; +} +.yandex-map-balloon-title { + font-weight: 700; +} +.yandex-map-balloon-action { + width: fit-content; + min-height: 32px; + padding: 6px 10px; + font-size: 13px; +} .canvas:fullscreen { width: 100vw; height: 100vh; @@ -753,6 +788,8 @@ hr { border: none; height: 1px; background: var(--border); margin: 10px 0; } border-radius: 8px; background: #f8fbf8; padding: 10px; + overflow: hidden; + container-type: inline-size; } .camera-preview:fullscreen { @@ -780,10 +817,16 @@ hr { border: none; height: 1px; background: var(--border); margin: 10px 0; } .camera-preview-header { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; gap: 10px; margin-bottom: 10px; + flex-wrap: wrap; +} + +.camera-preview-header > div:first-child { + min-width: 0; + flex: 1 1 140px; } .camera-preview:fullscreen .camera-preview-header { @@ -791,6 +834,10 @@ hr { border: none; height: 1px; background: var(--border); margin: 10px 0; } margin-bottom: 0; } +.camera-preview:fullscreen .camera-preview-header .small { + color: rgba(255, 255, 255, 0.72); +} + .camera-preview:fullscreen .camera-preview-actions .button { min-height: 34px; background: rgba(255, 255, 255, 0.94); @@ -815,10 +862,91 @@ hr { border: none; height: 1px; background: var(--border); margin: 10px 0; } .camera-preview-actions { display: flex; - align-items: center; + align-items: stretch; gap: 8px; flex-wrap: wrap; justify-content: flex-end; + min-width: 0; + max-width: 100%; + flex: 1 1 240px; +} + +.camera-preview-actions .button { + min-width: 0; + flex: 1 1 128px; +} + +.snapshot-mode-toggle { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + min-height: 36px; + padding: 3px; + border: 1px solid var(--border); + border-radius: 8px; + background: #eef6f0; + min-width: 176px; + max-width: 100%; + flex: 1 1 190px; +} + +.snapshot-mode-option { + min-height: 28px; + padding: 4px 10px; + border: 0; + border-radius: 6px; + background: transparent; + color: #4f5c55; + font: inherit; + font-size: 13px; + font-weight: 700; + cursor: pointer; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@container (max-width: 560px) { + .camera-preview-header { + display: grid; + grid-template-columns: minmax(0, 1fr); + } + + .camera-preview-actions { + width: 100%; + justify-content: stretch; + } + + .snapshot-mode-toggle, + .camera-preview-actions .button { + width: 100%; + flex-basis: 100%; + } +} + +.snapshot-mode-option.active { + background: #ffffff; + color: var(--accent); + box-shadow: 0 1px 4px rgba(18, 35, 24, 0.12); +} + +.snapshot-mode-option:disabled { + cursor: not-allowed; + opacity: 0.65; +} + +.camera-preview:fullscreen .snapshot-mode-toggle { + background: rgba(255, 255, 255, 0.14); + border-color: rgba(255, 255, 255, 0.28); +} + +.camera-preview:fullscreen .snapshot-mode-option { + color: rgba(255, 255, 255, 0.78); +} + +.camera-preview:fullscreen .snapshot-mode-option.active { + background: #ffffff; + color: #102015; } .camera-preview-image { diff --git a/vite.config.ts b/vite.config.ts index b661d4f..b29c2a9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -21,7 +21,6 @@ export default defineConfig({ // Выделяем большие библиотеки в отдельные чанки 'react-vendor': ['react', 'react-dom'], 'konva-vendor': ['konva', 'react-konva'], - 'leaflet-vendor': ['leaflet', 'react-leaflet'], 'zustand-vendor': ['zustand'] } }