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 { isDesktop, isIOS, isMobile } from "react-device-detect";
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 { useApiHost } from "@/api";
import { useResizeObserver } from "@/hooks/resize-observer";
import { useFormattedTimestamp, use24HourTime } from "@/hooks/use-date-utils";
import { getUTCOffset } from "@/utils/dateUtil";
import useSWR from "swr";
@ -113,11 +112,35 @@ export default function MotionSearchDialog({
}: MotionSearchDialogProps) {
const { t } = useTranslation(["views/motionSearch", "common"]);
const apiHost = useApiHost();
const containerRef = useRef<HTMLDivElement>(null);
const [{ width: containerWidth, height: containerHeight }] =
useResizeObserver(containerRef);
const [containerNode, setContainerNode] = useState<HTMLDivElement | null>(
null,
);
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
const containerWidth = containerSize.width;
const containerHeight = containerSize.height;
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(() => {
if (!selectedCamera) return undefined;
return config.cameras[selectedCamera];
@ -258,16 +281,16 @@ export default function MotionSearchDialog({
}}
>
<div
ref={containerRef}
ref={setContainerNode}
className="relative flex w-full items-center justify-center overflow-hidden rounded-lg border bg-secondary"
style={{ aspectRatio: "16 / 9" }}
>
{selectedCamera && cameraConfig && imageSize.width > 0 ? (
{selectedCamera && cameraConfig ? (
<div
className="relative"
style={{
width: imageSize.width,
height: imageSize.height,
width: imageSize.width || "100%",
height: imageSize.height || "100%",
}}
>
<img
@ -277,6 +300,11 @@ export default function MotionSearchDialog({
src={`${apiHost}api/${selectedCamera}/latest.jpg?h=500`}
className="h-full w-full object-contain"
onLoad={() => setImageLoaded(true)}
ref={(node) => {
if (node?.complete && node.naturalWidth > 0) {
setImageLoaded(true);
}
}}
/>
{!imageLoaded && (
<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 Konva from "konva";
import type { KonvaEventObject } from "konva/lib/Node";
import { flattenPoints } from "@/utils/canvasUtil";
import { cn } from "@/lib/utils";
import { useResizeObserver } from "@/hooks/resize-observer";
type MotionSearchROICanvasProps = {
camera: string;
@ -30,18 +29,40 @@ export default function MotionSearchROICanvas({
motionHeatmap,
showMotionHeatmap = false,
}: MotionSearchROICanvasProps) {
const containerRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<Konva.Stage>(null);
const [{ width: containerWidth, height: containerHeight }] =
useResizeObserver(containerRef);
const stageSize = useMemo(
() => ({
width: containerWidth > 0 ? Math.ceil(containerWidth) : 0,
height: containerHeight > 0 ? Math.ceil(containerHeight) : 0,
}),
[containerHeight, containerWidth],
const [containerNode, setContainerNode] = useState<HTMLDivElement | null>(
null,
);
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 stageWidth = stageSize.width;
@ -317,7 +338,7 @@ export default function MotionSearchROICanvas({
return (
<div
ref={containerRef}
ref={setContainerNode}
className={cn(
"absolute inset-0 z-10",
isInteractive ? "pointer-events-auto" : "pointer-events-none",
@ -385,6 +406,8 @@ export default function MotionSearchROICanvas({
stroke="white"
strokeWidth={2}
draggable={!isDrawing}
onMouseDown={(e) => e.evt.stopPropagation()}
onTouchStart={(e) => e.evt.stopPropagation()}
onDragMove={(e) => handlePointDragMove(e, index)}
onMouseOver={(e) => handleMouseOverPoint(e, index)}
onMouseOut={(e) => handleMouseOutPoint(e, index)}

View File

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