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

View File

@ -34,6 +34,7 @@ import { buttonVariants } from "../ui/button";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import ActivityIndicator from "../indicators/activity-indicator"; import ActivityIndicator from "../indicators/activity-indicator";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useMotionMaskState, useObjectMaskState, useZoneState } from "@/api/ws";
type PolygonItemProps = { type PolygonItemProps = {
polygon: Polygon; polygon: Polygon;
@ -66,6 +67,31 @@ export default function PolygonItem({
const { data: config, mutate: updateConfig } = const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config"); useSWR<FrigateConfig>("config");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); 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(() => { const cameraConfig = useMemo(() => {
if (polygon?.camera && config) { if (polygon?.camera && config) {
@ -261,164 +287,35 @@ export default function PolygonItem({
}; };
const handleToggleEnabled = useCallback( const handleToggleEnabled = useCallback(
async (e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
if (!polygon || !cameraConfig) { if (!polygon) {
return; return;
} }
const newEnabledState = polygon.enabled === false; const isEnabled = isPolygonEnabled;
const updateTopicType = const nextState = isEnabled ? "OFF" : "ON";
polygon.type === "zone"
? "zones"
: polygon.type === "motion_mask"
? "motion"
: polygon.type === "object_mask"
? "objects"
: polygon.type;
setIsLoading(true);
setLoadingPolygonIndex(index);
if (polygon.type === "zone") { if (polygon.type === "zone") {
// Zones use query string format sendZoneState(nextState);
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);
});
return; 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") { if (polygon.type === "motion_mask") {
configUpdate = { sendMotionMaskState(nextState);
cameras: { return;
[polygon.camera]: {
motion: {
mask: {
[polygon.name]: {
enabled: newEnabledState,
},
},
},
},
},
};
} }
if (polygon.type === "object_mask") { if (polygon.type === "object_mask") {
// Determine if this is a global mask or object-specific mask sendObjectMaskState(nextState);
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,
},
},
},
},
},
},
},
};
}
} }
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, isPolygonEnabled,
cameraConfig,
t,
polygon, polygon,
setIsLoading, sendZoneState,
index, sendMotionMaskState,
setLoadingPolygonIndex, sendObjectMaskState,
], ],
); );
@ -463,30 +360,27 @@ export default function PolygonItem({
<PolygonItemIcon <PolygonItemIcon
className="size-5" className="size-5"
style={{ style={{
fill: toRGBColorString( fill: toRGBColorString(polygon.color, isPolygonEnabled),
polygon.color,
polygon.enabled ?? true,
),
color: toRGBColorString( color: toRGBColorString(
polygon.color, polygon.color,
polygon.enabled ?? true, isPolygonEnabled,
), ),
}} }}
/> />
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{polygon.enabled === false {isPolygonEnabled
? t("button.enable", { ns: "common" }) ? t("button.disable", { ns: "common" })
: t("button.disable", { ns: "common" })} : t("button.enable", { ns: "common" })}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
))} ))}
<p <p
className={`cursor-default ${polygon.enabled === false ? "line-through" : ""}`} className={`cursor-default ${!isPolygonEnabled ? "line-through" : ""}`}
> >
{polygon.friendly_name ?? polygon.name} {polygon.friendly_name ?? polygon.name}
{polygon.enabled === false && " (disabled)"} {!isPolygonEnabled && " (disabled)"}
</p> </p>
</div> </div>
<AlertDialog <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; let objectMaskIndex = globalObjectMasks.length;
objectMasks = Object.entries(cameraConfig.objects.filters) objectMasks = Object.entries(cameraConfig.objects.filters)
@ -311,8 +310,8 @@ export default function MasksAndZonesView({
.flatMap(([objectName, filterConfig]): Polygon[] => { .flatMap(([objectName, filterConfig]): Polygon[] => {
return Object.entries(filterConfig.mask || {}).flatMap( return Object.entries(filterConfig.mask || {}).flatMap(
([maskId, maskData]) => { ([maskId, maskData]) => {
// Skip if this mask is already included in global masks // Skip if this mask is a global mask (prefixed with "global_")
if (globalObjectMaskIds.includes(maskId)) { if (maskId.startsWith("global_")) {
return []; return [];
} }