import { Button } from "@/components/ui/button"; import { useTranslation } from "react-i18next"; import { useState, useMemo, useRef, useCallback, useEffect } from "react"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { LuX, LuPlus } from "react-icons/lu"; import { Stage, Layer, Rect, Transformer } from "react-konva"; import Konva from "konva"; import { useResizeObserver } from "@/hooks/resize-observer"; import { useApiHost } from "@/api"; import { resolveCameraName } from "@/hooks/use-camera-friendly-name"; import Heading from "@/components/ui/heading"; import { isMobile } from "react-device-detect"; import { cn } from "@/lib/utils"; export type CameraAreaConfig = { camera: string; crop: [number, number, number, number]; }; export type Step2FormData = { cameraAreas: CameraAreaConfig[]; }; type Step2StateAreaProps = { initialData?: Partial; onNext: (data: Step2FormData) => void; onBack: () => void; }; export default function Step2StateArea({ initialData, onNext, onBack, }: Step2StateAreaProps) { const { t } = useTranslation(["views/classificationModel"]); const { data: config } = useSWR("config"); const apiHost = useApiHost(); const [cameraAreas, setCameraAreas] = useState( initialData?.cameraAreas || [], ); const [selectedCameraIndex, setSelectedCameraIndex] = useState(0); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [imageLoaded, setImageLoaded] = useState(false); const containerRef = useRef(null); const imageRef = useRef(null); const stageRef = useRef(null); const rectRef = useRef(null); const transformerRef = useRef(null); const [{ width: containerWidth }] = useResizeObserver(containerRef); const availableCameras = useMemo(() => { if (!config) return []; const selectedCameraNames = cameraAreas.map((ca) => ca.camera); return Object.entries(config.cameras) .sort() .filter( ([name, cam]) => cam.enabled && cam.enabled_in_config && !selectedCameraNames.includes(name), ) .map(([name]) => ({ name, displayName: resolveCameraName(config, name), })); }, [config, cameraAreas]); const selectedCamera = useMemo(() => { if (cameraAreas.length === 0) return null; return cameraAreas[selectedCameraIndex]; }, [cameraAreas, selectedCameraIndex]); const selectedCameraConfig = useMemo(() => { if (!config || !selectedCamera) return null; return config.cameras[selectedCamera.camera]; }, [config, selectedCamera]); const imageSize = useMemo(() => { if (!containerWidth || !selectedCameraConfig) { return { width: 0, height: 0 }; } const containerAspectRatio = 16 / 9; const containerHeight = containerWidth / containerAspectRatio; const cameraAspectRatio = selectedCameraConfig.detect.width / selectedCameraConfig.detect.height; // Fit camera within 16:9 container let imageWidth, imageHeight; if (cameraAspectRatio > containerAspectRatio) { imageWidth = containerWidth; imageHeight = imageWidth / cameraAspectRatio; } else { imageHeight = containerHeight; imageWidth = imageHeight * cameraAspectRatio; } return { width: imageWidth, height: imageHeight }; }, [containerWidth, selectedCameraConfig]); const handleAddCamera = useCallback( (cameraName: string) => { // Calculate a square crop in pixel space const camera = config?.cameras[cameraName]; if (!camera) return; const cameraAspect = camera.detect.width / camera.detect.height; const cropSize = 0.3; let x1, y1, x2, y2; if (cameraAspect >= 1) { const pixelSize = cropSize * camera.detect.height; const normalizedWidth = pixelSize / camera.detect.width; x1 = (1 - normalizedWidth) / 2; y1 = (1 - cropSize) / 2; x2 = x1 + normalizedWidth; y2 = y1 + cropSize; } else { const pixelSize = cropSize * camera.detect.width; const normalizedHeight = pixelSize / camera.detect.height; x1 = (1 - cropSize) / 2; y1 = (1 - normalizedHeight) / 2; x2 = x1 + cropSize; y2 = y1 + normalizedHeight; } const newArea: CameraAreaConfig = { camera: cameraName, crop: [x1, y1, x2, y2], }; setCameraAreas([...cameraAreas, newArea]); setSelectedCameraIndex(cameraAreas.length); setIsPopoverOpen(false); }, [cameraAreas, config], ); const handleRemoveCamera = useCallback( (index: number) => { const newAreas = cameraAreas.filter((_, i) => i !== index); setCameraAreas(newAreas); if (selectedCameraIndex >= newAreas.length) { setSelectedCameraIndex(Math.max(0, newAreas.length - 1)); } }, [cameraAreas, selectedCameraIndex], ); const handleCropChange = useCallback( (crop: [number, number, number, number]) => { const newAreas = [...cameraAreas]; newAreas[selectedCameraIndex] = { ...newAreas[selectedCameraIndex], crop, }; setCameraAreas(newAreas); }, [cameraAreas, selectedCameraIndex], ); useEffect(() => { setImageLoaded(false); }, [selectedCamera]); useEffect(() => { const rect = rectRef.current; const transformer = transformerRef.current; if ( rect && transformer && selectedCamera && imageSize.width > 0 && imageLoaded ) { rect.scaleX(1); rect.scaleY(1); transformer.nodes([rect]); transformer.getLayer()?.batchDraw(); } }, [selectedCamera, imageSize, imageLoaded]); const handleRectChange = useCallback(() => { const rect = rectRef.current; if (rect && imageSize.width > 0) { const actualWidth = rect.width() * rect.scaleX(); const actualHeight = rect.height() * rect.scaleY(); // Average dimensions to maintain perfect square const size = (actualWidth + actualHeight) / 2; rect.width(size); rect.height(size); rect.scaleX(1); rect.scaleY(1); const x1 = rect.x() / imageSize.width; const y1 = rect.y() / imageSize.height; const x2 = (rect.x() + size) / imageSize.width; const y2 = (rect.y() + size) / imageSize.height; handleCropChange([x1, y1, x2, y2]); } }, [imageSize, handleCropChange]); const handleContinue = useCallback(() => { onNext({ cameraAreas }); }, [cameraAreas, onNext]); const canContinue = cameraAreas.length > 0; return (

{t("wizard.step2.cameras")}

{availableCameras.length > 0 ? ( e.preventDefault()} >
{t("wizard.step2.selectCamera")}
{availableCameras.map((cam) => ( ))}
) : ( )}
{cameraAreas.map((area, index) => { const isSelected = index === selectedCameraIndex; const displayName = resolveCameraName(config, area.camera); return (
setSelectedCameraIndex(index)} > {displayName}
); })}
{cameraAreas.length === 0 && (
{t("wizard.step2.noCameras")}
)}
{selectedCamera && selectedCameraConfig && imageSize.width > 0 ? (
{resolveCameraName(config, setImageLoaded(true)} /> { const rect = rectRef.current; if (!rect) return pos; const size = rect.width(); const x = Math.max( 0, Math.min(pos.x, imageSize.width - size), ); const y = Math.max( 0, Math.min(pos.y, imageSize.height - size), ); return { x, y }; }} onDragEnd={handleRectChange} onTransformEnd={handleRectChange} /> { const minSize = 50; const maxSize = Math.min( imageSize.width, imageSize.height, ); // Clamp dimensions to stage bounds first const clampedWidth = Math.max( minSize, Math.min(newBox.width, maxSize), ); const clampedHeight = Math.max( minSize, Math.min(newBox.height, maxSize), ); // Enforce square using average const size = (clampedWidth + clampedHeight) / 2; // Clamp position to keep square within bounds const x = Math.max( 0, Math.min(newBox.x, imageSize.width - size), ); const y = Math.max( 0, Math.min(newBox.y, imageSize.height - size), ); return { ...newBox, x, y, width: size, height: size, }; }} />
) : (
{t("wizard.step2.selectCameraPrompt")}
)}
); }