diff --git a/web/package-lock.json b/web/package-lock.json index 119fc79ea..f2b186312 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index d0bdd01d4..700fd12d7 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/components/overlay/detail/ObjectPath.tsx b/web/src/components/overlay/detail/ObjectPath.tsx index d85750ee7..80f454470 100644 --- a/web/src/components/overlay/detail/ObjectPath.tsx +++ b/web/src/components/overlay/detail/ObjectPath.tsx @@ -15,6 +15,7 @@ type ObjectPathProps = { pointRadius?: number; imgRef: React.RefObject; 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(",")})`; diff --git a/web/src/components/overlay/detail/ObjectPathPlotter.tsx b/web/src/components/overlay/detail/ObjectPathPlotter.tsx new file mode 100644 index 000000000..40cf1728e --- /dev/null +++ b/web/src/components/overlay/detail/ObjectPathPlotter.tsx @@ -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("config"); + const imgRef = useRef(null); + const timezone = useTimezone(config); + const [selectedCamera, setSelectedCamera] = useState(""); + const [selectedEvent, setSelectedEvent] = useState(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(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, + ); + }, [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 ( + + +
+

Tracked Object Paths

+
+ + +
+
+
+ {`Latest + {imgRef.current && imageLoaded && ( + + {events?.map((event) => ( + point.event.id === event.id, + )} + color={eventColors[event.id]} + width={2} + imgRef={imgRef} + visible={ + selectedEvent === null || selectedEvent.id === event.id + } + /> + ))} + + )} +
+
+
+

Legend

+ {selectedEvent && ( + + )} +
+
+ {paginatedEvents?.map((event) => ( +
handleEventClick(event)} + > +
+ + {event.label} + {formatUnixTimestampToDateTime(event.start_time, { + timezone: config?.ui.timezone, + })} + +
+ ))} +
+ + + + + setCurrentPage((prev) => Math.max(prev - 1, 1)) + } + /> + + {[...Array(totalPages)].map((_, index) => ( + + setCurrentPage(index + 1)} + isActive={currentPage === index + 1} + > + {index + 1} + + + ))} + + + setCurrentPage((prev) => Math.min(prev + 1, totalPages)) + } + /> + + + +
+ + + ); +} + +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; + } +} diff --git a/web/src/components/ui/pagination.tsx b/web/src/components/ui/pagination.tsx new file mode 100644 index 000000000..ea40d196d --- /dev/null +++ b/web/src/components/ui/pagination.tsx @@ -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">) => ( +