From 9cbd80d981f7b9bd5f3dc3d0452ffbd6894fd826 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:14:13 -0500 Subject: [PATCH] Add motion previews filter (#22347) * add ability to filter motion previews via heatmap grid * i18n * use dialog on mobile --- web/public/locales/en/views/events.json | 5 +- .../filter/MotionRegionFilterGrid.tsx | 150 ++++++++++++++++++ web/src/views/events/EventView.tsx | 82 +++++++++- web/src/views/events/MotionPreviewsPane.tsx | 26 ++- 4 files changed, 259 insertions(+), 4 deletions(-) create mode 100644 web/src/components/filter/MotionRegionFilterGrid.tsx diff --git a/web/public/locales/en/views/events.json b/web/public/locales/en/views/events.json index ec0b29116..2efbd2652 100644 --- a/web/public/locales/en/views/events.json +++ b/web/public/locales/en/views/events.json @@ -80,6 +80,9 @@ "back": "Back", "empty": "No previews available", "noPreview": "Preview unavailable", - "seekAria": "Seek {{camera}} player to {{time}}" + "seekAria": "Seek {{camera}} player to {{time}}", + "filter": "Filter", + "filterDesc": "Select areas to only show clips with motion in those regions.", + "filterClear": "Clear" } } diff --git a/web/src/components/filter/MotionRegionFilterGrid.tsx b/web/src/components/filter/MotionRegionFilterGrid.tsx new file mode 100644 index 000000000..86457d07b --- /dev/null +++ b/web/src/components/filter/MotionRegionFilterGrid.tsx @@ -0,0 +1,150 @@ +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 lastCellRef = useRef(-1); + const gridRef = useRef(null); + + 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 getCellFromPoint = useCallback( + (clientX: number, clientY: number): number | null => { + const grid = gridRef.current; + + if (!grid) { + return null; + } + + const rect = grid.getBoundingClientRect(); + const x = clientX - rect.left; + const y = clientY - rect.top; + + if (x < 0 || y < 0 || x >= rect.width || y >= rect.height) { + return null; + } + + const col = Math.floor((x / rect.width) * GRID_SIZE); + const row = Math.floor((y / rect.height) * GRID_SIZE); + + return row * GRID_SIZE + col; + }, + [], + ); + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + e.preventDefault(); + const index = getCellFromPoint(e.clientX, e.clientY); + + if (index === null) { + return; + } + + const adding = !selectedCells.has(index); + paintingRef.current = { active: true, adding }; + lastCellRef.current = index; + toggleCell(index, adding); + }, + [selectedCells, toggleCell, getCellFromPoint], + ); + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (!paintingRef.current.active) { + return; + } + + const index = getCellFromPoint(e.clientX, e.clientY); + + if (index === null || index === lastCellRef.current) { + return; + } + + lastCellRef.current = index; + toggleCell(index, paintingRef.current.adding); + }, + [toggleCell, getCellFromPoint], + ); + + const handlePointerUp = useCallback(() => { + paintingRef.current.active = false; + lastCellRef.current = -1; + }, []); + + return ( +
+
+ +
+ {Array.from({ length: GRID_SIZE * GRID_SIZE }, (_, index) => { + const isSelected = selectedCells.has(index); + return ( +
+ ); + })} +
+
+
+ ); +} diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index f66f45858..442fb91d4 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -79,7 +79,17 @@ 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 ReviewActivityCalendar from "@/components/overlay/ReviewActivityCalendar"; import PlatformAwareDialog from "@/components/overlay/dialog/PlatformAwareDialog"; import MotionPreviewsPane from "./MotionPreviewsPane"; @@ -1117,6 +1127,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 +1331,68 @@ function MotionReview({ updateSelectedDay={onUpdateSelectedDay} /> )} + { + 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);