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 { MdAddBox } from "react-icons/md"; import { LuX } 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"; export type CameraAreaConfig = { camera: string; crop: [number, number, number, number]; // [x, y, width, height] normalized 0-1 }; 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 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) => { const newArea: CameraAreaConfig = { camera: cameraName, crop: [0.385, 0.385, 0.15, 0.15], }; setCameraAreas([...cameraAreas, newArea]); setSelectedCameraIndex(cameraAreas.length); setIsPopoverOpen(false); }, [cameraAreas], ); 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(() => { const rect = rectRef.current; const transformer = transformerRef.current; if (rect && transformer) { rect.scaleX(1); rect.scaleY(1); transformer.nodes([rect]); transformer.getLayer()?.batchDraw(); } }, [selectedCamera, imageSize]); 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); // Normalize to 0-1 range for storage const x = rect.x() / imageSize.width; const y = rect.y() / imageSize.height; const width = size / imageSize.width; const height = size / imageSize.height; handleCropChange([x, y, width, height]); } }, [imageSize, handleCropChange]); const handleContinue = useCallback(() => { onNext({ cameraAreas }); }, [cameraAreas, onNext]); const canContinue = cameraAreas.length > 0; return (
{t("wizard.step2.description")}

{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, { 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")}
)}
); }