object path plotter per camera with time selection dropdown (#16676)

This commit is contained in:
Josh Hawkins 2025-02-18 21:55:16 -06:00 committed by GitHub
parent 7abf28bcbc
commit 2b3ab02ebf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 570 additions and 7 deletions

169
web/package-lock.json generated
View File

@ -25,7 +25,7 @@
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.2.1",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toggle": "^1.1.0",
@ -1176,6 +1176,24 @@
}
}
},
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz",
"integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz",
@ -1293,6 +1311,24 @@
}
}
},
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz",
"integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz",
@ -1417,6 +1453,24 @@
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz",
"integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz",
@ -1685,6 +1739,24 @@
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz",
"integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.2.tgz",
@ -1737,6 +1809,24 @@
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz",
"integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz",
@ -1840,6 +1930,24 @@
}
}
},
"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz",
"integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-radio-group": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.1.tgz",
@ -2022,6 +2130,24 @@
}
}
},
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz",
"integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.0.tgz",
@ -2094,12 +2220,12 @@
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz",
"integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==",
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0"
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
@ -2111,6 +2237,21 @@
}
}
},
"node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
"integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-switch": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.1.tgz",
@ -2303,6 +2444,24 @@
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz",
"integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",

View File

@ -31,7 +31,7 @@
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.2.1",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toggle": "^1.1.0",

View File

