From 44af1b21b706dad9d6f43ab668391e0025c77194 Mon Sep 17 00:00:00 2001 From: Toba Ojo Date: Wed, 20 Aug 2025 08:27:05 +0100 Subject: [PATCH] added sighting feed --- .../CameraOverview/SnapshotContainer.tsx | 2 +- .../FrontCameraOverviewCard.tsx | 16 ++- src/components/Output/Output.tsx | 14 -- src/components/Output/OutputForm.tsx | 13 -- src/components/PlateStack/NumberPlate.tsx | 10 +- src/components/PlateStack/Sighting.tsx | 5 +- .../RearCameraOverviewCard.tsx | 6 +- .../SightingOverview/SightingOverview.tsx | 70 ++++++++++ .../SightingsWidget/SightingWidget.tsx | 121 ++++++++++++++++++ .../SightingsWidget/SightingWidgetDetails.tsx | 93 ++++++++++++++ src/context/SightingFeedContext.tsx | 50 ++++++++ src/hooks/useHiDPICanvas.ts | 43 +++++++ src/hooks/useOverviewOverlay.ts | 37 ++++++ src/hooks/useSightingFeed.ts | 91 +++++++++++++ src/pages/Dashboard.tsx | 16 ++- src/types/types.ts | 30 +++++ src/utils/utils.ts | 51 ++++++++ 17 files changed, 621 insertions(+), 47 deletions(-) delete mode 100644 src/components/Output/Output.tsx delete mode 100644 src/components/Output/OutputForm.tsx create mode 100644 src/components/SightingOverview/SightingOverview.tsx create mode 100644 src/components/SightingsWidget/SightingWidget.tsx create mode 100644 src/components/SightingsWidget/SightingWidgetDetails.tsx create mode 100644 src/context/SightingFeedContext.tsx create mode 100644 src/hooks/useHiDPICanvas.ts create mode 100644 src/hooks/useOverviewOverlay.ts create mode 100644 src/hooks/useSightingFeed.ts diff --git a/src/components/CameraOverview/SnapshotContainer.tsx b/src/components/CameraOverview/SnapshotContainer.tsx index 4f9810c..f9605ae 100644 --- a/src/components/CameraOverview/SnapshotContainer.tsx +++ b/src/components/CameraOverview/SnapshotContainer.tsx @@ -15,7 +15,7 @@ export const SnapshotContainer = ({ return (
- +
); }; diff --git a/src/components/FrontCameraOverview/FrontCameraOverviewCard.tsx b/src/components/FrontCameraOverview/FrontCameraOverviewCard.tsx index a4ae37c..274303b 100644 --- a/src/components/FrontCameraOverview/FrontCameraOverviewCard.tsx +++ b/src/components/FrontCameraOverview/FrontCameraOverviewCard.tsx @@ -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; + +const FrontCameraOverviewCard = ({ className }: CardProps) => { useOverviewVideo(); const navigate = useNavigate(); const handlers = useSwipeable({ @@ -16,10 +18,16 @@ const FrontCameraOverviewCard = () => { }); return ( - +
- + + {/* */}
); diff --git a/src/components/Output/Output.tsx b/src/components/Output/Output.tsx deleted file mode 100644 index c1d4531..0000000 --- a/src/components/Output/Output.tsx +++ /dev/null @@ -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 ( - - - - ); -}; - -export default Output; diff --git a/src/components/Output/OutputForm.tsx b/src/components/Output/OutputForm.tsx deleted file mode 100644 index fe18f83..0000000 --- a/src/components/Output/OutputForm.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Formik, Form, Field } from "formik"; - -const OutputForm = () => { - const initialValues = { - includeVRM: false, - includeMotion: false, - includeTimestamp: false, - timeStampFormat: "utc", - }; - return
OutputForm
; -}; - -export default OutputForm; diff --git a/src/components/PlateStack/NumberPlate.tsx b/src/components/PlateStack/NumberPlate.tsx index 6011261..51ea6ca 100644 --- a/src/components/PlateStack/NumberPlate.tsx +++ b/src/components/PlateStack/NumberPlate.tsx @@ -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 (
@@ -19,7 +19,7 @@ const NumberPlate = ({ sighting }: NumberPlateProps) => {

- {formatNumberPlate(sighting?.vrm)} + {vrm && formatNumberPlate(vrm)}

diff --git a/src/components/PlateStack/Sighting.tsx b/src/components/PlateStack/Sighting.tsx index 18efe15..6b235b2 100644 --- a/src/components/PlateStack/Sighting.tsx +++ b/src/components/PlateStack/Sighting.tsx @@ -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 (
-
- +
); diff --git a/src/components/RearCameraOverview/RearCameraOverviewCard.tsx b/src/components/RearCameraOverview/RearCameraOverviewCard.tsx index afc9248..bbb8f34 100644 --- a/src/components/RearCameraOverview/RearCameraOverviewCard.tsx +++ b/src/components/RearCameraOverview/RearCameraOverviewCard.tsx @@ -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; + +const RearCameraOverviewCard = ({ className }: CardProps) => { const navigate = useNavigate(); const handlers = useSwipeable({ onSwipedLeft: () => navigate("/rear-camera-settings"), @@ -14,7 +16,7 @@ const RearCameraOverviewCard = () => { }); return ( - +
diff --git a/src/components/SightingOverview/SightingOverview.tsx b/src/components/SightingOverview/SightingOverview.tsx new file mode 100644 index 0000000..aea617c --- /dev/null +++ b/src/components/SightingOverview/SightingOverview.tsx @@ -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(null); + const canvasRef = useRef(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 ( +
+ {/*
+
{effectiveSelected?.vrm ?? "—"}
+
{effectiveSelected?.countryCode ?? "—"}
+
{effectiveSelected?.timeStamp ?? "—"}
+
*/} + +
+
+ { + 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", + }} + /> + +
+
+ + + +
+ Overlay:{" "} + {overlayMode === 0 + ? "Off" + : overlayMode === 1 + ? "Plate box" + : "Track + box"}{" "} + (click image to toggle) +
+
+ ); +}; + +export default SightingOverview; diff --git a/src/components/SightingsWidget/SightingWidget.tsx b/src/components/SightingsWidget/SightingWidget.tsx new file mode 100644 index 0000000..f4650bd --- /dev/null +++ b/src/components/SightingsWidget/SightingWidget.tsx @@ -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; + +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 ( + + +
+ {/* Rows */} +
+ {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 ( +
onRowClick(obj.ref)} + > + {/* Info bar */} +
+
+ CH: {obj ? obj.charHeight : "—"} +
+
+ Seen: {obj ? obj.seenCount : "—"} +
+
+ {obj ? capitalize(obj.motion) : "—"} +
+
+ {obj ? formatAge(obj.timeStampMillis) : "—"} +
+
+ + {/* Patch row */} +
+
+ infrared patch +
+
+ colour patch +
+ +
+
+ ); + })} +
+
+
+ ); +} diff --git a/src/components/SightingsWidget/SightingWidgetDetails.tsx b/src/components/SightingsWidget/SightingWidgetDetails.tsx new file mode 100644 index 0000000..b91385a --- /dev/null +++ b/src/components/SightingsWidget/SightingWidgetDetails.tsx @@ -0,0 +1,93 @@ +import type { SightingWidgetType } from "../../types/types"; + +type SightingWidgetDetailsProps = { + effectiveSelected: SightingWidgetType | null; +}; + +const SightingWidgetDetails = ({ + effectiveSelected, +}: SightingWidgetDetailsProps) => { + return ( +
+
+ Make:{" "} + {effectiveSelected?.make ?? "—"} +
+
+ Model:{" "} + {effectiveSelected?.model ?? "—"} +
+
+ Colour:{" "} + {effectiveSelected?.color ?? "—"} +
+
+ Category:{" "} + {effectiveSelected?.category ?? "—"} +
+
+ Char Ht:{" "} + + {effectiveSelected?.charHeight ?? "—"} + +
+
+ Plate Size:{" "} + + {effectiveSelected?.plateSize ?? "—"} + +
+
+ Overview Size:{" "} + + {effectiveSelected?.overviewSize ?? "—"} + +
+
+ Motion:{" "} + {effectiveSelected?.motion ?? "—"} +
+
+ Seen:{" "} + + {effectiveSelected?.seenCount ?? "—"} + +
+
+ Location:{" "} + + {effectiveSelected?.locationName ?? "—"} + +
+
+ Lane:{" "} + {effectiveSelected?.laneID ?? "—"} +
+
+ Radar:{" "} + + {effectiveSelected?.radarSpeed ?? "—"} + +
+
+ Track:{" "} + + {effectiveSelected?.trackSpeed ?? "—"} + +
+ {effectiveSelected?.detailsUrl ? ( + + ) : null} +
+ ); +}; + +export default SightingWidgetDetails; diff --git a/src/context/SightingFeedContext.tsx b/src/context/SightingFeedContext.tsx new file mode 100644 index 0000000..0b2edde --- /dev/null +++ b/src/context/SightingFeedContext.tsx @@ -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( + 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 ( + + {children} + + ); +}; + +// 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; +}; diff --git a/src/hooks/useHiDPICanvas.ts b/src/hooks/useHiDPICanvas.ts new file mode 100644 index 0000000..d5fdcdc --- /dev/null +++ b/src/hooks/useHiDPICanvas.ts @@ -0,0 +1,43 @@ +import { useEffect } from "react"; + +export function useHiDPICanvas( + imgRef: React.RefObject, + canvasRef: React.RefObject +) { + 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 }; +} diff --git a/src/hooks/useOverviewOverlay.ts b/src/hooks/useOverviewOverlay.ts new file mode 100644 index 0000000..fed50a6 --- /dev/null +++ b/src/hooks/useOverviewOverlay.ts @@ -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, + canvasRef: React.RefObject +) { + 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]); +} diff --git a/src/hooks/useSightingFeed.ts b/src/hooks/useSightingFeed.ts new file mode 100644 index 0000000..fb969be --- /dev/null +++ b/src/hooks/useSightingFeed.ts @@ -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( + () => Array(limit).fill(null) as unknown as SightingWidgetType[] + ); + const [selectedRef, setSelectedRef] = useState(null); + const [mostRecent, setMostRecent] = useState(null); + + const mostRecentRef = useRef(-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, + }; +} diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 84f22fb..1773249 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -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} > - - - - + + + + + + + + +
); }; diff --git a/src/types/types.ts b/src/types/types.ts index 31fa975..64b3e77 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -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 +}; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index f8f355c..b940280 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -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(); + }); +}