added sighting feed
This commit is contained in:
@@ -2,12 +2,14 @@ import clsx from "clsx";
|
|||||||
import Card from "../UI/Card";
|
import Card from "../UI/Card";
|
||||||
import CardHeader from "../UI/CardHeader";
|
import CardHeader from "../UI/CardHeader";
|
||||||
import { faCamera } from "@fortawesome/free-regular-svg-icons";
|
import { faCamera } from "@fortawesome/free-regular-svg-icons";
|
||||||
import { SnapshotContainer } from "../CameraOverview/SnapshotContainer";
|
|
||||||
import { useSwipeable } from "react-swipeable";
|
import { useSwipeable } from "react-swipeable";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { useOverviewVideo } from "../../hooks/useOverviewVideo";
|
import { useOverviewVideo } from "../../hooks/useOverviewVideo";
|
||||||
|
import SightingOverview from "../SightingOverview/SightingOverview";
|
||||||
|
|
||||||
const FrontCameraOverviewCard = () => {
|
type CardProps = React.HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
const FrontCameraOverviewCard = ({ className }: CardProps) => {
|
||||||
useOverviewVideo();
|
useOverviewVideo();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const handlers = useSwipeable({
|
const handlers = useSwipeable({
|
||||||
@@ -16,10 +18,16 @@ const FrontCameraOverviewCard = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={clsx("relative min-h-[40vh] md:min-h-[60vh] h-auto")}>
|
<Card
|
||||||
|
className={clsx(
|
||||||
|
"relative min-h-[40vh] md:min-h-[60vh] h-auto",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="flex flex-col space-y-3 h-full" {...handlers}>
|
<div className="flex flex-col space-y-3 h-full" {...handlers}>
|
||||||
<CardHeader title="Front Overiew" icon={faCamera} />
|
<CardHeader title="Front Overiew" icon={faCamera} />
|
||||||
<SnapshotContainer side="TargetDetectionFront" />
|
<SightingOverview />
|
||||||
|
{/* <SnapshotContainer side="TargetDetectionFront" /> */}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import clsx from "clsx";
|
|
||||||
import Card from "../UI/Card";
|
|
||||||
import { faSliders } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import CardHeader from "../UI/CardHeader";
|
|
||||||
|
|
||||||
const Output = () => {
|
|
||||||
return (
|
|
||||||
<Card className={clsx("min-h-[40vh] md:min-h-[60vh] h-auto")}>
|
|
||||||
<CardHeader title="Output" icon={faSliders} />
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Output;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { Formik, Form, Field } from "formik";
|
|
||||||
|
|
||||||
const OutputForm = () => {
|
|
||||||
const initialValues = {
|
|
||||||
includeVRM: false,
|
|
||||||
includeMotion: false,
|
|
||||||
includeTimestamp: false,
|
|
||||||
timeStampFormat: "utc",
|
|
||||||
};
|
|
||||||
return <div>OutputForm</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default OutputForm;
|
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
import { GB } from "country-flag-icons/react/3x2";
|
import { GB } from "country-flag-icons/react/3x2";
|
||||||
import { formatNumberPlate } from "../../utils/utils";
|
import { formatNumberPlate } from "../../utils/utils";
|
||||||
import type { SightingType } from "../../types/types";
|
|
||||||
|
|
||||||
type NumberPlateProps = {
|
type NumberPlateProps = {
|
||||||
sighting: SightingType;
|
vrm?: string | undefined;
|
||||||
|
motion?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const NumberPlate = ({ sighting }: NumberPlateProps) => {
|
const NumberPlate = ({ motion, vrm }: NumberPlateProps) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`relative w-[8rem] border-4 border-black rounded-lg text-nowrap
|
className={`relative w-[8rem] border-4 border-black rounded-lg text-nowrap
|
||||||
text-black px-3
|
text-black px-3
|
||||||
${sighting?.motion !== "towards" ? "bg-yellow-400" : "bg-white"}
|
${motion ? "bg-yellow-400" : "bg-white"}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<div className="">
|
<div className="">
|
||||||
@@ -19,7 +19,7 @@ const NumberPlate = ({ sighting }: NumberPlateProps) => {
|
|||||||
<GB />
|
<GB />
|
||||||
</div>
|
</div>
|
||||||
<p className=" pl-2 font-extrabold text-right">
|
<p className=" pl-2 font-extrabold text-right">
|
||||||
{formatNumberPlate(sighting?.vrm)}
|
{vrm && formatNumberPlate(vrm)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import NumberPlate from "./NumberPlate";
|
import NumberPlate from "./NumberPlate";
|
||||||
import SightingCanvas from "../SightingOverview/SightingCanvas";
|
|
||||||
import type { SightingType } from "../../types/types";
|
import type { SightingType } from "../../types/types";
|
||||||
|
|
||||||
type SightingProps = {
|
type SightingProps = {
|
||||||
@@ -9,9 +9,8 @@ type SightingProps = {
|
|||||||
const Sighting = ({ sighting }: SightingProps) => {
|
const Sighting = ({ sighting }: SightingProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-700 flex flex-col md:flex-row m-1 items-center justify-between w-full rounded-md p-4 space-y-4">
|
<div className="bg-gray-700 flex flex-col md:flex-row m-1 items-center justify-between w-full rounded-md p-4 space-y-4">
|
||||||
<SightingCanvas />
|
|
||||||
<div className="flex flex-row m-1 items-center space-x-4">
|
<div className="flex flex-row m-1 items-center space-x-4">
|
||||||
<NumberPlate sighting={sighting} />
|
<NumberPlate />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import { useNavigate } from "react-router";
|
|||||||
import CardHeader from "../UI/CardHeader";
|
import CardHeader from "../UI/CardHeader";
|
||||||
import { faCamera } from "@fortawesome/free-regular-svg-icons";
|
import { faCamera } from "@fortawesome/free-regular-svg-icons";
|
||||||
|
|
||||||
const RearCameraOverviewCard = () => {
|
type CardProps = React.HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
const RearCameraOverviewCard = ({ className }: CardProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const handlers = useSwipeable({
|
const handlers = useSwipeable({
|
||||||
onSwipedLeft: () => navigate("/rear-camera-settings"),
|
onSwipedLeft: () => navigate("/rear-camera-settings"),
|
||||||
@@ -14,7 +16,7 @@ const RearCameraOverviewCard = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={clsx("min-h-[40vh] md:min-h-[60vh] h-auto")}>
|
<Card className={clsx("min-h-[40vh] md:min-h-[60vh] h-auto", className)}>
|
||||||
<div className="flex flex-col space-y-3 h-full" {...handlers}>
|
<div className="flex flex-col space-y-3 h-full" {...handlers}>
|
||||||
<CardHeader title="Rear Overiew" icon={faCamera} />
|
<CardHeader title="Rear Overiew" icon={faCamera} />
|
||||||
<SnapshotContainer side="TargetDetectionRear" />
|
<SnapshotContainer side="TargetDetectionRear" />
|
||||||
|
|||||||
70
src/components/SightingOverview/SightingOverview.tsx
Normal file
70
src/components/SightingOverview/SightingOverview.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { BLANK_IMG } from "../../utils/utils";
|
||||||
|
import SightingWidgetDetails from "../SightingsWidget/SightingWidgetDetails";
|
||||||
|
import { useOverviewOverlay } from "../../hooks/useOverviewOverlay";
|
||||||
|
import { useSightingFeedContext } from "../../context/SightingFeedContext";
|
||||||
|
import { useHiDPICanvas } from "../../hooks/useHiDPICanvas";
|
||||||
|
import NavigationArrow from "../UI/NavigationArrow";
|
||||||
|
|
||||||
|
const SightingOverview = () => {
|
||||||
|
const [overlayMode, setOverlayMode] = useState<0 | 1 | 2>(0);
|
||||||
|
|
||||||
|
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
|
const onOverviewClick = useCallback(() => {
|
||||||
|
setOverlayMode((m) => ((m + 1) % 3) as 0 | 1 | 2);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { effectiveSelected } = useSightingFeedContext();
|
||||||
|
|
||||||
|
useOverviewOverlay(effectiveSelected, overlayMode, imgRef, canvasRef);
|
||||||
|
|
||||||
|
const { sync } = useHiDPICanvas(imgRef, canvasRef);
|
||||||
|
return (
|
||||||
|
<div className="mt-2 grid gap-3">
|
||||||
|
{/* <div className="flex items-center gap-3 text-sm">
|
||||||
|
<div className="font-semibold">{effectiveSelected?.vrm ?? "—"}</div>
|
||||||
|
<div>{effectiveSelected?.countryCode ?? "—"}</div>
|
||||||
|
<div className="opacity-80">{effectiveSelected?.timeStamp ?? "—"}</div>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
<div className="inline-block">
|
||||||
|
<div className="relative aspect-[5/4]">
|
||||||
|
<img
|
||||||
|
ref={imgRef}
|
||||||
|
onLoad={() => {
|
||||||
|
sync();
|
||||||
|
setOverlayMode((m) => m);
|
||||||
|
}}
|
||||||
|
src={effectiveSelected?.overviewUrl || BLANK_IMG}
|
||||||
|
alt="overview"
|
||||||
|
className="absolute inset-0 w-full h-full object-contain cursor-pointer z-10"
|
||||||
|
onClick={onOverviewClick}
|
||||||
|
style={{
|
||||||
|
display: effectiveSelected?.overviewUrl ? "block" : "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="absolute inset-0 w-full h-full object-contain z-20 pointer-events-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SightingWidgetDetails effectiveSelected={effectiveSelected} />
|
||||||
|
|
||||||
|
<div className="text-xs opacity-80">
|
||||||
|
Overlay:{" "}
|
||||||
|
{overlayMode === 0
|
||||||
|
? "Off"
|
||||||
|
: overlayMode === 1
|
||||||
|
? "Plate box"
|
||||||
|
: "Track + box"}{" "}
|
||||||
|
(click image to toggle)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SightingOverview;
|
||||||
121
src/components/SightingsWidget/SightingWidget.tsx
Normal file
121
src/components/SightingsWidget/SightingWidget.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import type { SightingWidgetType } from "../../types/types";
|
||||||
|
import { BLANK_IMG, capitalize, formatAge } from "../../utils/utils";
|
||||||
|
import NumberPlate from "../PlateStack/NumberPlate";
|
||||||
|
import Card from "../UI/Card";
|
||||||
|
import CardHeader from "../UI/CardHeader";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { useSightingFeedContext } from "../../context/SightingFeedContext";
|
||||||
|
|
||||||
|
function useNow(tickMs = 1000) {
|
||||||
|
const [, setNow] = useState(() => Date.now());
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setNow(Date.now()), tickMs);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [tickMs]);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SightingHistoryProps = {
|
||||||
|
baseUrl: string;
|
||||||
|
entries?: number; // number of rows to show
|
||||||
|
pollMs?: number; // poll frequency
|
||||||
|
autoSelectLatest?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SightingHistoryWidgetProps = React.HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
export default function SightingHistoryWidget({
|
||||||
|
className,
|
||||||
|
}: SightingHistoryWidgetProps) {
|
||||||
|
useNow(1000);
|
||||||
|
const { items, selectedRef, setSelectedRef } = useSightingFeedContext();
|
||||||
|
|
||||||
|
const onRowClick = useCallback(
|
||||||
|
(ref: number) => {
|
||||||
|
setSelectedRef(ref);
|
||||||
|
},
|
||||||
|
[setSelectedRef]
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = useMemo(
|
||||||
|
() => items.filter(Boolean) as SightingWidgetType[],
|
||||||
|
[items]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={clsx("overflow-y-auto h-100", className)}>
|
||||||
|
<CardHeader title="Front Camera Sightings" />
|
||||||
|
<div className="flex flex-col gap-3 ">
|
||||||
|
{/* Rows */}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{rows.map((obj, idx) => {
|
||||||
|
const isSelected = obj?.ref === selectedRef;
|
||||||
|
const motionAway = (obj?.motion ?? "").toUpperCase() === "AWAY";
|
||||||
|
const primaryIsColour = obj?.srcCam === 1;
|
||||||
|
const secondaryMissing = (obj?.vrmSecondary ?? "") === "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`border border-neutral-700 rounded-md mb-2 p-2 cursor-pointer ${
|
||||||
|
isSelected ? "ring-2 ring-blue-400" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => onRowClick(obj.ref)}
|
||||||
|
>
|
||||||
|
{/* Info bar */}
|
||||||
|
<div className="flex items-center gap-3 text-xs bg-neutral-900 px-2 py-1 rounded">
|
||||||
|
<div className="min-w-14">
|
||||||
|
CH: {obj ? obj.charHeight : "—"}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-14">
|
||||||
|
Seen: {obj ? obj.seenCount : "—"}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-20">
|
||||||
|
{obj ? capitalize(obj.motion) : "—"}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-14 opacity-80">
|
||||||
|
{obj ? formatAge(obj.timeStampMillis) : "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Patch row */}
|
||||||
|
<div className="flex items-center gap-3 mt-2">
|
||||||
|
<div
|
||||||
|
className={`border p-1 ${
|
||||||
|
primaryIsColour ? "" : "ring-2 ring-lime-400"
|
||||||
|
} ${!obj ? "opacity-30" : ""}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={obj?.plateUrlInfrared || BLANK_IMG}
|
||||||
|
height={48}
|
||||||
|
alt="infrared patch"
|
||||||
|
className={!primaryIsColour ? "" : "opacity-60"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`border p-1 ${
|
||||||
|
primaryIsColour ? "ring-2 ring-lime-400" : ""
|
||||||
|
} ${
|
||||||
|
secondaryMissing && primaryIsColour
|
||||||
|
? "opacity-30 grayscale"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={obj?.plateUrlColour || BLANK_IMG}
|
||||||
|
height={48}
|
||||||
|
alt="colour patch"
|
||||||
|
className={primaryIsColour ? "" : "opacity-60"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<NumberPlate motion={motionAway} vrm={obj?.vrm} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
src/components/SightingsWidget/SightingWidgetDetails.tsx
Normal file
93
src/components/SightingsWidget/SightingWidgetDetails.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import type { SightingWidgetType } from "../../types/types";
|
||||||
|
|
||||||
|
type SightingWidgetDetailsProps = {
|
||||||
|
effectiveSelected: SightingWidgetType | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SightingWidgetDetails = ({
|
||||||
|
effectiveSelected,
|
||||||
|
}: SightingWidgetDetailsProps) => {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm">
|
||||||
|
<div>
|
||||||
|
Make:{" "}
|
||||||
|
<span className="opacity-90">{effectiveSelected?.make ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Model:{" "}
|
||||||
|
<span className="opacity-90">{effectiveSelected?.model ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Colour:{" "}
|
||||||
|
<span className="opacity-90">{effectiveSelected?.color ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Category:{" "}
|
||||||
|
<span className="opacity-90">{effectiveSelected?.category ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Char Ht:{" "}
|
||||||
|
<span className="opacity-90">
|
||||||
|
{effectiveSelected?.charHeight ?? "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Plate Size:{" "}
|
||||||
|
<span className="opacity-90">
|
||||||
|
{effectiveSelected?.plateSize ?? "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Overview Size:{" "}
|
||||||
|
<span className="opacity-90">
|
||||||
|
{effectiveSelected?.overviewSize ?? "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Motion:{" "}
|
||||||
|
<span className="opacity-90">{effectiveSelected?.motion ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Seen:{" "}
|
||||||
|
<span className="opacity-90">
|
||||||
|
{effectiveSelected?.seenCount ?? "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Location:{" "}
|
||||||
|
<span className="opacity-90">
|
||||||
|
{effectiveSelected?.locationName ?? "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Lane:{" "}
|
||||||
|
<span className="opacity-90">{effectiveSelected?.laneID ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Radar:{" "}
|
||||||
|
<span className="opacity-90">
|
||||||
|
{effectiveSelected?.radarSpeed ?? "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Track:{" "}
|
||||||
|
<span className="opacity-90">
|
||||||
|
{effectiveSelected?.trackSpeed ?? "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{effectiveSelected?.detailsUrl ? (
|
||||||
|
<div className="col-span-full">
|
||||||
|
<a
|
||||||
|
href={effectiveSelected.detailsUrl}
|
||||||
|
target="_blank"
|
||||||
|
className="underline text-blue-300"
|
||||||
|
>
|
||||||
|
Sighting Details
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SightingWidgetDetails;
|
||||||
50
src/context/SightingFeedContext.tsx
Normal file
50
src/context/SightingFeedContext.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { createContext, useContext, type ReactNode } from "react";
|
||||||
|
import type { SightingWidgetType } from "../types/types";
|
||||||
|
import { useSightingFeed } from "../hooks/useSightingFeed";
|
||||||
|
|
||||||
|
type SightingFeedContextType = {
|
||||||
|
items: (SightingWidgetType | null | undefined)[];
|
||||||
|
selectedRef: number | null;
|
||||||
|
setSelectedRef: (ref: number | null) => void;
|
||||||
|
effectiveSelected: SightingWidgetType | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SightingFeedProviderProps = {
|
||||||
|
baseUrl: string;
|
||||||
|
entries?: number;
|
||||||
|
pollMs?: number;
|
||||||
|
autoSelectLatest?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SightingFeedContext = createContext<SightingFeedContextType | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SightingFeedProvider = ({
|
||||||
|
baseUrl,
|
||||||
|
entries = 7,
|
||||||
|
pollMs = 500,
|
||||||
|
autoSelectLatest = true,
|
||||||
|
children,
|
||||||
|
}: SightingFeedProviderProps) => {
|
||||||
|
const { items, selectedRef, setSelectedRef, effectiveSelected } =
|
||||||
|
useSightingFeed(baseUrl, { limit: entries, pollMs, autoSelectLatest });
|
||||||
|
return (
|
||||||
|
<SightingFeedContext.Provider
|
||||||
|
value={{ items, selectedRef, setSelectedRef, effectiveSelected }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SightingFeedContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
|
export const useSightingFeedContext = () => {
|
||||||
|
const ctx = useContext(SightingFeedContext);
|
||||||
|
if (!ctx)
|
||||||
|
throw new Error(
|
||||||
|
"useSightingFeedContext must be used within SightingFeedProvider"
|
||||||
|
);
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
43
src/hooks/useHiDPICanvas.ts
Normal file
43
src/hooks/useHiDPICanvas.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export function useHiDPICanvas(
|
||||||
|
imgRef: React.RefObject<HTMLImageElement | null>,
|
||||||
|
canvasRef: React.RefObject<HTMLCanvasElement | null>
|
||||||
|
) {
|
||||||
|
const sync = () => {
|
||||||
|
const img = imgRef.current,
|
||||||
|
cvs = canvasRef.current;
|
||||||
|
if (!img || !cvs) return;
|
||||||
|
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const w = img.clientWidth || img.naturalWidth || 0;
|
||||||
|
const h = img.clientHeight || img.naturalHeight || 0;
|
||||||
|
|
||||||
|
// CSS size
|
||||||
|
cvs.style.width = `${w}px`;
|
||||||
|
cvs.style.height = `${h}px`;
|
||||||
|
|
||||||
|
// backing store size (scaled for HiDPI)
|
||||||
|
const W = Math.max(1, Math.round(w * dpr));
|
||||||
|
const H = Math.max(1, Math.round(h * dpr));
|
||||||
|
if (cvs.width !== W || cvs.height !== H) {
|
||||||
|
cvs.width = W;
|
||||||
|
cvs.height = H;
|
||||||
|
const ctx = cvs.getContext("2d");
|
||||||
|
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // draw in CSS px
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ro = new ResizeObserver(sync); // reacts to image size changes
|
||||||
|
if (imgRef.current) ro.observe(imgRef.current);
|
||||||
|
const onResize = () => sync();
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
return () => {
|
||||||
|
ro.disconnect();
|
||||||
|
window.removeEventListener("resize", onResize);
|
||||||
|
};
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
return { sync };
|
||||||
|
}
|
||||||
37
src/hooks/useOverviewOverlay.ts
Normal file
37
src/hooks/useOverviewOverlay.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import type { SightingWidgetType } from "../types/types";
|
||||||
|
import { drawRects } from "../utils/utils";
|
||||||
|
|
||||||
|
type Mode = 0 | 1 | 2;
|
||||||
|
|
||||||
|
export function useOverviewOverlay(
|
||||||
|
selected: SightingWidgetType | null | undefined,
|
||||||
|
overlayMode: Mode,
|
||||||
|
imgRef: React.RefObject<HTMLImageElement | null>,
|
||||||
|
canvasRef: React.RefObject<HTMLCanvasElement | null>
|
||||||
|
) {
|
||||||
|
useEffect(() => {
|
||||||
|
const img = imgRef?.current;
|
||||||
|
const cvs = canvasRef?.current;
|
||||||
|
if (!img || !cvs) return;
|
||||||
|
|
||||||
|
const ctx = cvs.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
// clear
|
||||||
|
ctx.clearRect(0, 0, cvs.width, cvs.height);
|
||||||
|
|
||||||
|
if (!selected || overlayMode === 0) return;
|
||||||
|
|
||||||
|
if (overlayMode === 1 && selected.overviewPlateRect) {
|
||||||
|
drawRects(cvs, img, [selected.overviewPlateRect], "chartreuse");
|
||||||
|
} else if (overlayMode === 2) {
|
||||||
|
const rects = selected.plateTrack ?? [];
|
||||||
|
if (rects.length) drawRects(cvs, img, rects, "yellow");
|
||||||
|
if (selected.overviewPlateRect) {
|
||||||
|
drawRects(cvs, img, [selected.overviewPlateRect], "chartreuse");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selected?.ref, overlayMode, imgRef?.current?.src]);
|
||||||
|
}
|
||||||
91
src/hooks/useSightingFeed.ts
Normal file
91
src/hooks/useSightingFeed.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import type { SightingWidgetType } from "../types/types";
|
||||||
|
|
||||||
|
export function useSightingFeed(
|
||||||
|
baseUrl: string,
|
||||||
|
{
|
||||||
|
limit = 7,
|
||||||
|
pollMs = 800,
|
||||||
|
autoSelectLatest = true,
|
||||||
|
}: {
|
||||||
|
limit?: number;
|
||||||
|
pollMs?: number;
|
||||||
|
autoSelectLatest?: boolean;
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const [items, setItems] = useState<SightingWidgetType[]>(
|
||||||
|
() => Array(limit).fill(null) as unknown as SightingWidgetType[]
|
||||||
|
);
|
||||||
|
const [selectedRef, setSelectedRef] = useState<number | null>(null);
|
||||||
|
const [mostRecent, setMostRecent] = useState<SightingWidgetType | null>(null);
|
||||||
|
|
||||||
|
const mostRecentRef = useRef<number>(-1);
|
||||||
|
|
||||||
|
// effective selected (fallback to most recent)
|
||||||
|
const selected = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedRef == null
|
||||||
|
? null
|
||||||
|
: items.find((x) => x?.ref === selectedRef) ?? null,
|
||||||
|
[items, selectedRef]
|
||||||
|
);
|
||||||
|
const effectiveSelected = selected ?? mostRecent ?? null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let delay = pollMs;
|
||||||
|
let dead = false;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
try {
|
||||||
|
// Pause when tab hidden to save CPU/network
|
||||||
|
if (document.hidden) {
|
||||||
|
setTimeout(tick, Math.max(delay, 2000));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `http://100.116.253.81/mergedHistory/sightingSummary?mostRecentRef=${mostRecentRef.current}`;
|
||||||
|
const res = await fetch(url, { signal: controller.signal });
|
||||||
|
if (!res.ok) throw new Error(String(res.status));
|
||||||
|
|
||||||
|
const obj: SightingWidgetType = await res.json();
|
||||||
|
if (obj && typeof obj.ref === "number" && obj.ref > -1) {
|
||||||
|
setItems((prev) => {
|
||||||
|
const next = [obj, ...prev].slice(0, limit);
|
||||||
|
// maintain selection if still present; otherwise select newest if allowed
|
||||||
|
const stillExists =
|
||||||
|
selectedRef != null && next.some((x) => x?.ref === selectedRef);
|
||||||
|
if (autoSelectLatest && !stillExists) {
|
||||||
|
setSelectedRef(obj.ref);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setMostRecent(obj);
|
||||||
|
mostRecentRef.current = obj.ref;
|
||||||
|
delay = pollMs; // reset backoff on success
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// exponential backoff (max 10s)
|
||||||
|
delay = Math.min(delay * 2, 10000);
|
||||||
|
} finally {
|
||||||
|
if (!dead) setTimeout(tick, delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = setTimeout(tick, pollMs);
|
||||||
|
return () => {
|
||||||
|
dead = true;
|
||||||
|
controller.abort();
|
||||||
|
clearTimeout(t);
|
||||||
|
};
|
||||||
|
}, [baseUrl, limit, pollMs, autoSelectLatest, selectedRef]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
selectedRef,
|
||||||
|
setSelectedRef,
|
||||||
|
mostRecent,
|
||||||
|
effectiveSelected,
|
||||||
|
mostRecentRef,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import FrontCameraOverviewCard from "../components/FrontCameraOverview/FrontCameraOverviewCard";
|
import FrontCameraOverviewCard from "../components/FrontCameraOverview/FrontCameraOverviewCard";
|
||||||
import Sightings from "../components/PlateStack/Sightings";
|
|
||||||
import RearCameraOverviewCard from "../components/RearCameraOverview/RearCameraOverviewCard";
|
import RearCameraOverviewCard from "../components/RearCameraOverview/RearCameraOverviewCard";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { useSwipeable } from "react-swipeable";
|
import { useSwipeable } from "react-swipeable";
|
||||||
|
import SightingHistoryWidget from "../components/SightingsWidget/SightingWidget";
|
||||||
|
import { SightingFeedProvider } from "../context/SightingFeedContext";
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -17,10 +18,15 @@ const Dashboard = () => {
|
|||||||
className="mx-auto grid grid-cols-1 sm:grid-cols-1 lg:grid-cols-2 gap-2 px-2 sm:px-4 lg:px-0 w-full"
|
className="mx-auto grid grid-cols-1 sm:grid-cols-1 lg:grid-cols-2 gap-2 px-2 sm:px-4 lg:px-0 w-full"
|
||||||
{...handlers}
|
{...handlers}
|
||||||
>
|
>
|
||||||
<FrontCameraOverviewCard />
|
<SightingFeedProvider baseUrl={"http://100.82.205.44/SightingListFront"}>
|
||||||
<RearCameraOverviewCard />
|
<FrontCameraOverviewCard className="order-1" />
|
||||||
<Sightings title="Front Camera Sightings" />
|
<SightingHistoryWidget className="order-3" />
|
||||||
<Sightings title="Rear Camera Sightings" />
|
</SightingFeedProvider>
|
||||||
|
|
||||||
|
<SightingFeedProvider baseUrl="http://100.82.205.44/SightingListRear">
|
||||||
|
<RearCameraOverviewCard className="order-2" />
|
||||||
|
<SightingHistoryWidget className="order-4" />
|
||||||
|
</SightingFeedProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -67,3 +67,33 @@ export type NPEDFieldType = {
|
|||||||
export type HotlistUploadType = {
|
export type HotlistUploadType = {
|
||||||
file: string | null;
|
file: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SightingWidgetType = {
|
||||||
|
ref: number; // unique, increasing
|
||||||
|
idx?: number; // client-side slot index
|
||||||
|
vrm: string;
|
||||||
|
vrmSecondary?: string; // empty string means missing
|
||||||
|
countryCode?: string;
|
||||||
|
timeStamp?: string; // formatted string
|
||||||
|
timeStampMillis: number; // epoch millis
|
||||||
|
motion: string; // e.g., "AWAY" or "TOWARDS"
|
||||||
|
seenCount: number;
|
||||||
|
charHeight: string | number;
|
||||||
|
overviewUrl: string;
|
||||||
|
detailsUrl?: string;
|
||||||
|
make?: string;
|
||||||
|
model?: string;
|
||||||
|
color?: string;
|
||||||
|
category?: string;
|
||||||
|
plateSize?: string | number;
|
||||||
|
overviewSize?: string | number;
|
||||||
|
locationName?: string;
|
||||||
|
laneID?: string | number;
|
||||||
|
radarSpeed?: string | number;
|
||||||
|
trackSpeed?: string | number;
|
||||||
|
srcCam?: 0 | 1;
|
||||||
|
plateUrlInfrared?: string;
|
||||||
|
plateUrlColour?: string;
|
||||||
|
overviewPlateRect?: [number, number, number, number]; // [x,y,w,h] normalized 0..1
|
||||||
|
plateTrack?: [number, number, number, number][]; // list of rects normalized 0..1
|
||||||
|
};
|
||||||
|
|||||||
@@ -36,3 +36,54 @@ export const formatNumberPlate = (plate: string) => {
|
|||||||
const formattedPlate = splittedPlate?.join("");
|
const formattedPlate = splittedPlate?.join("");
|
||||||
return formattedPlate;
|
return formattedPlate;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const BLANK_IMG =
|
||||||
|
"data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==";
|
||||||
|
|
||||||
|
export function capitalize(s?: string) {
|
||||||
|
return s ? s.charAt(0).toUpperCase() + s.slice(1) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAge(tsMillis: number) {
|
||||||
|
const ms = Date.now() - tsMillis;
|
||||||
|
const seconds = 5 * Math.floor(ms / 5000); // quantize to 5s like the original
|
||||||
|
const d = Math.floor(seconds / (3600 * 24));
|
||||||
|
const h = Math.floor((seconds % (3600 * 24)) / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
const s = Math.floor(seconds % 60);
|
||||||
|
if (d > 0) return `${d}d ago`;
|
||||||
|
if (h > 0) return `${h}h ago`;
|
||||||
|
if (m > 0) return `${m}m ago`;
|
||||||
|
return `${s}s ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawRects(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
imageEl: HTMLImageElement,
|
||||||
|
rects: [number, number, number, number][],
|
||||||
|
color: string
|
||||||
|
) {
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
// Ensure canvas size matches displayed image size
|
||||||
|
const w = imageEl.clientWidth || imageEl.naturalWidth;
|
||||||
|
const h = imageEl.clientHeight || imageEl.naturalHeight;
|
||||||
|
if (canvas.width !== w) canvas.width = w;
|
||||||
|
if (canvas.height !== h) canvas.height = h;
|
||||||
|
|
||||||
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
|
||||||
|
rects.forEach((r) => {
|
||||||
|
const [x, y, rw, rh] = r;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.rect(
|
||||||
|
Math.round(x * w),
|
||||||
|
Math.round(y * h),
|
||||||
|
Math.round(rw * w),
|
||||||
|
Math.round(rh * h)
|
||||||
|
);
|
||||||
|
ctx.stroke();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user