Map: pin 511NY camera feeds as tiles on the map (#183)
Clicking a camera's snapshot pins a 192x108 tile anchored at the camera -- the live HLS stream when the camera has one (hls.js; Safari native), else the snapshot refreshed every 60s, falling back to the snapshot if the stream dies. Up to 6 pins, oldest dropped. Players exist only while the DOT Cameras overlay is on, so hiding it stops every stream. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c95498e7fe
commit
c1a2721fbe
@@ -22,7 +22,8 @@ import { useAircraft } from "@/lib/useAircraft";
|
||||
import { useAircraftTrail } from "@/lib/useAircraftTrail";
|
||||
import { useVessels } from "@/lib/useVessels";
|
||||
import { use511 } from "@/lib/use511";
|
||||
import type { Ny511Event, Ny511FeedStatus } from "@/lib/types";
|
||||
import type { Ny511Camera, Ny511Event, Ny511FeedStatus } from "@/lib/types";
|
||||
import { CameraFeed } from "@/components/CameraFeed";
|
||||
|
||||
// ── Leaflet icon fix ──────────────────────────────────────────────────────────
|
||||
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
|
||||
@@ -380,14 +381,28 @@ function FeedProblem({ map, label, status, fetchError }: { map: L.Map; label: st
|
||||
);
|
||||
}
|
||||
|
||||
// Pinned cameras: clicking a camera's snapshot pins its live feed as a small
|
||||
// tile anchored at the camera, so several views along one road can be read
|
||||
// against the map. Oldest pin drops past the cap.
|
||||
const MAX_CAMERA_PINS = 6;
|
||||
|
||||
function DotCameraLayer() {
|
||||
const map = useMap();
|
||||
const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS);
|
||||
const { data, error } = use511(map, "cameras", shown);
|
||||
const [pinned, setPinned] = useState<Ny511Camera[]>([]);
|
||||
const pinnedIds = useMemo(() => new Set(pinned.map((c) => c.id)), [pinned]);
|
||||
|
||||
const pin = (c: Ny511Camera) => {
|
||||
map.closePopup();
|
||||
setPinned((prev) => (prev.some((p) => p.id === c.id) ? prev : [...prev, c].slice(-MAX_CAMERA_PINS)));
|
||||
};
|
||||
const unpin = (id: string) => setPinned((prev) => prev.filter((p) => p.id !== id));
|
||||
|
||||
return (
|
||||
<>
|
||||
{shown && <FeedProblem map={map} label="DOT cameras" status={data.cameras_status} fetchError={error} />}
|
||||
{(data.cameras ?? []).map((c) => (
|
||||
{(data.cameras ?? []).filter((c) => !pinnedIds.has(c.id)).map((c) => (
|
||||
<Marker key={c.id} position={[c.lat, c.lon]} icon={cameraIcon()}>
|
||||
<Popup minWidth={260} maxWidth={340}>
|
||||
<div className="space-y-1">
|
||||
@@ -395,14 +410,32 @@ function DotCameraLayer() {
|
||||
{c.roadway && <div className="text-xs text-ink-muted">{c.roadway}{c.direction && c.direction !== "Unknown" ? ` · ${c.direction}` : ""}</div>}
|
||||
{c.image_url && (
|
||||
// Popup content mounts on open, so the timestamp busts the cache per open.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={`${c.image_url}?t=${Date.now()}`} alt={`Camera: ${c.name}`} className="w-full rounded border border-line" loading="lazy" />
|
||||
<button type="button" onClick={() => pin(c)} title="Pin this camera to the map" className="block w-full p-0 border-0 bg-transparent cursor-pointer">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={`${c.image_url}?t=${Date.now()}`} alt={`Camera: ${c.name}`} className="w-full rounded border border-line" loading="lazy" />
|
||||
</button>
|
||||
)}
|
||||
<div className="text-[10px] text-ink-muted">Snapshot: 511NY / NYSDOT</div>
|
||||
<div className="text-[10px] text-ink-muted">
|
||||
Click the image to pin {c.video_url ? "the live feed" : "it"} to the map · 511NY / NYSDOT
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
{/* Players only exist while the overlay is on, so hiding it stops every stream. */}
|
||||
{shown && pinned.map((c) => (
|
||||
<Marker key={`pin-${c.id}`} position={[c.lat, c.lon]} icon={cameraIcon()} zIndexOffset={300}>
|
||||
<Tooltip permanent interactive direction="top" offset={[0, -8]} opacity={1} className="drb-cam-pin">
|
||||
<div className="w-[192px]">
|
||||
<div className="flex items-center gap-1 px-1.5 h-4">
|
||||
<span className="flex-1 truncate text-[10px] leading-4 text-ink-2" title={c.name}>{c.name}</span>
|
||||
<button type="button" onClick={() => unpin(c.id)} aria-label={`Unpin ${c.name}`} className="text-ink-muted hover:text-ink text-xs leading-none px-0.5">×</button>
|
||||
</div>
|
||||
<CameraFeed camera={c} className="block w-[192px] h-[108px] object-cover bg-black rounded-b" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user