diff --git a/web/package-lock.json b/web/package-lock.json index ab9283144..168a00acd 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "dependencies": { "@cycjimmy/jsmpeg-player": "^6.0.5", - "@hookform/resolvers": "^3.3.2", + "@hookform/resolvers": "^3.3.4", "@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-aspect-ratio": "^1.0.3", "@radix-ui/react-context-menu": "^2.1.5", diff --git a/web/package.json b/web/package.json index a2144bdce..b01cadc54 100644 --- a/web/package.json +++ b/web/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@cycjimmy/jsmpeg-player": "^6.0.5", - "@hookform/resolvers": "^3.3.2", + "@hookform/resolvers": "^3.3.4", "@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-aspect-ratio": "^1.0.3", "@radix-ui/react-context-menu": "^2.1.5", diff --git a/web/src/components/settings/MasksAndZones.tsx b/web/src/components/settings/MasksAndZones.tsx index e038eed01..2fc7e8fa1 100644 --- a/web/src/components/settings/MasksAndZones.tsx +++ b/web/src/components/settings/MasksAndZones.tsx @@ -1,12 +1,4 @@ import { Separator } from "@/components/ui/separator"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; import { FrigateConfig } from "@/types/frigateConfig"; import useSWR from "swr"; @@ -16,14 +8,17 @@ import { PolygonCanvas } from "./PolygonCanvas"; import { Polygon } from "@/types/canvas"; import { interpolatePoints, toRGBColorString } from "@/utils/canvasUtil"; import { isDesktop } from "react-device-detect"; -import ZoneControls, { - NewZoneButton, - ZoneObjectSelector, -} from "./NewZoneButton"; +import { NewZoneButton } from "./NewZoneButton"; import { Skeleton } from "../ui/skeleton"; import { useResizeObserver } from "@/hooks/resize-observer"; -import { LuCopy, LuPencil, LuPlusSquare, LuTrash } from "react-icons/lu"; +import { LuCopy, LuPencil, LuTrash } from "react-icons/lu"; import { FaDrawPolygon } from "react-icons/fa"; +import copy from "copy-to-clipboard"; +import { toast } from "sonner"; +import { Toaster } from "../ui/sonner"; +import Heading from "../ui/heading"; +import { Input } from "../ui/input"; +import { ZoneEditPane } from "./ZoneEditPane"; const parseCoordinates = (coordinatesString: string) => { const coordinates = coordinatesString.split(","); @@ -56,10 +51,14 @@ export default function MasksAndZones({ const { data: config } = useSWR("config"); const [zonePolygons, setZonePolygons] = useState([]); const [zoneObjects, setZoneObjects] = useState([]); - const [activePolygonIndex, setActivePolygonIndex] = useState( - null, - ); + const [activePolygonIndex, setActivePolygonIndex] = useState< + number | undefined + >(undefined); const containerRef = useRef(null); + const editViews = ["zone", "motion_mask", "object_mask", undefined] as const; + + type EditPaneType = (typeof editViews)[number]; + const [editPane, setEditPane] = useState(undefined); const cameras = useMemo(() => { if (!config) { @@ -77,22 +76,6 @@ export default function MasksAndZones({ } }, [config, selectedCamera]); - const allLabels = useMemo(() => { - if (!cameras) { - return []; - } - - const labels = new Set(); - - cameras.forEach((camera) => { - camera.objects.track.forEach((label) => { - labels.add(label); - }); - }); - - return [...labels].sort(); - }, [cameras]); - // const saveZoneObjects = useCallback( // (camera: string, zoneName: string, newObjects?: string[]) => { // setZoneObjects((prevZoneObjects) => @@ -243,6 +226,23 @@ export default function MasksAndZones({ [scaledHeight, aspectRatio], ); + const handleCopyCoordinates = useCallback( + (index: number) => { + if (zonePolygons) { + const poly = zonePolygons[index]; + copy( + interpolatePoints(poly.points, scaledWidth, scaledHeight, 1, 1) + .map((point) => `${point[0]},${point[1]}`) + .join(","), + ); + toast.success(`Copied coordinates for ${poly.name} to clipboard.`); + } else { + toast.error("Could not copy coordinates to clipboard."); + } + }, + [zonePolygons, scaledHeight, scaledWidth], + ); + useEffect(() => { if (cameraConfig && containerRef.current) { setZonePolygons( @@ -273,21 +273,21 @@ export default function MasksAndZones({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [cameraConfig, containerRef]); - 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) { - setActivePolygonIndex(null); + setActivePolygonIndex(undefined); } }, [selectedCamera]); @@ -297,73 +297,122 @@ export default function MasksAndZones({ return ( <> - {cameraConfig && ( + {cameraConfig && zonePolygons && (
-
-
+ +
+ {/*
-
-
-
Zones
- */} + {editPane == "zone" && ( + -
- {zonePolygons.map((polygon, index) => ( -
{ + setEditPane(undefined); + setActivePolygonIndex(undefined); }} - > -
- + )} + {editPane == "motion_mask" && ( + { + setEditPane(undefined); + setActivePolygonIndex(undefined); + }} + /> + )} + {editPane == "object_mask" && ( + { + setEditPane(undefined); + setActivePolygonIndex(undefined); + }} + /> + )} + {editPane == undefined && ( + <> +
+
Zones
+ - {polygon.name}
-
+ {zonePolygons.map((polygon, index) => (
setActivePolygonIndex(index)} + key={index} + className="flex p-1 rounded-lg flex-row items-center justify-between mx-2 mb-1" + // style={{ + // backgroundColor: + // activePolygonIndex === index + // ? toRGBColorString(polygon.color, false) + // : "", + // }} > - +
+ +

{polygon.name}

+
+
+
{ + setActivePolygonIndex(index); + setEditPane("zone"); + // if (activePolygonIndex == index) { + // setActivePolygonIndex(null); + + // } else { + // setActivePolygonIndex(index); + // } + }} + > + +
+
handleCopyCoordinates(index)} + > + +
+
{ + setZonePolygons((oldPolygons) => { + return oldPolygons.filter((_, i) => i !== index); + }); + setActivePolygonIndex(undefined); + }} + > + +
+
- -
{ - setZonePolygons((oldPolygons) => { - return oldPolygons.filter((_, i) => i !== index); - }); - setActivePolygonIndex(null); - }} - > - -
-
-
- ))} + ))} + + )} {/* @@ -446,7 +495,7 @@ export default function MasksAndZones({
{cameraConfig ? ( diff --git a/web/src/components/settings/NewZoneButton.tsx b/web/src/components/settings/NewZoneButton.tsx index 6c4ce3625..26ee2ccc0 100644 --- a/web/src/components/settings/NewZoneButton.tsx +++ b/web/src/components/settings/NewZoneButton.tsx @@ -18,115 +18,6 @@ import useSWR from "swr"; import { isMobile } from "react-device-detect"; import { LuPlusSquare } from "react-icons/lu"; -type ZoneObjectSelectorProps = { - camera: string; - zoneName: string; - allLabels: string[]; - updateLabelFilter: (labels: string[] | undefined) => void; -}; - -export function ZoneObjectSelector({ - camera, - zoneName, - allLabels, - updateLabelFilter, -}: ZoneObjectSelectorProps) { - const { data: config } = useSWR("config"); - const [open, setOpen] = useState(false); - - const cameraConfig = useMemo(() => { - if (config && camera) { - return config.cameras[camera]; - } - }, [config, camera]); - - const zoneLabels = useMemo(() => { - if (!cameraConfig || !zoneName) { - return []; - } - - const labels = new Set(); - - cameraConfig.objects.track.forEach((label) => { - if (!ATTRIBUTES.includes(label)) { - labels.add(label); - } - }); - - if (cameraConfig.zones[zoneName]) { - cameraConfig.zones[zoneName].objects.forEach((label) => { - if (!ATTRIBUTES.includes(label)) { - labels.add(label); - } - }); - } - - return [...labels].sort() || []; - }, [cameraConfig, zoneName]); - - const [currentLabels, setCurrentLabels] = useState( - zoneLabels, - ); - - const trigger = ( - - ); - - const content = ( - setOpen(false)} - /> - ); - - if (isMobile) { - return ( - { - if (!open) { - setCurrentLabels(zoneLabels); - } - - setOpen(open); - }} - > - {trigger} - - {content} - - - ); - } - - return ( - { - if (!open) { - setCurrentLabels(zoneLabels); - } - - setOpen(open); - }} - > - {trigger} - {content} - - ); -} - type NewZoneButtonProps = { camera: string; polygons: Polygon[]; diff --git a/web/src/components/settings/PolygonCanvas.tsx b/web/src/components/settings/PolygonCanvas.tsx index 2497e82bc..c8a2d325e 100644 --- a/web/src/components/settings/PolygonCanvas.tsx +++ b/web/src/components/settings/PolygonCanvas.tsx @@ -12,7 +12,7 @@ type PolygonCanvasProps = { height: number; polygons: Polygon[]; setPolygons: React.Dispatch>; - activePolygonIndex: number | null; + activePolygonIndex: number | undefined; }; export function PolygonCanvas({ @@ -68,7 +68,7 @@ export function PolygonCanvas({ }; const handleMouseDown = (e: KonvaEventObject) => { - if (activePolygonIndex == null || !polygons) { + if (!activePolygonIndex || !polygons) { return; } @@ -103,7 +103,7 @@ export function PolygonCanvas({ const handleMouseOverStartPoint = ( e: KonvaEventObject, ) => { - if (activePolygonIndex == null || !polygons) { + if (!activePolygonIndex || !polygons) { return; } @@ -118,7 +118,7 @@ export function PolygonCanvas({ ) => { e.currentTarget.scale({ x: 1, y: 1 }); - if (activePolygonIndex == null || !polygons) { + if (!activePolygonIndex || !polygons) { return; } @@ -134,7 +134,7 @@ export function PolygonCanvas({ const handlePointDragMove = ( e: KonvaEventObject, ) => { - if (activePolygonIndex == null || !polygons) { + if (!activePolygonIndex || !polygons) { return; } @@ -165,7 +165,7 @@ export function PolygonCanvas({ }; const handleGroupDragEnd = (e: KonvaEventObject) => { - if (activePolygonIndex !== null && e.target.name() === "polygon") { + if (activePolygonIndex && e.target.name() === "polygon") { const updatedPolygons = [...polygons]; const activePolygon = updatedPolygons[activePolygonIndex]; const result: number[][] = []; diff --git a/web/src/components/settings/PolygonDrawer.tsx b/web/src/components/settings/PolygonDrawer.tsx index 87adf112a..83fef8677 100644 --- a/web/src/components/settings/PolygonDrawer.tsx +++ b/web/src/components/settings/PolygonDrawer.tsx @@ -99,7 +99,7 @@ export default function PolygonDrawer({ stroke={colorString(true)} strokeWidth={3} closed={isFinished} - fill={colorString(false)} + fill={colorString(isActive ? true : false)} /> {points.map((point, index) => { if (!isActive) { @@ -122,9 +122,9 @@ export default function PolygonDrawer({ x={x} y={y} radius={vertexRadius} - fill={colorString(false)} - stroke="#cccccc" - strokeWidth={2} + stroke={colorString(true)} + fill="#ffffff" + strokeWidth={3} draggable={isActive} onDragMove={isActive ? handlePointDragMove : undefined} dragBoundFunc={(pos) => { diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx new file mode 100644 index 000000000..b0272702d --- /dev/null +++ b/web/src/components/settings/ZoneEditPane.tsx @@ -0,0 +1,300 @@ +import Heading from "../ui/heading"; +import { Separator } from "../ui/separator"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + 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, FrigateConfig } from "@/types/frigateConfig"; +import useSWR from "swr"; +import { isMobile } from "react-device-detect"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { Polygon } from "@/types/canvas"; +import { Switch } from "../ui/switch"; +import { Label } from "../ui/label"; + +type ZoneObjectSelectorProps = { + camera: string; + zoneName: string; + updateLabelFilter: (labels: string[] | undefined) => void; +}; + +export function ZoneObjectSelector({ + camera, + zoneName, + updateLabelFilter, +}: ZoneObjectSelectorProps) { + const { data: config } = useSWR("config"); + + const cameraConfig = useMemo(() => { + if (config && camera) { + return config.cameras[camera]; + } + }, [config, camera]); + + const allLabels = useMemo(() => { + if (!config) { + return []; + } + + const labels = new Set(); + + Object.values(config.cameras).forEach((camera) => { + camera.objects.track.forEach((label) => { + if (!ATTRIBUTES.includes(label)) { + labels.add(label); + } + }); + }); + + return [...labels].sort(); + }, [config]); + + const zoneLabels = useMemo(() => { + if (!cameraConfig || !zoneName) { + return []; + } + + const labels = new Set(); + + cameraConfig.objects.track.forEach((label) => { + if (!ATTRIBUTES.includes(label)) { + labels.add(label); + } + }); + + if (cameraConfig.zones[zoneName]) { + cameraConfig.zones[zoneName].objects.forEach((label) => { + if (!ATTRIBUTES.includes(label)) { + labels.add(label); + } + }); + } + + return [...labels].sort() || []; + }, [cameraConfig, zoneName]); + + const [currentLabels, setCurrentLabels] = useState( + zoneLabels, + ); + + useEffect(() => { + updateLabelFilter(currentLabels); + }, [currentLabels, updateLabelFilter]); + + return ( + <> +
+
+ + { + if (isChecked) { + setCurrentLabels(undefined); + } + }} + /> +
+ +
+ {allLabels.map((item) => ( +
+ + { + if (isChecked) { + const updatedLabels = currentLabels + ? [...currentLabels] + : []; + + updatedLabels.push(item); + setCurrentLabels(updatedLabels); + } else { + const updatedLabels = currentLabels + ? [...currentLabels] + : []; + + // can not deselect the last item + if (updatedLabels.length > 1) { + updatedLabels.splice(updatedLabels.indexOf(item), 1); + setCurrentLabels(updatedLabels); + } + } + }} + /> +
+ ))} +
+
+ + ); +} + +const formSchema = z.object({ + name: z.string().min(2, { + message: "Zone name must be at least 2 characters.", + }), + inertia: z.number(), + loitering_time: z.number(), +}); + +type ZoneEditPaneProps = { + polygons: Polygon[]; + activePolygonIndex?: number; + onCancel: () => void; +}; + +export function ZoneEditPane({ + polygons, + activePolygonIndex, + onCancel, +}: ZoneEditPaneProps) { + const polygon = useMemo(() => { + if (polygons && activePolygonIndex !== undefined) { + return polygons[activePolygonIndex]; + } else { + return null; + } + }, [polygons, activePolygonIndex]); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: "", + inertia: 3, + loitering_time: 10, + }, + }); + + function onSubmit(values: z.infer) { + console.log(values); + } + + if (!polygon) { + return; + } + + return ( + <> + Edit 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); + }} + /> + +
+ + +
+ + + + ); +} diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 40c1ef5cb..c84c8e821 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -176,7 +176,7 @@ export default function Settings() {
)}
-
+
{page == "general" && } {page == "objects" && <>} {page == "masks / zones" && ( diff --git a/web/src/utils/canvasUtil.ts b/web/src/utils/canvasUtil.ts index d9c26d936..7323f72f2 100644 --- a/web/src/utils/canvasUtil.ts +++ b/web/src/utils/canvasUtil.ts @@ -69,5 +69,5 @@ export const toRGBColorString = (color: number[], darkened: boolean) => { return "rgb(220,0,0,0.5)"; } - return `rgba(${color[2]},${color[1]},${color[0]},${darkened ? "0.9" : "0.5"})`; + return `rgba(${color[2]},${color[1]},${color[0]},${darkened ? "0.7" : "0.3"})`; };