From 8deec1c9b6081b7c8869b27d1ed51c5be2bcead9 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sat, 13 Apr 2024 23:23:54 -0500 Subject: [PATCH] add objects and unsaved to type --- .../dynamic/CameraFeatureToggle.tsx | 2 +- web/src/components/filter/LogLevelFilter.tsx | 1 - web/src/components/filter/ZoneMaskFilter.tsx | 19 +- web/src/components/settings/MasksAndZones.tsx | 355 ++++++------ web/src/components/settings/NewZoneButton.tsx | 158 ------ web/src/components/settings/PolygonCanvas.tsx | 6 +- .../settings/PolygonEditControls.tsx | 51 ++ web/src/components/settings/ZoneEditPane.tsx | 510 +++++++++--------- web/src/pages/Settings.tsx | 184 ++++--- web/src/types/canvas.ts | 3 +- 10 files changed, 625 insertions(+), 664 deletions(-) delete mode 100644 web/src/components/settings/NewZoneButton.tsx create mode 100644 web/src/components/settings/PolygonEditControls.tsx diff --git a/web/src/components/dynamic/CameraFeatureToggle.tsx b/web/src/components/dynamic/CameraFeatureToggle.tsx index aa33a019d..b0418c556 100644 --- a/web/src/components/dynamic/CameraFeatureToggle.tsx +++ b/web/src/components/dynamic/CameraFeatureToggle.tsx @@ -14,7 +14,7 @@ const variants = { overlay: { active: "font-bold text-white bg-selected rounded-full", inactive: - "text-primary-white rounded-full bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500", + "text-primary rounded-full bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500", }, }; diff --git a/web/src/components/filter/LogLevelFilter.tsx b/web/src/components/filter/LogLevelFilter.tsx index a9e58e463..0ec8af8b2 100644 --- a/web/src/components/filter/LogLevelFilter.tsx +++ b/web/src/components/filter/LogLevelFilter.tsx @@ -120,7 +120,6 @@ export function GeneralFilterContent({ ))} - ); } diff --git a/web/src/components/filter/ZoneMaskFilter.tsx b/web/src/components/filter/ZoneMaskFilter.tsx index 071d9ece0..bb57af40e 100644 --- a/web/src/components/filter/ZoneMaskFilter.tsx +++ b/web/src/components/filter/ZoneMaskFilter.tsx @@ -17,9 +17,19 @@ export function ZoneMaskFilterButton({ updateZoneMaskFilter, }: ZoneMaskFilterButtonProps) { const trigger = ( - ); const content = ( @@ -80,7 +90,7 @@ export function GeneralFilterContent({
{["zone", "motion_mask", "object_mask"].map((item) => ( -
+
- ); } diff --git a/web/src/components/settings/MasksAndZones.tsx b/web/src/components/settings/MasksAndZones.tsx index b1d0e4b69..326eb444d 100644 --- a/web/src/components/settings/MasksAndZones.tsx +++ b/web/src/components/settings/MasksAndZones.tsx @@ -9,7 +9,7 @@ import { isDesktop, isMobile } from "react-device-detect"; import { Skeleton } from "../ui/skeleton"; import { useResizeObserver } from "@/hooks/resize-observer"; import { LuCopy, LuPencil, LuPlusSquare, LuTrash } from "react-icons/lu"; -import { FaDrawPolygon } from "react-icons/fa"; +import { FaDrawPolygon, FaObjectGroup } from "react-icons/fa"; import copy from "copy-to-clipboard"; import { toast } from "sonner"; import { Toaster } from "../ui/sonner"; @@ -25,6 +25,8 @@ import { AlertDialogHeader, AlertDialogTitle, } from "../ui/alert-dialog"; +import { Separator } from "../ui/separator"; +import { BsPersonBoundingBox } from "react-icons/bs"; const parseCoordinates = (coordinatesString: string) => { const coordinates = coordinatesString.split(","); @@ -53,125 +55,6 @@ type PolygonItemProps = { handleCopyCoordinates: (index: number) => void; }; -function PolygonItem({ - polygon, - setAllPolygons, - index, - activePolygonIndex, - hoveredPolygonIndex, - setHoveredPolygonIndex, - deleteDialogOpen, - setDeleteDialogOpen, - setActivePolygonIndex, - setEditPane, - handleCopyCoordinates, -}: PolygonItemProps) { - return ( -
setHoveredPolygonIndex(index)} - onMouseLeave={() => setHoveredPolygonIndex(null)} - style={{ - backgroundColor: - hoveredPolygonIndex === index - ? toRGBColorString(polygon.color, false) - : "", - }} - > - {isMobile && <>} -
- -

{polygon.name}

-
- {deleteDialogOpen && ( - setDeleteDialogOpen(!deleteDialogOpen)} - > - - - Confirm Delete - - - Are you sure you want to delete this{" "} - {polygon.type.replace("_", " ")}? - - - Cancel - { - setAllPolygons((oldPolygons) => { - return oldPolygons.filter((_, i) => i !== index); - }); - setActivePolygonIndex(undefined); - }} - > - Delete - - - - - )} - {hoveredPolygonIndex === index && ( -
-
{ - setActivePolygonIndex(index); - setEditPane(polygon.type); - }} - > - -
-
handleCopyCoordinates(index)} - > - -
-
setDeleteDialogOpen(true)} - > - -
-
- )} -
- ); -} - export type ZoneObjects = { camera: string; zoneName: string; @@ -180,16 +63,24 @@ export type ZoneObjects = { type MasksAndZoneProps = { selectedCamera: string; - selectedZoneMask: PolygonType; + selectedZoneMask?: PolygonType[]; + isEditing: boolean; + setIsEditing: React.Dispatch>; + unsavedChanges: boolean; + setUnsavedChanges: React.Dispatch>; }; export default function MasksAndZones({ selectedCamera, selectedZoneMask, + isEditing, + setIsEditing, + unsavedChanges, + setUnsavedChanges, }: MasksAndZoneProps) { const { data: config } = useSWR("config"); const [allPolygons, setAllPolygons] = useState([]); - const [editingPolygons, setEditingPolygons] = useState(); + const [editingPolygons, setEditingPolygons] = useState([]); const [zoneObjects, setZoneObjects] = useState([]); const [activePolygonIndex, setActivePolygonIndex] = useState< number | undefined @@ -323,8 +214,6 @@ export default function MasksAndZones({ return finalHeight; } } - - return 100; }, [ aspectRatio, containerWidth, @@ -338,32 +227,31 @@ export default function MasksAndZones({ if (aspectRatio && scaledHeight) { return Math.ceil(scaledHeight * aspectRatio); } - - return 100; }, [scaledHeight, aspectRatio]); const handleNewPolygon = (type: PolygonType) => { - setAllPolygons([ + setActivePolygonIndex(allPolygons.length); + setEditingPolygons([ ...(allPolygons || []), { points: [], isFinished: false, - // isUnsaved: true, + isUnsaved: true, type, name: "", + objects: [], camera: selectedCamera, color: [0, 0, 220], }, ]); - setActivePolygonIndex(allPolygons.length); }; const handleCancel = useCallback(() => { setEditPane(undefined); - // setAllPolygons(allPolygons.filter((poly) => !poly.isUnsaved)); + setAllPolygons(allPolygons.filter((poly) => !poly.isUnsaved)); setActivePolygonIndex(undefined); setHoveredPolygonIndex(null); - }, []); + }, [allPolygons]); const handleSave = useCallback(() => { setAllPolygons([...(editingPolygons ?? [])]); @@ -374,7 +262,7 @@ export default function MasksAndZones({ const handleCopyCoordinates = useCallback( (index: number) => { - if (allPolygons && scaledWidth) { + if (allPolygons && scaledWidth && scaledHeight) { const poly = allPolygons[index]; copy( interpolatePoints(poly.points, scaledWidth, scaledHeight, 1, 1) @@ -389,13 +277,16 @@ export default function MasksAndZones({ [allPolygons, scaledHeight, scaledWidth], ); + useEffect(() => {}, [editPane]); + useEffect(() => { - if (cameraConfig && containerRef.current && scaledWidth) { + if (cameraConfig && containerRef.current && scaledWidth && scaledHeight) { const zones = Object.entries(cameraConfig.zones).map( ([name, zoneData]) => ({ type: "zone" as PolygonType, camera: cameraConfig.name, name, + objects: zoneData.objects, points: interpolatePoints( parseCoordinates(zoneData.coordinates), 1, @@ -404,6 +295,7 @@ export default function MasksAndZones({ scaledHeight, ), isFinished: true, + isUnsaved: false, color: zoneData.color, }), ); @@ -413,6 +305,7 @@ export default function MasksAndZones({ type: "motion_mask" as PolygonType, camera: cameraConfig.name, name: `Motion Mask ${index + 1}`, + objects: [], points: interpolatePoints( parseCoordinates(maskData), 1, @@ -421,6 +314,7 @@ export default function MasksAndZones({ scaledHeight, ), isFinished: true, + isUnsaved: false, color: [0, 0, 255], }), ); @@ -430,6 +324,7 @@ export default function MasksAndZones({ type: "object_mask" as PolygonType, camera: cameraConfig.name, name: `All Objects Object Mask ${index + 1}`, + objects: [], points: interpolatePoints( parseCoordinates(maskData), 1, @@ -438,6 +333,7 @@ export default function MasksAndZones({ scaledHeight, ), isFinished: true, + isUnsaved: false, color: [0, 0, 255], }), ); @@ -454,6 +350,7 @@ export default function MasksAndZones({ type: "object_mask" as PolygonType, camera: cameraConfig.name, name: `${objectName.charAt(0).toUpperCase() + objectName.slice(1)} Object Mask ${globalObjectMasksCount + subIndex + 1}`, + objects: [objectName], points: interpolatePoints( parseCoordinates(maskItem), 1, @@ -462,6 +359,7 @@ export default function MasksAndZones({ scaledHeight, ), isFinished: true, + isUnsaved: false, color: [128, 128, 128], }, ] @@ -487,26 +385,29 @@ export default function MasksAndZones({ } // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps - }, [cameraConfig, containerRef]); + }, [cameraConfig, containerRef, scaledHeight, scaledWidth]); useEffect(() => { if (editPane === undefined) { setEditingPolygons([...allPolygons]); + setIsEditing(false); console.log(allPolygons); + } else { + setIsEditing(true); } - }, [setEditingPolygons, allPolygons, editPane]); + }, [setEditingPolygons, setIsEditing, allPolygons, editPane]); - // useEffect(() => { - // console.log( - // "config zone objects", - // Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({ - // camera: cameraConfig.name, - // zoneName: name, - // objects: Object.keys(zoneData.filters), - // })), - // ); - // console.log("component zone objects", zoneObjects); - // }, [zoneObjects]); + useEffect(() => { + console.log( + "config zone objects", + Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({ + camera: cameraConfig.name, + zoneName: name, + objects: Object.keys(zoneData.filters), + })), + ); + console.log("component zone objects", zoneObjects); + }, [zoneObjects]); useEffect(() => { if (selectedCamera) { @@ -518,8 +419,6 @@ export default function MasksAndZones({ return ; } - // console.log(selectedZoneMask); - return ( <> {cameraConfig && allPolygons && ( @@ -528,7 +427,7 @@ export default function MasksAndZones({
{editPane == "zone" && ( )} {editPane == "object_mask" && ( )} {editPane === undefined && ( @@ -553,7 +454,7 @@ export default function MasksAndZones({ {(selectedZoneMask === undefined || selectedZoneMask.includes("zone" as PolygonType)) && ( <> -
+
Zones
- */} - - - { - setDialogOpen(open); - if (!open) { - setZoneName(""); - } - }} - > - - {isMobile && } - New Zone - - Enter a unique label for your zone. Do not include spaces, and don't - use the name of a camera. - - <> - { - setInvalidName( - Object.keys(config.cameras).includes(e.target.value) || - e.target.value.includes(" ") || - polygons.map((item) => item.name).includes(e.target.value), - ); - - setZoneName(e.target.value); - }} - /> - {invalidName && ( -
Invalid zone name.
- )} - - - - -
-
-
- ); -} - -export default NewZoneButton; diff --git a/web/src/components/settings/PolygonCanvas.tsx b/web/src/components/settings/PolygonCanvas.tsx index c516ceb3a..c29558e8f 100644 --- a/web/src/components/settings/PolygonCanvas.tsx +++ b/web/src/components/settings/PolygonCanvas.tsx @@ -11,19 +11,17 @@ type PolygonCanvasProps = { camera: string; width: number; height: number; - scale: number; polygons: Polygon[]; setPolygons: React.Dispatch>; activePolygonIndex: number | undefined; hoveredPolygonIndex: number | null; - selectedZoneMask: PolygonType; + selectedZoneMask: PolygonType[] | undefined; }; export function PolygonCanvas({ camera, width, height, - scale, polygons, setPolygons, activePolygonIndex, @@ -193,8 +191,6 @@ export function PolygonCanvas({ ref={stageRef} width={width} height={height} - scaleX={scale} - scaleY={scale} onMouseDown={handleMouseDown} onTouchStart={handleMouseDown} > diff --git a/web/src/components/settings/PolygonEditControls.tsx b/web/src/components/settings/PolygonEditControls.tsx new file mode 100644 index 000000000..ed707aa74 --- /dev/null +++ b/web/src/components/settings/PolygonEditControls.tsx @@ -0,0 +1,51 @@ +import { Polygon } from "@/types/canvas"; +import { Button } from "../ui/button"; + +type PolygonEditControlsProps = { + polygons: Polygon[]; + setPolygons: React.Dispatch>; + activePolygonIndex: number | null; +}; + +export default function PolygonEditControls({ + polygons, + setPolygons, + activePolygonIndex, +}: PolygonEditControlsProps) { + const undo = () => { + if (activePolygonIndex !== null && polygons) { + const updatedPolygons = [...polygons]; + const activePolygon = updatedPolygons[activePolygonIndex]; + if (activePolygon.points.length > 0) { + updatedPolygons[activePolygonIndex] = { + ...activePolygon, + points: activePolygon.points.slice(0, -1), + isFinished: false, + }; + setPolygons(updatedPolygons); + } + } + }; + + const reset = () => { + if (activePolygonIndex !== null) { + const updatedPolygons = [...polygons]; + updatedPolygons[activePolygonIndex] = { + ...updatedPolygons[activePolygonIndex], + points: [], + }; + setPolygons(updatedPolygons); + } + }; + + return ( +
+ + +
+ ); +} diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx index 3c04938b9..23167701a 100644 --- a/web/src/components/settings/ZoneEditPane.tsx +++ b/web/src/components/settings/ZoneEditPane.tsx @@ -11,12 +11,8 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; -import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import { useEffect, useMemo, useState } from "react"; -import { GeneralFilterContent } from "../filter/ReviewFilterGroup"; -import { FaObjectGroup } from "react-icons/fa"; -import { ATTRIBUTES, CameraConfig, FrigateConfig } from "@/types/frigateConfig"; +import { ATTRIBUTES, FrigateConfig } from "@/types/frigateConfig"; import useSWR from "swr"; import { isMobile } from "react-device-detect"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -26,6 +22,265 @@ import { Polygon } from "@/types/canvas"; import { Switch } from "../ui/switch"; import { Label } from "../ui/label"; +type ZoneEditPaneProps = { + polygons?: Polygon[]; + activePolygonIndex?: number; + onSave?: () => void; + onCancel?: () => void; +}; + +export function ZoneEditPane({ + polygons, + activePolygonIndex, + onSave, + onCancel, +}: ZoneEditPaneProps) { + const { data: config } = useSWR("config"); + + const cameras = useMemo(() => { + if (!config) { + return []; + } + + return Object.values(config.cameras) + .filter((conf) => conf.ui.dashboard && conf.enabled) + .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); + }, [config]); + + const polygon = useMemo(() => { + if (polygons && activePolygonIndex !== undefined) { + return polygons[activePolygonIndex]; + } else { + return null; + } + }, [polygons, activePolygonIndex]); + + const formSchema = z + .object({ + name: z + .string() + .min(2, { + message: "Zone name must be at least 2 characters.", + }) + .transform((val: string) => val.trim().replace(/\s+/g, "_")) + .refine( + (value: string) => { + return !cameras.map((cam) => cam.name).includes(value); + }, + { + message: "Zone name must not be the name of a camera.", + }, + ) + .refine( + (value: string) => { + const otherPolygonNames = + polygons + ?.filter((_, index) => index !== activePolygonIndex) + .map((polygon) => polygon.name) || []; + + return !otherPolygonNames.includes(value); + }, + { + message: "Zone name already exists on this camera.", + }, + ), + inertia: z.coerce.number().min(1, { + message: "Inertia must be above 0.", + }), + loitering_time: z.coerce.number().min(0, { + message: "Loitering time must be greater than or equal to 0.", + }), + polygon: z.object({ isFinished: z.boolean() }), + }) + .refine(() => polygon?.isFinished === true, { + message: "The polygon drawing must be finished before saving.", + path: ["polygon.isFinished"], + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + mode: "onChange", + defaultValues: { + name: polygon?.name ?? "", + inertia: + ((polygon?.camera && + polygon?.name && + config?.cameras[polygon.camera]?.zones[polygon.name] + ?.inertia) as number) || 3, + loitering_time: + ((polygon?.camera && + polygon?.name && + config?.cameras[polygon.camera]?.zones[polygon.name] + ?.loitering_time) as number) || 0, + polygon: { isFinished: polygon?.isFinished ?? false }, + }, + }); + + function onSubmit(values: z.infer) { + polygons[activePolygonIndex].name = values.name; + console.log("form values", values); + console.log("active polygon", polygons[activePolygonIndex]); + // make sure polygon isFinished + onSave(); + } + + if (!polygon) { + return; + } + + return ( + <> + + Zone + +
+ +
+ +
+ + ( + + Name + + + + + + )} + /> +
+ +
+ ( + + Inertia + + + + + Specifies how many frames that an object must be in a zone + before they are considered in the zone. + + + + )} + /> +
+ +
+ ( + + Loitering Time + + + + + Sets a minimum amount of time in seconds that the object must + be in the zone for it to activate. + + + + )} + /> +
+ +
+ + Objects + + List of objects that apply to this zone. + + { + // console.log(objects); + }} + /> + +
+ +
+ + Alerts and Detections + + When an object enters this zone, ensure it is marked as an alert + or detection. + + +
+
+ + { + if (isChecked) { + return; + } + }} + /> +
+
+ + { + if (isChecked) { + return; + } + }} + /> +
+
+
+
+ ( + + + + )} + /> +
+ + +
+ + + + ); +} + type ZoneObjectSelectorProps = { camera: string; zoneName: string; @@ -101,10 +356,7 @@ export function ZoneObjectSelector({ <>
-