- {cameraConfig ? (
+ {cameraConfig &&
+ scaledWidth &&
+ scaledHeight &&
+ editingPolygons ? (
);
}
+
+function PolygonItem({
+ polygon,
+ setAllPolygons,
+ index,
+ activePolygonIndex,
+ hoveredPolygonIndex,
+ setHoveredPolygonIndex,
+ deleteDialogOpen,
+ setDeleteDialogOpen,
+ setActivePolygonIndex,
+ setEditPane,
+ handleCopyCoordinates,
+}: PolygonItemProps) {
+ const polygonTypeIcons = {
+ zone: FaDrawPolygon,
+ motion_mask: FaObjectGroup,
+ object_mask: BsPersonBoundingBox,
+ };
+
+ const PolygonItemIcon = polygon ? polygonTypeIcons[polygon.type] : undefined;
+
+ return (
+
setHoveredPolygonIndex(index)}
+ onMouseLeave={() => setHoveredPolygonIndex(null)}
+ style={{
+ backgroundColor:
+ hoveredPolygonIndex === index
+ ? toRGBColorString(polygon.color, false)
+ : "",
+ }}
+ >
+ {isMobile && <>>}
+
+ {PolygonItemIcon && (
+
+ )}
+
{polygon.name}
+
+ {deleteDialogOpen && hoveredPolygonIndex === index && (
+
setDeleteDialogOpen(!deleteDialogOpen)}
+ >
+
+
+ Confirm Delete
+
+
+ Are you sure you want to delete the{" "}
+ {polygon.type.replace("_", " ")} {polygon.name}?
+
+
+ Cancel
+ {
+ setAllPolygons((oldPolygons) => {
+ return oldPolygons.filter((_, i) => i !== index);
+ });
+ setActivePolygonIndex(undefined);
+ }}
+ >
+ Delete
+
+
+
+
+ )}
+ {hoveredPolygonIndex === index && (
+
+
{
+ setActivePolygonIndex(index);
+ setEditPane(polygon.type);
+ }}
+ >
+
+
+
handleCopyCoordinates(index)}
+ >
+
+
+
setDeleteDialogOpen(true)}
+ >
+
+
+
+ )}
+
+ );
+}
diff --git a/web/src/components/settings/NewZoneButton.tsx b/web/src/components/settings/NewZoneButton.tsx
deleted file mode 100644
index 0374b23f5..000000000
--- a/web/src/components/settings/NewZoneButton.tsx
+++ /dev/null
@@ -1,158 +0,0 @@
-import { Polygon } from "@/types/canvas";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogTitle,
-} from "@/components/ui/dialog";
-import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
-import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
-import { useMemo, useState } from "react";
-import { Input } from "../ui/input";
-import { GeneralFilterContent } from "../filter/ReviewFilterGroup";
-import { FaObjectGroup } from "react-icons/fa";
-import { Button } from "../ui/button";
-import { ATTRIBUTES, FrigateConfig } from "@/types/frigateConfig";
-import useSWR from "swr";
-import { isMobile } from "react-device-detect";
-import { LuPlusSquare } from "react-icons/lu";
-
-type NewZoneButtonProps = {
- camera: string;
- polygons: Polygon[];
- setPolygons: React.Dispatch
>;
- activePolygonIndex: number | null;
- setActivePolygonIndex: React.Dispatch>;
-};
-
-export function NewZoneButton({
- camera,
- polygons,
- setPolygons,
- activePolygonIndex,
- setActivePolygonIndex,
-}: NewZoneButtonProps) {
- const { data: config } = useSWR("config");
- const [zoneName, setZoneName] = useState();
- const [invalidName, setInvalidName] = useState();
- const [dialogOpen, setDialogOpen] = useState(false);
-
- 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] = {
- points: [],
- isFinished: false,
- name: updatedPolygons[activePolygonIndex].name,
- camera: camera,
- color: updatedPolygons[activePolygonIndex].color ?? [220, 0, 0],
- };
- setPolygons(updatedPolygons);
- }
- };
-
- const handleNewPolygon = (zoneName: string) => {
- setPolygons([
- ...(polygons || []),
- {
- points: [],
- isFinished: false,
- // isUnsaved: true,
- name: zoneName,
- camera: camera,
- color: [220, 0, 0],
- },
- ]);
- setActivePolygonIndex(polygons.length);
- };
-
- return (
-
- {/*
-
*/}
-
-
-
-
- );
-}
-
-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
+
+
+
+
+
+
+
+ >
+ );
+}
+
type ZoneObjectSelectorProps = {
camera: string;
zoneName: string;
@@ -101,10 +356,7 @@ export function ZoneObjectSelector({
<>
-