render masks and zones based on ws enabled state

This commit is contained in:
Josh Hawkins 2026-01-21 10:25:26 -06:00
parent 14d9068bfa
commit c3be785061
4 changed files with 95 additions and 156 deletions

View File

@ -7,6 +7,7 @@ import { Polygon, PolygonType } from "@/types/canvas";
import { useApiHost } from "@/api";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { snapPointToLines } from "@/utils/canvasUtil";
import { usePolygonStates } from "@/hooks/use-polygon-states";
type PolygonCanvasProps = {
containerRef: RefObject<HTMLDivElement>;
@ -40,6 +41,7 @@ export function PolygonCanvas({
const imageRef = useRef<Konva.Image | null>(null);
const stageRef = useRef<Konva.Stage>(null);
const apiHost = useApiHost();
const getPolygonEnabled = usePolygonStates(polygons);
const videoElement = useMemo(() => {
if (camera && width && height) {
@ -321,7 +323,7 @@ export function PolygonCanvas({
isActive={index === activePolygonIndex}
isHovered={index === hoveredPolygonIndex}
isFinished={polygon.isFinished}
enabled={polygon.enabled}
enabled={getPolygonEnabled(polygon)}
color={polygon.color}
handlePointDragMove={handlePointDragMove}
handleGroupDragEnd={handleGroupDragEnd}
@ -351,7 +353,7 @@ export function PolygonCanvas({
isActive={true}
isHovered={activePolygonIndex === hoveredPolygonIndex}
isFinished={polygons[activePolygonIndex].isFinished}
enabled={polygons[activePolygonIndex].enabled}
enabled={getPolygonEnabled(polygons[activePolygonIndex])}
color={polygons[activePolygonIndex].color}
handlePointDragMove={handlePointDragMove}
handleGroupDragEnd={handleGroupDragEnd}

View File

@ -34,6 +34,7 @@ import { buttonVariants } from "../ui/button";
import { Trans, useTranslation } from "react-i18next";
import ActivityIndicator from "../indicators/activity-indicator";
import { cn } from "@/lib/utils";
import { useMotionMaskState, useObjectMaskState, useZoneState } from "@/api/ws";
type PolygonItemProps = {
polygon: Polygon;
@ -66,6 +67,31 @@ export default function PolygonItem({
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const { payload: motionMaskState, send: sendMotionMaskState } =
useMotionMaskState(polygon.camera, polygon.name);
const { payload: objectMaskState, send: sendObjectMaskState } =
useObjectMaskState(polygon.camera, polygon.name);
const { payload: zoneState, send: sendZoneState } = useZoneState(
polygon.camera,
polygon.name,
);
const isPolygonEnabled = useMemo(() => {
const wsState =
polygon.type === "zone"
? zoneState
: polygon.type === "motion_mask"
? motionMaskState
: objectMaskState;
const wsEnabled =
wsState === "ON" ? true : wsState === "OFF" ? false : undefined;
return wsEnabled ?? polygon.enabled ?? true;
}, [
polygon.enabled,
polygon.type,
zoneState,
motionMaskState,
objectMaskState,
]);
const cameraConfig = useMemo(() => {
if (polygon?.camera && config) {
@ -261,164 +287,35 @@ export default function PolygonItem({
};
const handleToggleEnabled = useCallback(
async (e: React.MouseEvent) => {
(e: React.MouseEvent) => {
e.stopPropagation();
if (!polygon || !cameraConfig) {
if (!polygon) {
return;
}
const newEnabledState = polygon.enabled === false;
const updateTopicType =
polygon.type === "zone"
? "zones"
: polygon.type === "motion_mask"
? "motion"
: polygon.type === "object_mask"
? "objects"
: polygon.type;
setIsLoading(true);
setLoadingPolygonIndex(index);
const isEnabled = isPolygonEnabled;
const nextState = isEnabled ? "OFF" : "ON";
if (polygon.type === "zone") {
// Zones use query string format
const url = `cameras.${polygon.camera}.zones.${polygon.name}.enabled=${newEnabledState ? "True" : "False"}`;
await axios
.put(`config/set?${url}`, {
requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/${updateTopicType}`,
})
.then((res) => {
if (res.status === 200) {
updateConfig();
} else {
toast.error(
t("toast.save.error.title", {
ns: "common",
errorMessage: res.statusText,
}),
{ position: "top-center" },
);
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("toast.save.error.title", { errorMessage, ns: "common" }),
{ position: "top-center" },
);
})
.finally(() => {
setIsLoading(false);
});
sendZoneState(nextState);
return;
}
// Motion masks and object masks use JSON body format
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let configUpdate: any = {};
if (polygon.type === "motion_mask") {
configUpdate = {
cameras: {
[polygon.camera]: {
motion: {
mask: {
[polygon.name]: {
enabled: newEnabledState,
},
},
},
},
},
};
sendMotionMaskState(nextState);
return;
}
if (polygon.type === "object_mask") {
// Determine if this is a global mask or object-specific mask
const isGlobalMask = !polygon.objects.length;
if (isGlobalMask) {
configUpdate = {
cameras: {
[polygon.camera]: {
objects: {
mask: {
[polygon.name]: {
enabled: newEnabledState,
},
},
},
},
},
};
} else {
configUpdate = {
cameras: {
[polygon.camera]: {
objects: {
filters: {
[polygon.objects[0]]: {
mask: {
[polygon.name]: {
enabled: newEnabledState,
},
},
},
},
},
},
},
};
}
sendObjectMaskState(nextState);
}
await axios
.put("config/set", {
config_data: configUpdate,
requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/${updateTopicType}`,
})
.then((res) => {
if (res.status === 200) {
updateConfig();
} else {
toast.error(
t("toast.save.error.title", {
ns: "common",
errorMessage: res.statusText,
}),
{ position: "top-center" },
);
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("toast.save.error.title", { errorMessage, ns: "common" }),
{ position: "top-center" },
);
})
.finally(() => {
setIsLoading(false);
setLoadingPolygonIndex(undefined);
});
},
[
updateConfig,
cameraConfig,
t,
isPolygonEnabled,
polygon,
setIsLoading,
index,
setLoadingPolygonIndex,
sendZoneState,
sendMotionMaskState,
sendObjectMaskState,
],
);
@ -463,30 +360,27 @@ export default function PolygonItem({
<PolygonItemIcon
className="size-5"
style={{
fill: toRGBColorString(
polygon.color,
polygon.enabled ?? true,
),
fill: toRGBColorString(polygon.color, isPolygonEnabled),
color: toRGBColorString(
polygon.color,
polygon.enabled ?? true,
isPolygonEnabled,
),
}}
/>
</button>
</TooltipTrigger>
<TooltipContent>
{polygon.enabled === false
? t("button.enable", { ns: "common" })
: t("button.disable", { ns: "common" })}
{isPolygonEnabled
? t("button.disable", { ns: "common" })
: t("button.enable", { ns: "common" })}
</TooltipContent>
</Tooltip>
))}
<p
className={`cursor-default ${polygon.enabled === false ? "line-through" : ""}`}
className={`cursor-default ${!isPolygonEnabled ? "line-through" : ""}`}
>
{polygon.friendly_name ?? polygon.name}
{polygon.enabled === false && " (disabled)"}
{!isPolygonEnabled && " (disabled)"}
</p>
</div>
<AlertDialog

View File

@ -0,0 +1,44 @@
import { useMemo } from "react";
import { Polygon } from "@/types/canvas";
import { useWsState } from "@/api/ws";
/**
* Hook to get enabled state for a polygon from websocket state.
* Memoizes the lookup function to avoid unnecessary re-renders.
*/
export function usePolygonStates(polygons: Polygon[]) {
const wsState = useWsState();
// Create a memoized lookup map that only updates when relevant ws values change
return useMemo(() => {
const stateMap = new Map<string, boolean>();
polygons.forEach((polygon) => {
const topic =
polygon.type === "zone"
? `${polygon.camera}/zone/${polygon.name}/state`
: polygon.type === "motion_mask"
? `${polygon.camera}/motion_mask/${polygon.name}/state`
: `${polygon.camera}/object_mask/${polygon.name}/state`;
const wsValue = wsState[topic];
const enabled =
wsValue === "ON"
? true
: wsValue === "OFF"
? false
: (polygon.enabled ?? true);
stateMap.set(
`${polygon.camera}/${polygon.type}/${polygon.name}`,
enabled,
);
});
return (polygon: Polygon) => {
return (
stateMap.get(`${polygon.camera}/${polygon.type}/${polygon.name}`) ??
true
);
};
}, [polygons, wsState]);
}

View File

@ -300,7 +300,6 @@ export default function MasksAndZonesView({
}),
);
const globalObjectMaskIds = Object.keys(cameraConfig.objects.mask || {});
let objectMaskIndex = globalObjectMasks.length;
objectMasks = Object.entries(cameraConfig.objects.filters)
@ -311,8 +310,8 @@ export default function MasksAndZonesView({
.flatMap(([objectName, filterConfig]): Polygon[] => {
return Object.entries(filterConfig.mask || {}).flatMap(
([maskId, maskData]) => {
// Skip if this mask is already included in global masks
if (globalObjectMaskIds.includes(maskId)) {
// Skip if this mask is a global mask (prefixed with "global_")
if (maskId.startsWith("global_")) {
return [];
}