From 9cbddf92944c69d8c90a203d5e13d11909779225 Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Tue, 26 May 2026 23:14:14 +0300 Subject: [PATCH 01/10] feat: add snapshot annotation toggle --- src/components/CamerasPage.tsx | 30 ++++++++++++++++++-- src/styles.css | 52 ++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/src/components/CamerasPage.tsx b/src/components/CamerasPage.tsx index 8406527..7357bfa 100644 --- a/src/components/CamerasPage.tsx +++ b/src/components/CamerasPage.tsx @@ -184,6 +184,7 @@ export default function CamerasPage() { const snapshotPreviewRef = useRef(null); 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 }); @@ -289,7 +290,7 @@ export default function CamerasPage() { setSnapshot({ loading: true }); try { const data = await api.getSnapshot(selectedCamera.camera_id, { - annotated: true, + annotated: snapshotAnnotated, fallback_to_raw: true }); if (!cancelled) { @@ -310,7 +311,7 @@ export default function CamerasPage() { URL.revokeObjectURL(lastImageUrl); } }; - }, [selectedCamera?.camera_id, snapshotReloadKey]); + }, [selectedCamera?.camera_id, snapshotAnnotated, snapshotReloadKey]); useEffect(() => { if (!selectedCamera) { @@ -744,8 +745,31 @@ export default function CamerasPage() { {saveState.error &&
{saveState.error}
}
-

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

+
+

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

+
+ {snapshotAnnotated ? 'Разметка включена' : 'Разметка скрыта'} +
+
+
+ + +
{snapshot.data?.image_url && (
)} - {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 7357bfa..2fc06ab 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,27 +16,6 @@ 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; @@ -103,6 +73,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; @@ -323,12 +417,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)); @@ -870,52 +958,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/styles.css b/src/styles.css index a26ed3f..4926d35 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; From 8206eabe68c16ad3d111c15cfc9acbd98155e3b1 Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Wed, 27 May 2026 15:13:13 +0300 Subject: [PATCH 04/10] feat: replace zone map editor with yandex --- src/components/ZoneMapSelector.tsx | 385 +++++++++++++++++------------ 1 file changed, 225 insertions(+), 160 deletions(-) diff --git a/src/components/ZoneMapSelector.tsx b/src/components/ZoneMapSelector.tsx index 0d78d43..5eb674a 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,206 @@ 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 stopYandexEvent(event: any) { + if (typeof event?.stopPropagation === 'function') event.stopPropagation(); + if (typeof event?.preventDefault === 'function') event.preventDefault(); } -function DraggableZoneShape({ +function YandexZoneGeometryMap({ + center, points, - onMove, - onMoveEnd, + fitVersion, + onMapClick, + onPointsDraftChange, + onPointsCommit, onInteractionStart }: { - points: LatLng[]; - onMove: (points: LatLng[]) => void; - onMoveEnd: (points: LatLng[]) => void; + center: YandexPoint; + points: MapPoint[]; + fitVersion: number; + onMapClick: (point: MapPoint) => void; + onPointsDraftChange: (points: 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 { ymaps, map, loading, error } = useYandexMap(mapRef, { center, zoom: 16 }); + + useEffect(() => { + if (!map || points.length > 0) return; + map.setCenter(center, 16, { duration: 200 }); + }, [map, center, points.length]); + + useEffect(() => { + if (!map || fitVersion === 0 || points.length === 0) return; + fitYandexMap(map, points.map(toYandexPoint), 16); + }, [map, fitVersion, points]); + + 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]); + + useEffect(() => { + if (!ymaps || !map) return; - const onMouseUp = () => { - map.off('mousemove', onMouseMove); - map.off('mouseup', onMouseUp); - map.dragging.enable(); + const collection = new ymaps.GeoObjectCollection(); + + const startShapeDrag = (event: any) => { + if (points.length < 2) return; + stopYandexEvent(event); onInteractionStart(); - onMoveEnd(latest); + map.behaviors.disable(['drag']); + + const start = event.get('coords') as YandexPoint | undefined; + if (!start) return; + const origin = points.map(point => ({ ...point })); + let latest = origin; + + const onMouseMove = (moveEvent: any) => { + const current = moveEvent.get('coords') as YandexPoint | undefined; + if (!current) return; + const latDelta = current[0] - start[0]; + const lngDelta = current[1] - start[1]; + latest = origin.map(point => ({ + lat: point.lat + latDelta, + lng: point.lng + lngDelta + })); + onPointsDraftChange(latest); + }; + + const onMouseUp = () => { + map.events.remove('mousemove', onMouseMove); + map.events.remove('mouseup', onMouseUp); + map.behaviors.enable(['drag']); + onInteractionStart(); + onPointsCommit(latest); + }; + + map.events.add('mousemove', onMouseMove); + map.events.add('mouseup', onMouseUp); }; - map.on('mousemove', onMouseMove); - map.on('mouseup', onMouseUp); - } + const startPointDrag = (pointIndex: number, event: any) => { + stopYandexEvent(event); + onInteractionStart(); + map.behaviors.disable(['drag']); + + const latest = points.map(point => ({ ...point })); + + const onMouseMove = (moveEvent: any) => { + const current = moveEvent.get('coords') as YandexPoint | undefined; + if (!current) return; + latest[pointIndex] = fromYandexPoint(current); + onPointsDraftChange([...latest]); + }; + + const onMouseUp = () => { + map.events.remove('mousemove', onMouseMove); + map.events.remove('mouseup', onMouseUp); + map.behaviors.enable(['drag']); + onInteractionStart(); + onPointsCommit([...latest]); + }; + + map.events.add('mousemove', onMouseMove); + map.events.add('mouseup', onMouseUp); + }; + + if (points.length >= 2) { + const coordinates = points.map(toYandexPoint); + const isComplete = points.length === 4; + const shape = isComplete + ? new ymaps.Polygon( + [coordinates], + {}, + { + strokeColor: '#ff7a45', + strokeOpacity: 0.92, + strokeWidth: 2, + fillColor: '#ff7a453d', + fillOpacity: 0.24, + zIndex: 250 + } + ) + : new ymaps.Polyline( + coordinates, + {}, + { + strokeColor: '#ff7a45', + strokeOpacity: 0.92, + strokeWidth: 2, + strokeStyle: 'dash', + zIndex: 250 + } + ); + + const dragLineCoordinates = isComplete + ? [...coordinates, coordinates[0]] + : coordinates; + const dragLine = new ymaps.Polyline( + dragLineCoordinates, + {}, + { + strokeColor: '#ff7a45', + strokeOpacity: 0.01, + strokeWidth: 22, + zIndex: 300, + cursor: 'move' + } + ); + + shape.events.add('mousedown', startShapeDrag); + dragLine.events.add('mousedown', startShapeDrag); + 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: 500, + cursor: 'move' + } + ); + + placemark.events.add('mousedown', (event: any) => startPointDrag(index, event)); + collection.add(placemark); + }); + + map.geoObjects.add(collection); + return () => { + map.geoObjects.remove(collection); + }; + }, [ymaps, map, points, onInteractionStart, onMapClick, onPointsCommit, onPointsDraftChange]); 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 +232,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 +243,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,14 +279,14 @@ 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 }); } @@ -193,8 +294,8 @@ export default function ZoneMapSelector() { 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 +314,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 +358,7 @@ export default function ZoneMapSelector() {
Вместимость: {zone.capacity}
)} - {loading &&
Сохранение…
} + {loading &&
Сохранение...
} {error &&
{error}
}
@@ -272,51 +373,15 @@ 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; - }); - } - }} - /> - ))} - +
); From 369cc4d9df3301138924f4c5d9fea29c1c993fb6 Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Wed, 27 May 2026 15:18:49 +0300 Subject: [PATCH 05/10] chore: remove leaflet map stack --- .env.example | 1 + README.md | 13 +++++++++- package-lock.json | 62 +---------------------------------------------- package.json | 3 --- src/main.tsx | 1 - vite.config.ts | 1 - 6 files changed, 14 insertions(+), 67 deletions(-) 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/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/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'] } } From 2e67572699317559bb7847e599eea54613c7f653 Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Wed, 27 May 2026 15:58:20 +0300 Subject: [PATCH 06/10] fix: prevent zone map refit while dragging points --- src/components/ZoneMapSelector.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/components/ZoneMapSelector.tsx b/src/components/ZoneMapSelector.tsx index 5eb674a..184fbba 100644 --- a/src/components/ZoneMapSelector.tsx +++ b/src/components/ZoneMapSelector.tsx @@ -48,17 +48,23 @@ function YandexZoneGeometryMap({ onInteractionStart: () => void; }) { 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(() => { - if (!map || fitVersion === 0 || points.length === 0) return; - fitYandexMap(map, points.map(toYandexPoint), 16); - }, [map, fitVersion, points]); + const pointsToFit = pointsRef.current; + if (!map || fitVersion === 0 || pointsToFit.length === 0) return; + fitYandexMap(map, pointsToFit.map(toYandexPoint), 16); + }, [map, fitVersion]); useEffect(() => { if (!map) return; From 953573f58fd24b1da21b03841da25a1da2f33da1 Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Wed, 27 May 2026 16:18:08 +0300 Subject: [PATCH 07/10] fix: cache camera snapshot preview modes --- src/components/CamerasPage.tsx | 104 +++++++++++++++++++++++++++++---- 1 file changed, 92 insertions(+), 12 deletions(-) diff --git a/src/components/CamerasPage.tsx b/src/components/CamerasPage.tsx index 2fc06ab..6edcfb6 100644 --- a/src/components/CamerasPage.tsx +++ b/src/components/CamerasPage.tsx @@ -22,6 +22,16 @@ type SnapshotState = { 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; @@ -276,6 +286,9 @@ 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); @@ -296,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); @@ -373,7 +454,6 @@ export default function CamerasPage() { useEffect(() => { let cancelled = false; - let lastImageUrl: string | undefined; async function loadSnapshot() { if (!selectedCamera) { @@ -381,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: snapshotAnnotated, - 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) { @@ -401,9 +486,6 @@ export default function CamerasPage() { loadSnapshot(); return () => { cancelled = true; - if (lastImageUrl?.startsWith('blob:')) { - URL.revokeObjectURL(lastImageUrl); - } }; }, [selectedCamera?.camera_id, snapshotAnnotated, snapshotReloadKey]); @@ -845,7 +927,6 @@ export default function CamerasPage() { type="button" className={`snapshot-mode-option ${snapshotAnnotated ? 'active' : ''}`} onClick={() => setSnapshotAnnotated(true)} - disabled={snapshot.loading} > Разметка @@ -853,7 +934,6 @@ export default function CamerasPage() { type="button" className={`snapshot-mode-option ${!snapshotAnnotated ? 'active' : ''}`} onClick={() => setSnapshotAnnotated(false)} - disabled={snapshot.loading} > Кадр @@ -869,7 +949,7 @@ export default function CamerasPage() { )} From d9950888714dd6ec02ad9b4b342856a9e28f2bca Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Wed, 27 May 2026 16:26:06 +0300 Subject: [PATCH 08/10] fix: keep camera preview actions within card --- src/styles.css | 47 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/src/styles.css b/src/styles.css index 4926d35..aef7023 100644 --- a/src/styles.css +++ b/src/styles.css @@ -788,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 { @@ -815,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 { @@ -854,20 +862,31 @@ 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(84px, 1fr)); + 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 { @@ -881,6 +900,28 @@ hr { border: none; height: 1px; background: var(--border); margin: 10px 0; } 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 { From 3a01d6f54c25d930e481d61e1f62ce52a68129ea Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Wed, 27 May 2026 16:51:02 +0300 Subject: [PATCH 09/10] fix: update zone map geometry during drag --- src/components/ZoneMapSelector.tsx | 55 ++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/src/components/ZoneMapSelector.tsx b/src/components/ZoneMapSelector.tsx index 184fbba..2a7cb14 100644 --- a/src/components/ZoneMapSelector.tsx +++ b/src/components/ZoneMapSelector.tsx @@ -35,7 +35,6 @@ function YandexZoneGeometryMap({ points, fitVersion, onMapClick, - onPointsDraftChange, onPointsCommit, onInteractionStart }: { @@ -43,7 +42,6 @@ function YandexZoneGeometryMap({ points: MapPoint[]; fitVersion: number; onMapClick: (point: MapPoint) => void; - onPointsDraftChange: (points: MapPoint[]) => void; onPointsCommit: (points: MapPoint[]) => void; onInteractionStart: () => void; }) { @@ -83,6 +81,34 @@ function YandexZoneGeometryMap({ 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 updateMapGeometry = (nextPoints: MapPoint[]) => { + const coordinates = visibleLineCoordinates(nextPoints); + if (shape) { + shape.geometry.setCoordinates(isComplete ? [coordinates] : coordinates); + } + if (dragLine) { + dragLine.geometry.setCoordinates(dragLineCoordinates(nextPoints)); + } + placemarks.forEach((placemark, index) => { + const point = nextPoints[index]; + if (point) { + placemark.geometry.setCoordinates(toYandexPoint(point)); + } + }); + }; const startShapeDrag = (event: any) => { if (points.length < 2) return; @@ -104,7 +130,7 @@ function YandexZoneGeometryMap({ lat: point.lat + latDelta, lng: point.lng + lngDelta })); - onPointsDraftChange(latest); + updateMapGeometry(latest); }; const onMouseUp = () => { @@ -130,7 +156,7 @@ function YandexZoneGeometryMap({ const current = moveEvent.get('coords') as YandexPoint | undefined; if (!current) return; latest[pointIndex] = fromYandexPoint(current); - onPointsDraftChange([...latest]); + updateMapGeometry(latest); }; const onMouseUp = () => { @@ -147,8 +173,7 @@ function YandexZoneGeometryMap({ if (points.length >= 2) { const coordinates = points.map(toYandexPoint); - const isComplete = points.length === 4; - const shape = isComplete + shape = isComplete ? new ymaps.Polygon( [coordinates], {}, @@ -173,11 +198,8 @@ function YandexZoneGeometryMap({ } ); - const dragLineCoordinates = isComplete - ? [...coordinates, coordinates[0]] - : coordinates; - const dragLine = new ymaps.Polyline( - dragLineCoordinates, + dragLine = new ymaps.Polyline( + dragLineCoordinates(points), {}, { strokeColor: '#ff7a45', @@ -209,6 +231,7 @@ function YandexZoneGeometryMap({ ); placemark.events.add('mousedown', (event: any) => startPointDrag(index, event)); + placemarks.push(placemark); collection.add(placemark); }); @@ -216,7 +239,7 @@ function YandexZoneGeometryMap({ return () => { map.geoObjects.remove(collection); }; - }, [ymaps, map, points, onInteractionStart, onMapClick, onPointsCommit, onPointsDraftChange]); + }, [ymaps, map, points, onInteractionStart, onMapClick, onPointsCommit]); return (
@@ -297,6 +320,11 @@ export default function ZoneMapSelector() { updateZone(zone.id, { points: updatedZonePoints }); } + function commitMapPoints(nextPoints: MapPoint[]) { + setPoints(nextPoints); + syncZonePoints(nextPoints); + } + function onReset() { setPoints([]); if (zone) { @@ -384,8 +412,7 @@ export default function ZoneMapSelector() { points={points} fitVersion={fitVersion} onMapClick={onMapClick} - onPointsDraftChange={setPoints} - onPointsCommit={syncZonePoints} + onPointsCommit={commitMapPoints} onInteractionStart={suppressMapClick} />
From 4434aedff8fe89005cfef8c713afaab54391a7b2 Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Wed, 27 May 2026 20:05:35 +0300 Subject: [PATCH 10/10] fix: use native yandex dragging for zone geometry --- src/components/ZoneMapSelector.tsx | 130 +++++++++++++++-------------- 1 file changed, 67 insertions(+), 63 deletions(-) diff --git a/src/components/ZoneMapSelector.tsx b/src/components/ZoneMapSelector.tsx index 2a7cb14..276f7f1 100644 --- a/src/components/ZoneMapSelector.tsx +++ b/src/components/ZoneMapSelector.tsx @@ -25,9 +25,8 @@ function fromYandexPoint(point: YandexPoint): MapPoint { return { lat: point[0], lng: point[1] }; } -function stopYandexEvent(event: any) { +function stopYandexEventPropagation(event: any) { if (typeof event?.stopPropagation === 'function') event.stopPropagation(); - if (typeof event?.preventDefault === 'function') event.preventDefault(); } function YandexZoneGeometryMap({ @@ -94,15 +93,16 @@ function YandexZoneGeometryMap({ : coordinates; }; - const updateMapGeometry = (nextPoints: MapPoint[]) => { + const updateMapGeometryFromDrag = (nextPoints: MapPoint[], source?: any) => { const coordinates = visibleLineCoordinates(nextPoints); - if (shape) { + if (shape && shape !== source) { shape.geometry.setCoordinates(isComplete ? [coordinates] : coordinates); } - if (dragLine) { + 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)); @@ -110,65 +110,56 @@ function YandexZoneGeometryMap({ }); }; - const startShapeDrag = (event: any) => { - if (points.length < 2) return; - stopYandexEvent(event); + 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(); map.behaviors.disable(['drag']); + }; - const start = event.get('coords') as YandexPoint | undefined; - if (!start) return; - const origin = points.map(point => ({ ...point })); - let latest = origin; - - const onMouseMove = (moveEvent: any) => { - const current = moveEvent.get('coords') as YandexPoint | undefined; - if (!current) return; - const latDelta = current[0] - start[0]; - const lngDelta = current[1] - start[1]; - latest = origin.map(point => ({ - lat: point.lat + latDelta, - lng: point.lng + lngDelta - })); - updateMapGeometry(latest); - }; - - const onMouseUp = () => { - map.events.remove('mousemove', onMouseMove); - map.events.remove('mouseup', onMouseUp); - map.behaviors.enable(['drag']); - onInteractionStart(); - onPointsCommit(latest); - }; + const onShapeDrag = (source: any) => { + const latest = pointsFromDraggedGeometry(source); + if (latest.length !== points.length) return; + updateMapGeometryFromDrag(latest, source); + }; - map.events.add('mousemove', onMouseMove); - map.events.add('mouseup', onMouseUp); + 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 startPointDrag = (pointIndex: number, event: any) => { - stopYandexEvent(event); + const onShapeDragEnd = (source: any) => { + const latest = pointsFromDraggedGeometry(source); + if (latest.length === points.length) { + updateMapGeometryFromDrag(latest, source); + onPointsCommit(latest); + } + map.behaviors.enable(['drag']); onInteractionStart(); - map.behaviors.disable(['drag']); + }; + const onPointDragEnd = (pointIndex: number, placemark: any) => { const latest = points.map(point => ({ ...point })); - - const onMouseMove = (moveEvent: any) => { - const current = moveEvent.get('coords') as YandexPoint | undefined; - if (!current) return; + const current = placemark.geometry.getCoordinates() as YandexPoint | undefined; + if (current) { latest[pointIndex] = fromYandexPoint(current); - updateMapGeometry(latest); - }; - - const onMouseUp = () => { - map.events.remove('mousemove', onMouseMove); - map.events.remove('mouseup', onMouseUp); - map.behaviors.enable(['drag']); - onInteractionStart(); - onPointsCommit([...latest]); - }; - - map.events.add('mousemove', onMouseMove); - map.events.add('mouseup', onMouseUp); + updateMapGeometryFromDrag(latest, placemark); + onPointsCommit(latest); + } + map.behaviors.enable(['drag']); + onInteractionStart(); }; if (points.length >= 2) { @@ -183,7 +174,8 @@ function YandexZoneGeometryMap({ strokeWidth: 2, fillColor: '#ff7a453d', fillOpacity: 0.24, - zIndex: 250 + zIndex: 250, + draggable: true } ) : new ymaps.Polyline( @@ -194,7 +186,8 @@ function YandexZoneGeometryMap({ strokeOpacity: 0.92, strokeWidth: 2, strokeStyle: 'dash', - zIndex: 250 + zIndex: 250, + draggable: true } ); @@ -205,13 +198,18 @@ function YandexZoneGeometryMap({ strokeColor: '#ff7a45', strokeOpacity: 0.01, strokeWidth: 22, - zIndex: 300, - cursor: 'move' + zIndex: 200, + cursor: 'move', + draggable: true } ); - shape.events.add('mousedown', startShapeDrag); - dragLine.events.add('mousedown', startShapeDrag); + 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); } @@ -225,18 +223,24 @@ function YandexZoneGeometryMap({ { preset: 'islands#circleIcon', iconColor: '#ffd43b', - zIndex: 500, - cursor: 'move' + zIndex: 1000, + zIndexHover: 1100, + zIndexActive: 1200, + cursor: 'move', + draggable: true } ); - placemark.events.add('mousedown', (event: any) => startPointDrag(index, event)); + 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]);