motion search fixes

- tweak progress bar to exclude heatmap and inactive segments
- show metrics immediately on search start
- fix preview frame loading race
- fix polygon missing after dialog remount
- don't try to drag the image when dragging vertex of polygon
This commit is contained in:
Josh Hawkins 2026-04-08 07:36:17 -05:00
parent c0f70295d8
commit c6c4d818be
3 changed files with 89 additions and 33 deletions

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { isDesktop, isIOS, isMobile } from "react-device-detect"; import { isDesktop, isIOS, isMobile } from "react-device-detect";
import { FaArrowRight, FaCalendarAlt, FaCheckCircle } from "react-icons/fa"; import { FaArrowRight, FaCalendarAlt, FaCheckCircle } from "react-icons/fa";
@ -42,7 +42,6 @@ import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import { TimezoneAwareCalendar } from "@/components/overlay/ReviewActivityCalendar"; import { TimezoneAwareCalendar } from "@/components/overlay/ReviewActivityCalendar";
import { useApiHost } from "@/api"; import { useApiHost } from "@/api";
import { useResizeObserver } from "@/hooks/resize-observer";
import { useFormattedTimestamp, use24HourTime } from "@/hooks/use-date-utils"; import { useFormattedTimestamp, use24HourTime } from "@/hooks/use-date-utils";
import { getUTCOffset } from "@/utils/dateUtil"; import { getUTCOffset } from "@/utils/dateUtil";
import useSWR from "swr"; import useSWR from "swr";
@ -113,11 +112,35 @@ export default function MotionSearchDialog({
}: MotionSearchDialogProps) { }: MotionSearchDialogProps) {
const { t } = useTranslation(["views/motionSearch", "common"]); const { t } = useTranslation(["views/motionSearch", "common"]);
const apiHost = useApiHost(); const apiHost = useApiHost();
const containerRef = useRef<HTMLDivElement>(null); const [containerNode, setContainerNode] = useState<HTMLDivElement | null>(
const [{ width: containerWidth, height: containerHeight }] = null,
useResizeObserver(containerRef); );
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
const containerWidth = containerSize.width;
const containerHeight = containerSize.height;
const [imageLoaded, setImageLoaded] = useState(false); const [imageLoaded, setImageLoaded] = useState(false);
useEffect(() => {
if (!containerNode) {
return;
}
const measure = () => {
const rect = containerNode.getBoundingClientRect();
setContainerSize((prev) =>
prev.width === rect.width && prev.height === rect.height
? prev
: { width: rect.width, height: rect.height },
);
};
measure();
const observer = new ResizeObserver(() => measure());
observer.observe(containerNode);
return () => observer.disconnect();
}, [containerNode]);
const cameraConfig = useMemo(() => { const cameraConfig = useMemo(() => {
if (!selectedCamera) return undefined; if (!selectedCamera) return undefined;
return config.cameras[selectedCamera]; return config.cameras[selectedCamera];
@ -258,16 +281,16 @@ export default function MotionSearchDialog({
}} }}
> >
<div <div
ref={containerRef} ref={setContainerNode}
className="relative flex w-full items-center justify-center overflow-hidden rounded-lg border bg-secondary" className="relative flex w-full items-center justify-center overflow-hidden rounded-lg border bg-secondary"
style={{ aspectRatio: "16 / 9" }} style={{ aspectRatio: "16 / 9" }}
> >
{selectedCamera && cameraConfig && imageSize.width > 0 ? ( {selectedCamera && cameraConfig ? (
<div <div
className="relative" className="relative"
style={{ style={{
width: imageSize.width, width: imageSize.width || "100%",
height: imageSize.height, height: imageSize.height || "100%",
}} }}
> >
<img <img
@ -277,6 +300,11 @@ export default function MotionSearchDialog({
src={`${apiHost}api/${selectedCamera}/latest.jpg?h=500`} src={`${apiHost}api/${selectedCamera}/latest.jpg?h=500`}
className="h-full w-full object-contain" className="h-full w-full object-contain"
onLoad={() => setImageLoaded(true)} onLoad={() => setImageLoaded(true)}
ref={(node) => {
if (node?.complete && node.naturalWidth > 0) {
setImageLoaded(true);
}
}}
/> />
{!imageLoaded && ( {!imageLoaded && (
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">

View File

@ -1,10 +1,9 @@
import { useCallback, useMemo, useRef } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Stage, Layer, Line, Circle, Image } from "react-konva"; import { Stage, Layer, Line, Circle, Image } from "react-konva";
import Konva from "konva"; import Konva from "konva";
import type { KonvaEventObject } from "konva/lib/Node"; import type { KonvaEventObject } from "konva/lib/Node";
import { flattenPoints } from "@/utils/canvasUtil"; import { flattenPoints } from "@/utils/canvasUtil";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useResizeObserver } from "@/hooks/resize-observer";
type MotionSearchROICanvasProps = { type MotionSearchROICanvasProps = {
camera: string; camera: string;
@ -30,18 +29,40 @@ export default function MotionSearchROICanvas({
motionHeatmap, motionHeatmap,
showMotionHeatmap = false, showMotionHeatmap = false,
}: MotionSearchROICanvasProps) { }: MotionSearchROICanvasProps) {
const containerRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<Konva.Stage>(null); const stageRef = useRef<Konva.Stage>(null);
const [{ width: containerWidth, height: containerHeight }] = const [containerNode, setContainerNode] = useState<HTMLDivElement | null>(
useResizeObserver(containerRef); null,
const stageSize = useMemo(
() => ({
width: containerWidth > 0 ? Math.ceil(containerWidth) : 0,
height: containerHeight > 0 ? Math.ceil(containerHeight) : 0,
}),
[containerHeight, containerWidth],
); );
const [stageSize, setStageSize] = useState({ width: 0, height: 0 });
useEffect(() => {
if (!containerNode) {
return;
}
const apply = (width: number, height: number) => {
setStageSize((prev) => {
const next = {
width: width > 0 ? Math.ceil(width) : 0,
height: height > 0 ? Math.ceil(height) : 0,
};
if (prev.width === next.width && prev.height === next.height) {
return prev;
}
return next;
});
};
apply(containerNode.clientWidth, containerNode.clientHeight);
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
apply(entry.contentRect.width, entry.contentRect.height);
});
observer.observe(containerNode);
return () => observer.disconnect();
}, [containerNode]);
const videoRect = useMemo(() => { const videoRect = useMemo(() => {
const stageWidth = stageSize.width; const stageWidth = stageSize.width;
@ -317,7 +338,7 @@ export default function MotionSearchROICanvas({
return ( return (
<div <div
ref={containerRef} ref={setContainerNode}
className={cn( className={cn(
"absolute inset-0 z-10", "absolute inset-0 z-10",
isInteractive ? "pointer-events-auto" : "pointer-events-none", isInteractive ? "pointer-events-auto" : "pointer-events-none",
@ -385,6 +406,8 @@ export default function MotionSearchROICanvas({
stroke="white" stroke="white"
strokeWidth={2} strokeWidth={2}
draggable={!isDrawing} draggable={!isDrawing}
onMouseDown={(e) => e.evt.stopPropagation()}
onTouchStart={(e) => e.evt.stopPropagation()}
onDragMove={(e) => handlePointDragMove(e, index)} onDragMove={(e) => handlePointDragMove(e, index)}
onMouseOver={(e) => handleMouseOverPoint(e, index)} onMouseOver={(e) => handleMouseOverPoint(e, index)}
onMouseOut={(e) => handleMouseOutPoint(e, index)} onMouseOut={(e) => handleMouseOutPoint(e, index)}

View File

@ -994,15 +994,20 @@ export default function MotionSearchView({
); );
const progressMetrics = jobStatus?.metrics ?? searchMetrics; const progressMetrics = jobStatus?.metrics ?? searchMetrics;
const progressValue = const progressValue = (() => {
progressMetrics && progressMetrics.segments_scanned > 0 if (!progressMetrics || progressMetrics.segments_scanned <= 0) {
? Math.min( return 0;
100, }
(progressMetrics.segments_processed / const skipped =
progressMetrics.segments_scanned) * progressMetrics.heatmap_roi_skip_segments +
100, progressMetrics.metadata_inactive_segments;
) const totalWork = progressMetrics.segments_scanned - skipped;
: 0; const doneWork = progressMetrics.segments_processed - skipped;
if (totalWork <= 0) {
return 100;
}
return Math.min(100, Math.max(0, (doneWork / totalWork) * 100));
})();
const resultsPanel = ( const resultsPanel = (
<> <>
@ -1036,8 +1041,8 @@ export default function MotionSearchView({
<Progress className="h-1" value={progressValue} /> <Progress className="h-1" value={progressValue} />
</div> </div>
)} )}
{searchMetrics && searchResults.length > 0 && ( {searchMetrics && (isSearching || searchResults.length > 0) && (
<div className="mx-2 rounded-lg border bg-secondary p-2"> <div className="mx-2 my-3 rounded-lg border bg-secondary p-2">
<div className="space-y-0.5 text-xs text-muted-foreground"> <div className="space-y-0.5 text-xs text-muted-foreground">
<div className="flex justify-between"> <div className="flex justify-between">
<span>{t("metrics.segmentsScanned")}</span> <span>{t("metrics.segmentsScanned")}</span>