added sighting feed

This commit is contained in:
2025-08-20 08:27:05 +01:00
parent 9288540957
commit 44af1b21b7
17 changed files with 621 additions and 47 deletions

View File

@@ -15,7 +15,7 @@ export const SnapshotContainer = ({
return (
<div className="relative w-full aspect-video">
<NavigationArrow side={side} settingsPage={settingsPage} />
<canvas ref={canvasRef} className="w-full h-full object-contain block " />
<canvas ref={canvasRef} className="w-full h-full object-contain block" />
</div>
);
};

View File

@@ -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>
);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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" />

View 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;

View 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>
);
}

View 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;

View 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;
};

View 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 };
}

View 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]);
}

View 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,
};
}

View File

@@ -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>
);
};

View File

@@ -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
};

View File

@@ -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();
});
}