added sighting feed
This commit is contained in:
@@ -2,12 +2,14 @@ import clsx from "clsx";
|
||||
import Card from "../UI/Card";
|
||||
import CardHeader from "../UI/CardHeader";
|
||||
import { faCamera } from "@fortawesome/free-regular-svg-icons";
|
||||
import { SnapshotContainer } from "../CameraOverview/SnapshotContainer";
|
||||
import { useSwipeable } from "react-swipeable";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useOverviewVideo } from "../../hooks/useOverviewVideo";
|
||||
import SightingOverview from "../SightingOverview/SightingOverview";
|
||||
|
||||
const FrontCameraOverviewCard = () => {
|
||||
type CardProps = React.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
const FrontCameraOverviewCard = ({ className }: CardProps) => {
|
||||
useOverviewVideo();
|
||||
const navigate = useNavigate();
|
||||
const handlers = useSwipeable({
|
||||
@@ -16,10 +18,16 @@ const FrontCameraOverviewCard = () => {
|
||||
});
|
||||
|
||||
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}>
|
||||
<CardHeader title="Front Overiew" icon={faCamera} />
|
||||
<SnapshotContainer side="TargetDetectionFront" />
|
||||
<SightingOverview />
|
||||
{/* <SnapshotContainer side="TargetDetectionFront" /> */}
|
||||
</div>
|
||||
</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 { formatNumberPlate } from "../../utils/utils";
|
||||
import type { SightingType } from "../../types/types";
|
||||
|
||||
type NumberPlateProps = {
|
||||
sighting: SightingType;
|
||||
vrm?: string | undefined;
|
||||
motion?: boolean;
|
||||
};
|
||||
|
||||
const NumberPlate = ({ sighting }: NumberPlateProps) => {
|
||||
const NumberPlate = ({ motion, vrm }: NumberPlateProps) => {
|
||||
return (
|
||||
<div
|
||||
className={`relative w-[8rem] border-4 border-black rounded-lg text-nowrap
|
||||
text-black px-3
|
||||
${sighting?.motion !== "towards" ? "bg-yellow-400" : "bg-white"}
|
||||
${motion ? "bg-yellow-400" : "bg-white"}
|
||||
`}
|
||||
>
|
||||
<div className="">
|
||||
@@ -19,7 +19,7 @@ const NumberPlate = ({ sighting }: NumberPlateProps) => {
|
||||
<GB />
|
||||
</div>
|
||||
<p className=" pl-2 font-extrabold text-right">
|
||||
{formatNumberPlate(sighting?.vrm)}
|
||||
{vrm && formatNumberPlate(vrm)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import NumberPlate from "./NumberPlate";
|
||||
import SightingCanvas from "../SightingOverview/SightingCanvas";
|
||||
|
||||
import type { SightingType } from "../../types/types";
|
||||
|
||||
type SightingProps = {
|
||||
@@ -9,9 +9,8 @@ type SightingProps = {
|
||||
const Sighting = ({ sighting }: SightingProps) => {
|
||||
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">
|
||||
<SightingCanvas />
|
||||
<div className="flex flex-row m-1 items-center space-x-4">
|
||||
<NumberPlate sighting={sighting} />
|
||||
<NumberPlate />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,9 @@ import { useNavigate } from "react-router";
|
||||
import CardHeader from "../UI/CardHeader";
|
||||
import { faCamera } from "@fortawesome/free-regular-svg-icons";
|
||||
|
||||
const RearCameraOverviewCard = () => {
|
||||
type CardProps = React.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
const RearCameraOverviewCard = ({ className }: CardProps) => {
|
||||
const navigate = useNavigate();
|
||||
const handlers = useSwipeable({
|
||||
onSwipedLeft: () => navigate("/rear-camera-settings"),
|
||||
@@ -14,7 +16,7 @@ const RearCameraOverviewCard = () => {
|
||||
});
|
||||
|
||||
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}>
|
||||
<CardHeader title="Rear Overiew" icon={faCamera} />
|
||||
<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 Sightings from "../components/PlateStack/Sightings";
|
||||
import RearCameraOverviewCard from "../components/RearCameraOverview/RearCameraOverviewCard";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useSwipeable } from "react-swipeable";
|
||||
import SightingHistoryWidget from "../components/SightingsWidget/SightingWidget";
|
||||
import { SightingFeedProvider } from "../context/SightingFeedContext";
|
||||
|
||||
const Dashboard = () => {
|
||||
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"
|
||||
{...handlers}
|
||||
>
|
||||
<FrontCameraOverviewCard />
|
||||
<RearCameraOverviewCard />
|
||||
<Sightings title="Front Camera Sightings" />
|
||||
<Sightings title="Rear Camera Sightings" />
|
||||
<SightingFeedProvider baseUrl={"http://100.82.205.44/SightingListFront"}>
|
||||
<FrontCameraOverviewCard className="order-1" />
|
||||
<SightingHistoryWidget className="order-3" />
|
||||
</SightingFeedProvider>
|
||||
|
||||
<SightingFeedProvider baseUrl="http://100.82.205.44/SightingListRear">
|
||||
<RearCameraOverviewCard className="order-2" />
|
||||
<SightingHistoryWidget className="order-4" />
|
||||
</SightingFeedProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -67,3 +67,33 @@ export type NPEDFieldType = {
|
||||
export type HotlistUploadType = {
|
||||
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("");
|
||||
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