From 6edc7185c1d444550cf5ee7b4f8861525ccaec66 Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Thu, 28 May 2026 15:25:06 +0300 Subject: [PATCH 1/3] feat: cache camera snapshot in labeler workspace --- src/components/TopBar.tsx | 48 ++++++++++----------------------------- src/store/useStore.ts | 40 +++++++++++++++++++++++++++++--- 2 files changed, 49 insertions(+), 39 deletions(-) diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index 2ab432b..8d7dd7e 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -2,20 +2,18 @@ import { useStore } from '@/store/useStore'; import { apiConfig, api } from '@/api/client'; import { useSessionStore } from '@/auth/sessionStore'; import { Button, Field, Input, FilePicker } from './UiKit'; -import { useEffect, useState, useCallback, useRef } from 'react'; +import { useEffect, useState, useCallback } from 'react'; import { navigate } from '@/router/routes'; import { useFeedbackStore } from '@/feedback/feedbackStore'; export default function TopBar() { - const { apiBase, token, cameraId, viewMode, setImage, image, setViewMode, labelerReturnRoute } = useStore(); + const { apiBase, token, cameraId, viewMode, setImage, image, imageCameraId, setViewMode, labelerReturnRoute } = useStore(); const sessionAccessToken = useSessionStore(state => state.accessToken); const [imageUrlInput, setImageUrlInput] = useState(''); const [loadingSnapshot, setLoadingSnapshot] = useState(false); const notifySuccess = useFeedbackStore(state => state.success); const notifyError = useFeedbackStore(state => state.error); const notifyWarning = useFeedbackStore(state => state.warning); - const currentBlobUrlRef = useRef(null); - const loadedCameraIdRef = useRef(null); const effectiveToken = sessionAccessToken || token; useEffect(() => { @@ -27,7 +25,7 @@ export default function TopBar() { if (!url) return; try { const img = await loadImage(url); - setImage(img); + setImage(img, cameraId || undefined); fitToView(img); notifySuccess('Изображение открыто.'); } catch (error: any) { @@ -38,8 +36,7 @@ export default function TopBar() { const loadByCameraId = useCallback(async () => { if (!cameraId) return; - // Prevent duplicate loads for the same camera - if (loadedCameraIdRef.current === cameraId && image?.url) { + if (image?.url && imageCameraId === cameraId) { return; } @@ -49,28 +46,21 @@ export default function TopBar() { const snap = await api.getSnapshot(parseInt(cameraId, 10)); if (snap?.image_url) { - // Clean up previous blob URL to prevent memory leaks - if (currentBlobUrlRef.current && currentBlobUrlRef.current.startsWith('blob:')) { - URL.revokeObjectURL(currentBlobUrlRef.current); - } - - currentBlobUrlRef.current = snap.image_url; - loadedCameraIdRef.current = cameraId; const img = await loadImage(snap.image_url); - setImage(img); + setImage(img, cameraId); fitToView(img); } else { console.warn('Snapshot missing image_url, using fallback'); - const img = await loadImage('/sample.jpg'); - setImage(img); + const img = await loadImage('/sample.png'); + setImage(img, cameraId); fitToView(img); notifyWarning('Snapshot недоступен, открыт тестовый кадр.'); } } catch (error) { console.error('Error loading snapshot:', error); try { - const img = await loadImage('/sample.jpg'); - setImage(img); + const img = await loadImage('/sample.png'); + setImage(img, cameraId); fitToView(img); notifyWarning('Не удалось загрузить snapshot, открыт тестовый кадр.'); } catch (fallbackError) { @@ -80,7 +70,7 @@ export default function TopBar() { } finally { setLoadingSnapshot(false); } - }, [cameraId, apiBase, effectiveToken, setImage, image]); + }, [cameraId, apiBase, effectiveToken, setImage, image, imageCameraId]); function fitToView(img: { naturalWidth: number; naturalHeight: number; url: string }) { useStore.getState().setView(1, 0, 0); @@ -89,6 +79,7 @@ export default function TopBar() { const isLabeler = viewMode === 'labeler'; function backToOrigin() { + setImage(undefined); if (labelerReturnRoute === 'zones') { navigate('zones'); return; @@ -97,21 +88,6 @@ export default function TopBar() { navigate('cameras'); } - // Cleanup blob URLs on unmount to prevent memory leaks - useEffect(() => { - return () => { - if (currentBlobUrlRef.current && currentBlobUrlRef.current.startsWith('blob:')) { - URL.revokeObjectURL(currentBlobUrlRef.current); - } - }; - }, []); - - useEffect(() => { - if (viewMode !== 'labeler' || !cameraId) { - loadedCameraIdRef.current = null; - } - }, [viewMode, cameraId]); - useEffect(() => { if (viewMode === 'labeler' && cameraId) { const timer = setTimeout(() => { @@ -156,7 +132,7 @@ export default function TopBar() { try { const url = URL.createObjectURL(f); const img = await loadImage(url); - setImage(img); + setImage(img, cameraId || undefined); fitToView(img); notifySuccess('Файл изображения открыт.'); } catch (error: any) { diff --git a/src/store/useStore.ts b/src/store/useStore.ts index 8c3f886..a850ccf 100644 --- a/src/store/useStore.ts +++ b/src/store/useStore.ts @@ -36,6 +36,11 @@ const hasCoordinates = (latitude?: number | null, longitude?: number | null) => && typeof longitude === 'number' && Number.isFinite(longitude) ); +const revokeImage = (image?: ImageMeta) => { + if (image?.url.startsWith('blob:')) { + URL.revokeObjectURL(image.url); + } +}; type State = { apiBase: string; @@ -43,6 +48,7 @@ type State = { cameraId: string; labelerReturnRoute?: 'cameras' | 'zones'; image?: ImageMeta; + imageCameraId?: string; cameraMeta?: Camera; viewMode: ViewMode; @@ -63,7 +69,7 @@ type State = { setViewMode(mode: ViewMode): void; setCamera(id: string): void; setLabelerReturnRoute(route?: 'cameras' | 'zones'): void; - setImage(img: ImageMeta | undefined): void; + setImage(img: ImageMeta | undefined, cameraId?: string): void; loadCameraMeta(id: number): Promise; saveCamera(id: number, patch: Partial): Promise; @@ -92,6 +98,7 @@ export const useStore = create((set, get) => ({ cameraId: '', labelerReturnRoute: 'cameras', image: undefined, + imageCameraId: undefined, cameraMeta: undefined, viewMode: 'cameras', tool: 'select', @@ -103,9 +110,36 @@ export const useStore = create((set, get) => ({ loading: false, setViewMode(mode) { set({ viewMode: mode }); }, - setCamera(id) { set({ cameraId: id }); }, + setCamera(id) { + set((state) => { + if (state.cameraId === id) { + return { cameraId: id }; + } + revokeImage(state.image); + return { + cameraId: id, + image: undefined, + imageCameraId: undefined, + cameraMeta: undefined, + zones: [], + activeZoneId: undefined, + zoneDraft: null, + tool: 'select' + }; + }); + }, setLabelerReturnRoute(route) { set({ labelerReturnRoute: route }); }, - setImage(img) { set({ image: img }); }, + setImage(img, cameraId) { + set((state) => { + if (state.image?.url && state.image.url !== img?.url) { + revokeImage(state.image); + } + return { + image: img, + imageCameraId: img ? cameraId : undefined + }; + }); + }, async loadCameraMeta(id) { try { From b7a9b7499d219aecfdecc4828ded48595531e04e Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Thu, 28 May 2026 15:26:54 +0300 Subject: [PATCH 2/3] feat: add camera list scroll and labeler shortcuts --- src/components/Sidebar.tsx | 69 +++++++++++++++++++++++++++++++++++--- src/styles.css | 14 ++++++++ 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index bc4ec5d..7d2a27d 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -28,6 +28,15 @@ function parseOptionalPositiveInt(value: string): number | null { return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } +function isEditableTarget(target: EventTarget | null) { + if (!(target instanceof HTMLElement)) return false; + const tagName = target.tagName.toLowerCase(); + return target.isContentEditable + || tagName === 'input' + || tagName === 'textarea' + || tagName === 'select'; +} + export default function Sidebar() { const s = useStore(); const notifySuccess = useFeedbackStore(state => state.success); @@ -142,6 +151,54 @@ export default function Sidebar() { } } + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if (isEditableTarget(event.target) || event.altKey || event.ctrlKey || event.metaKey) return; + + const key = event.key.toLowerCase(); + if (key === 'n') { + event.preventDefault(); + startDrawZone(); + return; + } + + if (key === 'escape') { + event.preventDefault(); + if (s.tool === 'drawZone') { + s.zoneDraftClear(); + notifyInfo('Рисование зоны отменено.'); + } else { + s.setTool('select'); + notifyInfo('Режим выбора включён.'); + } + return; + } + + if (!zone) return; + + if (key === 'e') { + event.preventDefault(); + s.setTool('editZone'); + notifyInfo('Редактирование полигона включено.'); + return; + } + + if (key === 's') { + event.preventDefault(); + saveActiveZone(); + return; + } + + if (key === 'm') { + event.preventDefault(); + openZoneOnMap(); + } + } + + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [zone?.id, s.tool, s.zoneDraft?.length]); + return (
@@ -201,7 +258,9 @@ export default function Sidebar() { )}
- + @@ -311,15 +370,15 @@ export default function Sidebar() {
- - + +
- +
-
diff --git a/src/styles.css b/src/styles.css index aef7023..ca14816 100644 --- a/src/styles.css +++ b/src/styles.css @@ -714,6 +714,20 @@ hr { border: none; height: 1px; background: var(--border); margin: 10px 0; } margin-bottom: 12px; } +.camera-list-scroll { + max-height: clamp(280px, 38vh, 520px); + overflow-x: auto; + overflow-y: auto; +} + +.camera-list-scroll .camera-list-header { + position: sticky; + top: 0; + z-index: 2; + background: #fff; + box-shadow: 0 1px 0 #edf1ee; +} + .camera-list-header, .camera-list-item { display: grid; From 94bd01fdf2e994579df5e89816e2e8e158849db6 Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Thu, 28 May 2026 15:28:10 +0300 Subject: [PATCH 3/3] fix: save updated zone map geometry --- src/api/zones.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/zones.ts b/src/api/zones.ts index 6f0d1ea..26e0184 100644 --- a/src/api/zones.ts +++ b/src/api/zones.ts @@ -273,7 +273,7 @@ function buildZoneCreateBody(zone: ParkingZone) { zone_type: zone.zone_type, capacity: zone.capacity, pay: zone.pay, - geometry: zone.geometry ?? buildZoneGeometry(zone.points), + geometry: buildZoneGeometry(zone.points), image_polygon: (zone.image_polygon ?? buildImagePolygon(zone.image_quad ?? zone.points)).map(point => [point.x, point.y]), partner_id: zone.partner_id, is_active: zone.is_active, @@ -298,7 +298,7 @@ function buildZoneUpdateBody(zone: ParkingZone) { if (zone.is_private !== undefined) body.is_private = zone.is_private; if (zone.is_accessible !== undefined) body.is_accessible = zone.is_accessible; - body.geometry = zone.geometry ?? buildZoneGeometry(zone.points); + body.geometry = buildZoneGeometry(zone.points); body.image_polygon = (zone.image_polygon ?? buildImagePolygon(zone.image_quad ?? zone.points)).map(point => [point.x, point.y]); return body;