diff --git a/web/src/components/filter/MotionRegionFilterGrid.tsx b/web/src/components/filter/MotionRegionFilterGrid.tsx new file mode 100644 index 000000000..b85843e6b --- /dev/null +++ b/web/src/components/filter/MotionRegionFilterGrid.tsx @@ -0,0 +1,110 @@ +import { baseUrl } from "@/api/baseUrl"; +import { useCallback, useRef } from "react"; + +const GRID_SIZE = 16; + +type MotionRegionFilterGridProps = { + cameraName: string; + selectedCells: Set; + onCellsChange: (cells: Set) => void; +}; + +export default function MotionRegionFilterGrid({ + cameraName, + selectedCells, + onCellsChange, +}: MotionRegionFilterGridProps) { + const paintingRef = useRef<{ active: boolean; adding: boolean }>({ + active: false, + adding: true, + }); + + const toggleCell = useCallback( + (index: number, forceAdd?: boolean) => { + const next = new Set(selectedCells); + + if (forceAdd !== undefined) { + if (forceAdd) { + next.add(index); + } else { + next.delete(index); + } + } else if (next.has(index)) { + next.delete(index); + } else { + next.add(index); + } + + onCellsChange(next); + }, + [selectedCells, onCellsChange], + ); + + const handlePointerDown = useCallback( + (index: number) => { + const adding = !selectedCells.has(index); + paintingRef.current = { active: true, adding }; + toggleCell(index, adding); + }, + [selectedCells, toggleCell], + ); + + const handlePointerEnter = useCallback( + (index: number) => { + if (!paintingRef.current.active) { + return; + } + + toggleCell(index, paintingRef.current.adding); + }, + [toggleCell], + ); + + const handlePointerUp = useCallback(() => { + paintingRef.current.active = false; + }, []); + + return ( +
+
+ +
+ {Array.from({ length: GRID_SIZE * GRID_SIZE }, (_, index) => { + const isSelected = selectedCells.has(index); + return ( +
{ + e.preventDefault(); + handlePointerDown(index); + }} + onPointerEnter={() => handlePointerEnter(index)} + /> + ); + })} +
+
+
+ ); +} diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index f66f45858..fe8dfc322 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -79,7 +79,18 @@ import { GiSoundWaves } from "react-icons/gi"; import useKeyboardListener from "@/hooks/use-keyboard-listener"; import { useTimelineZoom } from "@/hooks/use-timeline-zoom"; import { useTranslation } from "react-i18next"; -import { FaCog } from "react-icons/fa"; +import { FaCog, FaFilter } from "react-icons/fa"; +import MotionRegionFilterGrid from "@/components/filter/MotionRegionFilterGrid"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer"; import ReviewActivityCalendar from "@/components/overlay/ReviewActivityCalendar"; import PlatformAwareDialog from "@/components/overlay/dialog/PlatformAwareDialog"; import MotionPreviewsPane from "./MotionPreviewsPane"; @@ -1117,6 +1128,13 @@ function MotionReview({ const [controlsOpen, setControlsOpen] = useState(false); const [dimStrength, setDimStrength] = useState(82); const [isPreviewSettingsOpen, setIsPreviewSettingsOpen] = useState(false); + const [motionFilterCells, setMotionFilterCells] = useState>( + new Set(), + ); + const [pendingFilterCells, setPendingFilterCells] = useState>( + new Set(), + ); + const [isRegionFilterOpen, setIsRegionFilterOpen] = useState(false); const objectReviewItems = useMemo( () => @@ -1314,6 +1332,135 @@ function MotionReview({ updateSelectedDay={onUpdateSelectedDay} /> )} + {isDesktop ? ( + { + if (open) { + setPendingFilterCells(new Set(motionFilterCells)); + } + setIsRegionFilterOpen(open); + }} + > + + + + + + {t("motionPreviews.filter")} + + {t("motionPreviews.filterDesc")} + + + + + + + + + + ) : ( + { + if (open) { + setPendingFilterCells(new Set(motionFilterCells)); + } + setIsRegionFilterOpen(open); + }} + > + + + + +
+
+
+ {t("motionPreviews.filter")} +
+
+ {t("motionPreviews.filterDesc")} +
+
+ +
+ + +
+
+
+
+ )} { onOpenRecording({ camera: selectedMotionPreviewCamera.name, diff --git a/web/src/views/events/MotionPreviewsPane.tsx b/web/src/views/events/MotionPreviewsPane.tsx index fc080c956..7f126f107 100644 --- a/web/src/views/events/MotionPreviewsPane.tsx +++ b/web/src/views/events/MotionPreviewsPane.tsx @@ -589,6 +589,7 @@ type MotionPreviewsPaneProps = { isLoadingMotionRanges?: boolean; playbackRate: number; nonMotionAlpha: number; + motionFilterCells?: Set; onSeek: (timestamp: number) => void; }; @@ -600,6 +601,7 @@ export default function MotionPreviewsPane({ isLoadingMotionRanges = false, playbackRate, nonMotionAlpha, + motionFilterCells, onSeek, }: MotionPreviewsPaneProps) { const { t } = useTranslation(["views/events"]); @@ -879,6 +881,26 @@ export default function MotionPreviewsPane({ ], ); + const filteredClipData = useMemo(() => { + if (!motionFilterCells || motionFilterCells.size === 0) { + return clipData; + } + + return clipData.filter(({ motionHeatmap }) => { + if (!motionHeatmap) { + return false; + } + + for (const cellIndex of motionFilterCells) { + if ((motionHeatmap[cellIndex.toString()] ?? 0) > 0) { + return true; + } + } + + return false; + }); + }, [clipData, motionFilterCells]); + const hasCurrentHourRanges = useMemo( () => motionRanges.some((range) => isCurrentHour(range.end_time)), [motionRanges], @@ -901,13 +923,13 @@ export default function MotionPreviewsPane({ ref={setContentNode} className="no-scrollbar min-h-0 flex-1 overflow-y-auto" > - {clipData.length === 0 ? ( + {filteredClipData.length === 0 ? (
{t("motionPreviews.empty")}
) : (
- {clipData.map( + {filteredClipData.map( ({ range, preview, fallbackFrameTimes, motionHeatmap }, idx) => { const clipId = `${camera.name}-${range.start_time}-${range.end_time}-${idx}`; const isMounted = mountedClips.has(clipId);