From b7479fa68f2b0bd09fbcf7a20514e19cb2901de6 Mon Sep 17 00:00:00 2001 From: ulhub Date: Tue, 14 Jul 2026 16:28:07 +0000 Subject: [PATCH] Show point details on marker click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a point on the job map opens an InfoWindow with its full telemetry readout (utility, fix, recorded time, depth, frequency, current, signal, gain, locate mode, phase, compass, distortion, GPS accuracy/satellites/HDOP, altitude, coordinates) — only the fields the point actually has, since instruments vary in what they report. Clicking empty map area dismisses it. MapPoint gained altitude/hAccuracy/vAccuracy/satellites/hdop/phaseDeg, which the backend already returned but the frontend type omitted. Co-Authored-By: Claude Fable 5 --- .env.example | 11 ----- web/components/map/google/GoogleJobMap.tsx | 50 +++++++++++++++++++--- web/components/map/types.ts | 36 ++++++++++++++++ 3 files changed, 80 insertions(+), 17 deletions(-) delete mode 100644 .env.example diff --git a/.env.example b/.env.example deleted file mode 100644 index 085740b..0000000 --- a/.env.example +++ /dev/null @@ -1,11 +0,0 @@ -# Copy to .env and fill in real values (.env is gitignored) - -# Secret used to sign JWT auth tokens -JWT_SECRET=change-me-to-a-long-random-string - -# MQTT credentials the backend uses to connect to mosquitto -MQTT_BACKEND_USERNAME=backend -MQTT_BACKEND_PASSWORD=backendpass - -# Google Maps JavaScript API key (exposed to the browser; restrict by HTTP referrer) -NEXT_PUBLIC_GOOGLE_MAPS_API_KEY= diff --git a/web/components/map/google/GoogleJobMap.tsx b/web/components/map/google/GoogleJobMap.tsx index 4678521..b0e666e 100644 --- a/web/components/map/google/GoogleJobMap.tsx +++ b/web/components/map/google/GoogleJobMap.tsx @@ -1,7 +1,7 @@ /// -import { APIProvider, Map as GoogleMap, useMap } from '@vis.gl/react-google-maps'; -import { useEffect, useRef } from 'react'; -import { JobMapProps, MapPoint, pointSummary, UTILITY_COLORS } from '../types'; +import { APIProvider, InfoWindow, Map as GoogleMap, useMap } from '@vis.gl/react-google-maps'; +import { useEffect, useRef, useState } from 'react'; +import { JobMapProps, MapPoint, pointDetailRows, UTILITY_COLORS } from '../types'; const API_KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || ''; @@ -19,10 +19,20 @@ function orderKey(p: MapPoint): number { // Draws points as colored circles plus one polyline per utility run. // Imperative overlays (google.maps.Marker/Polyline) are used because // @vis.gl/react-google-maps has no polyline component. -function PointsLayer({ points, fitBounds }: { points: MapPoint[]; fitBounds: boolean }) { +function PointsLayer({ + points, + fitBounds, + onSelect, +}: { + points: MapPoint[]; + fitBounds: boolean; + onSelect: (point: MapPoint) => void; +}) { const map = useMap(); const overlaysRef = useRef<{ setMap: (m: google.maps.Map | null) => void }[]>([]); const fittedCountRef = useRef(0); + const onSelectRef = useRef(onSelect); + onSelectRef.current = onSelect; useEffect(() => { if (!map) { @@ -59,6 +69,7 @@ function PointsLayer({ points, fitBounds }: { points: MapPoint[]; fitBounds: boo const marker = new google.maps.Marker({ position: { lat: p.lat, lng: p.lng }, map, + cursor: 'pointer', icon: { path: google.maps.SymbolPath.CIRCLE, scale: 5, @@ -67,8 +78,8 @@ function PointsLayer({ points, fitBounds }: { points: MapPoint[]; fitBounds: boo strokeColor: '#ffffff', strokeWeight: 1.5, }, - title: `${pointSummary(p)}\n${new Date(p.recordedAt).toLocaleString()}`, }); + marker.addListener('click', () => onSelectRef.current(p)); overlaysRef.current.push(marker); } } @@ -84,7 +95,28 @@ function PointsLayer({ points, fitBounds }: { points: MapPoint[]; fitBounds: boo return null; } +function PointDetails({ point, onClose }: { point: MapPoint; onClose: () => void }) { + return ( + +
+ + + {pointDetailRows(point).map(([label, value]) => ( + + + + + ))} + +
{label}{value}
+
+
+ ); +} + export default function GoogleJobMap({ points, fitBounds = true, heightPx = 480 }: JobMapProps) { + const [selected, setSelected] = useState(null); + if (!API_KEY) { return (
p.id === selected.id) ?? selected) : null; + return (
@@ -111,8 +147,10 @@ export default function GoogleJobMap({ points, fitBounds = true, heightPx = 480 defaultZoom={points[0] ? 18 : DEFAULT_ZOOM} mapTypeId="hybrid" gestureHandling="greedy" + onClick={() => setSelected(null)} > - + + {selectedCurrent && setSelected(null)} />}
diff --git a/web/components/map/types.ts b/web/components/map/types.ts index 16822f0..f678799 100644 --- a/web/components/map/types.ts +++ b/web/components/map/types.ts @@ -6,10 +6,16 @@ export interface MapPoint { id: string; lat: number; lng: number; + altitude: number | null; utilityType: string; fixType: string; sequence: number | null; recordedAt: string; + // GPS quality + hAccuracy: number | null; + vAccuracy: number | null; + satellites: number | null; + hdop: number | null; // Locator receiver telemetry (all optional — depends on the instrument) depth: number | null; frequencyHz: number | null; @@ -17,6 +23,7 @@ export interface MapPoint { signalDb: number | null; gainDb: number | null; locateMode: string | null; + phaseDeg: number | null; compassDeg: number | null; distortionPct: number | null; } @@ -31,6 +38,35 @@ export function pointSummary(p: MapPoint): string { return parts.join(' · '); } +// Ordered [label, value] pairs for a full detail readout — only fields the +// point actually has are included, since instruments vary in what they report. +export function pointDetailRows(p: MapPoint): Array<[string, string]> { + const rows: Array<[string, string]> = [ + ['Utility', p.utilityType], + ['GPS fix', p.fixType], + ['Recorded', new Date(p.recordedAt).toLocaleString()], + ]; + if (p.sequence != null) rows.push(['Sequence', String(p.sequence)]); + if (p.depth != null) rows.push(['Depth', `${p.depth} m`]); + if (p.frequencyHz != null) { + rows.push(['Frequency', p.frequencyHz >= 1000 ? `${p.frequencyHz / 1000} kHz` : `${p.frequencyHz} Hz`]); + } + if (p.currentMa != null) rows.push(['Current', `${p.currentMa} mA`]); + if (p.signalDb != null) rows.push(['Signal', `${p.signalDb} dB`]); + if (p.gainDb != null) rows.push(['Gain', `${p.gainDb} dB`]); + if (p.locateMode) rows.push(['Locate mode', p.locateMode]); + if (p.phaseDeg != null) rows.push(['Phase', `${p.phaseDeg}°`]); + if (p.compassDeg != null) rows.push(['Compass', `${p.compassDeg}°`]); + if (p.distortionPct != null) rows.push(['Distortion', `${p.distortionPct}%`]); + if (p.altitude != null) rows.push(['Altitude', `${p.altitude} m`]); + if (p.hAccuracy != null) rows.push(['Horiz. accuracy', `±${p.hAccuracy} m`]); + if (p.vAccuracy != null) rows.push(['Vert. accuracy', `±${p.vAccuracy} m`]); + if (p.satellites != null) rows.push(['Satellites', String(p.satellites)]); + if (p.hdop != null) rows.push(['HDOP', String(p.hdop)]); + rows.push(['Coordinates', `${p.lat.toFixed(7)}, ${p.lng.toFixed(7)}`]); + return rows; +} + export interface JobMapProps { points: MapPoint[]; // Pan/zoom to fit all points whenever their count changes