mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-07-26 13:47:03 +02:00
object path plotter per camera with time selection dropdown (#16676)
This commit is contained in:
parent
7abf28bcbc
commit
2b3ab02ebf
169
web/package-lock.json
generated
169
web/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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(",")})`;
|
||||
|
||||
|
281
web/src/components/overlay/detail/ObjectPathPlotter.tsx
Normal file
281
web/src/components/overlay/detail/ObjectPathPlotter.tsx
Normal 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;
|
||||
}
|
||||
}
|
117
web/src/components/ui/pagination.tsx
Normal file
117
web/src/components/ui/pagination.tsx
Normal 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,
|
||||
}
|
@ -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}
|
||||
|
@ -57,6 +57,7 @@ export type SearchResult = {
|
||||
description?: string;
|
||||
average_estimated_speed: number;
|
||||
velocity_angle: number;
|
||||
path_data: [number[], number][];
|
||||
};
|
||||
};
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user