@ -15,6 +15,7 @@ type ObjectPathProps = {
pointRadius?: number;
imgRef: React.RefObject<HTMLImageElement>;
onPointClick?: (index: number) => void;
visible?: boolean;
};
const typeColorMap: Partial<
@ -37,6 +38,7 @@ export function ObjectPath({
pointRadius = 4,
imgRef,
onPointClick,
visible = true,
}: ObjectPathProps) {
const getAbsolutePositions = useCallback(() => {
if (!imgRef.current || !positions) return [];
@ -69,7 +71,7 @@ export function ObjectPath({
return `rgb(${baseColor.map((c) => Math.max(0, c - 10)).join(",")})`;
};
if (!imgRef.current) return null;
if (!imgRef.current || !visible) return null;
const absolutePositions = getAbsolutePositions();
const lineColor = `rgb(${color.join(",")})`;

View File

@ -0,0 +1,281 @@
import { useState, useEffect, useMemo, useRef } from "react";
import useSWR from "swr";
import { useApiHost } from "@/api";
import type { SearchResult } from "@/types/search";
import { ObjectPath } from "./ObjectPath";
import type { FrigateConfig } from "@/types/frigateConfig";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent } from "@/components/ui/card";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { useTimezone } from "@/hooks/use-date-utils";
import { Button } from "@/components/ui/button";
import { LuX } from "react-icons/lu";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
export default function ObjectPathPlotter() {
const apiHost = useApiHost();
const [timeRange, setTimeRange] = useState("1d");
const { data: config } = useSWR<FrigateConfig>("config");
const imgRef = useRef<HTMLImageElement>(null);
const timezone = useTimezone(config);
const [selectedCamera, setSelectedCamera] = useState<string>("");
const [selectedEvent, setSelectedEvent] = useState<SearchResult | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const eventsPerPage = 20;
useEffect(() => {
if (config && !selectedCamera) {
setSelectedCamera(Object.keys(config.cameras)[0]);
}
}, [config, selectedCamera]);
const searchQuery = useMemo(() => {
if (!selectedCamera) return null;
return [
"events",
{
cameras: selectedCamera,
after: Math.floor(Date.now() / 1000) - getTimeRangeInSeconds(timeRange),
before: Math.floor(Date.now() / 1000),
has_clip: 1,
include_thumbnails: 0,
limit: 1000,
timezone,
},
];
}, [selectedCamera, timeRange, timezone]);
const { data: events } = useSWR<SearchResult[]>(searchQuery);
const aspectRatio = useMemo(() => {
if (!config || !selectedCamera) return 16 / 9;
return (
config.cameras[selectedCamera].detect.width /
config.cameras[selectedCamera].detect.height
);
}, [config, selectedCamera]);
const pathPoints = useMemo(() => {
if (!events) return [];
return events.flatMap(
(event) =>
event.data.path_data?.map(
([coords, timestamp]: [number[], number]) => ({
x: coords[0],
y: coords[1],
timestamp,
event,
}),
) || [],
);
}, [events]);
const getRandomColor = () => {
return [
Math.floor(Math.random() * 256),
Math.floor(Math.random() * 256),
Math.floor(Math.random() * 256),
];
};
const eventColors = useMemo(() => {
if (!events) return {};
return events.reduce(
(acc, event) => {
acc[event.id] = getRandomColor();
return acc;
},
{} as Record<string, number[]>,
);
}, [events]);
const [imageLoaded, setImageLoaded] = useState(false);
useEffect(() => {
if (!selectedCamera) return;
const img = new Image();
img.src = selectedEvent
? `${apiHost}api/${selectedCamera}/recordings/${selectedEvent.start_time}/snapshot.jpg`
: `${apiHost}api/${selectedCamera}/latest.jpg?h=500`;
img.onload = () => {
if (imgRef.current) {
imgRef.current.src = img.src;
setImageLoaded(true);
}
};
}, [apiHost, selectedCamera, selectedEvent]);
const handleEventClick = (event: SearchResult) => {
setSelectedEvent(event.id === selectedEvent?.id ? null : event);
};
const clearSelectedEvent = () => {
setSelectedEvent(null);
};
const totalPages = Math.ceil((events?.length || 0) / eventsPerPage);
const paginatedEvents = events?.slice(
(currentPage - 1) * eventsPerPage,
currentPage * eventsPerPage,
);
return (
<Card className="p-4">
<CardContent>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-2xl font-bold">Tracked Object Paths</h2>
<div className="flex space-x-2">
<Select value={selectedCamera} onValueChange={setSelectedCamera}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select camera" />
</SelectTrigger>
<SelectContent>
{config &&
Object.keys(config.cameras).map((cameraName) => (
<SelectItem key={cameraName} value={cameraName}>
{cameraName}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select time range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1h">Last 1 hour</SelectItem>
<SelectItem value="6h">Last 6 hours</SelectItem>
<SelectItem value="12h">Last 12 hours</SelectItem>
<SelectItem value="1d">Last 24 hours</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="relative" style={{ aspectRatio }}>
<img
ref={imgRef}
src="/placeholder.svg"
alt={`Latest from ${selectedCamera}`}
className="h-auto w-full"
/>
{imgRef.current && imageLoaded && (
<svg
viewBox={`0 0 ${imgRef.current.width} ${imgRef.current.height}`}
className="absolute inset-0"
>
{events?.map((event) => (
<ObjectPath
key={event.id}
positions={pathPoints.filter(
(point) => point.event.id === event.id,
)}
color={eventColors[event.id]}
width={2}
imgRef={imgRef}
visible={
selectedEvent === null || selectedEvent.id === event.id
}
/>
))}
</svg>
)}
</div>
<div className="mt-4">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-xl font-semibold">Legend</h3>
{selectedEvent && (
<Button
variant="outline"
size="sm"
onClick={clearSelectedEvent}
className="flex items-center"
>
<LuX className="mr-1" /> Clear Selection
</Button>
)}
</div>
<div className="mb-4 grid grid-cols-2 gap-1">
{paginatedEvents?.map((event) => (
<div
key={event.id}
className={`flex cursor-pointer items-center rounded p-1 ${
selectedEvent?.id === event.id ? "bg-secondary" : ""
}`}
onClick={() => handleEventClick(event)}
>
<div
className="mr-2 h-4 w-4 flex-shrink-0"
style={{
backgroundColor: `rgb(${eventColors[event.id].join(",")})`,
}}
/>
<span className="text-sm">
<strong className="mr-1 capitalize">{event.label}</strong>
{formatUnixTimestampToDateTime(event.start_time, {
timezone: config?.ui.timezone,
})}
</span>
</div>
))}
</div>
<Pagination>
<PaginationContent className="cursor-pointer">
<PaginationItem>
<PaginationPrevious
onClick={() =>
setCurrentPage((prev) => Math.max(prev - 1, 1))
}
/>
</PaginationItem>
{[...Array(totalPages)].map((_, index) => (
<PaginationItem key={index}>
<PaginationLink
onClick={() => setCurrentPage(index + 1)}
isActive={currentPage === index + 1}
>
{index + 1}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
onClick={() =>
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</CardContent>
</Card>
);
}
function getTimeRangeInSeconds(range: string): number {
switch (range) {
case "1h":
return 60 * 60;
case "6h":
return 6 * 60 * 60;
case "12h":
return 12 * 60 * 60;
case "1d":
return 24 * 60 * 60;
default:
return 24 * 60 * 60;
}
}

View File

@ -0,0 +1,117 @@
import * as React from "react"
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
import { ButtonProps, buttonVariants } from "@/components/ui/button"
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
Pagination.displayName = "Pagination"
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
))
PaginationContent.displayName = "PaginationContent"
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
))
PaginationItem.displayName = "PaginationItem"
type PaginationLinkProps = {
isActive?: boolean
} & Pick<ButtonProps, "size"> &
React.ComponentProps<"a">
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
PaginationLink.displayName = "PaginationLink"
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
)
PaginationPrevious.displayName = "PaginationPrevious"
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
)
PaginationNext.displayName = "PaginationNext"
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
PaginationEllipsis.displayName = "PaginationEllipsis"
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}

View File

@ -30,6 +30,7 @@ import SummaryTimeline from "@/components/timeline/SummaryTimeline";
import { isMobile } from "react-device-detect";
import IconPicker, { IconElement } from "@/components/icons/IconPicker";
import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
import ObjectPathPlotter from "@/components/overlay/detail/ObjectPathPlotter";
// Color data
const colors = [
@ -224,6 +225,8 @@ function UIPlayground() {
<div className="no-scrollbar mr-5 mt-4 flex-1 content-start gap-2 overflow-y-auto">
<Heading as="h2">UI Playground</Heading>
<ObjectPathPlotter />
<IconPicker
selectedIcon={selectedIcon}
setSelectedIcon={setSelectedIcon}

View File

@ -57,6 +57,7 @@ export type SearchResult = {
description?: string;
average_estimated_speed: number;
velocity_angle: number;
path_data: [number[], number][];
};
};