teecup/frontend/components/hole-target-distance.tsx

132 lines
5 KiB
TypeScript
Raw Normal View History

"use client"
import { useEffect, useRef, useState } from "react"
import { TargetDistance } from "@/components/target-distance"
import { haversineMeters, type LatLng } from "@/lib/geo"
// Rangefinder-datalag (ADR-064) -- henter rundens rå banepunkter (grønn
// front/midt/bak, hindringer) og kombinerer dem klient-side med spillerens
// LIVE GPS-posisjon via samme watchPosition-mønster som map-point-picker.tsx
// allerede bruker for slagmåling (ADR-048 tillegg 2026-08-08 del 2) --
// `enableHighAccuracy`, `maximumAge: 1000`, grasiøs degradering ved avslått
// tillatelse, `clearWatch` ved avmontering. `TargetDistance` selv forblir
// "dum" (V0-eksportert, ren visning) -- all logikk bor her.
type ApiTargetPoint = {
poi_type: string
location: "front" | "middle" | "back" | null
side_fairway: "left" | "center" | "right" | null
latitude: number
longitude: number
}
const _HAZARD_LABELS: Record<string, string> = {
green_bunker: "Bunker (grønn)",
fairway_bunker: "Bunker (fairway)",
water: "Vannhinder",
}
export function HoleTargetDistance({
roundId,
holeNumber,
size = "compact",
className,
}: {
roundId: string
holeNumber: number
size?: "compact" | "full"
className?: string
}) {
const [points, setPoints] = useState<ApiTargetPoint[] | null>(null)
const [livePosition, setLivePosition] = useState<LatLng | null>(null)
const [isLive, setIsLive] = useState(false)
const watchIdRef = useRef<number | null>(null)
useEffect(() => {
let cancelled = false
setPoints(null)
fetch(`/rounds/${roundId}/holes/${holeNumber}/target-points`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : []))
.then((data: ApiTargetPoint[]) => {
if (!cancelled) setPoints(data)
})
.catch(() => {
if (!cancelled) setPoints([])
})
return () => {
cancelled = true
}
}, [roundId, holeNumber])
useEffect(() => {
// Ingen vits å spore GPS når banen uansett ikke har koordinatdata for
// dette hullet -- unngår unødvendig batteribruk/tillatelses-prompt.
if (!points || points.length === 0) return
if (typeof navigator === "undefined" || !navigator.geolocation) return
watchIdRef.current = navigator.geolocation.watchPosition(
(pos) => {
setLivePosition({ lat: pos.coords.latitude, lng: pos.coords.longitude })
setIsLive(true)
},
() => {
// Avslått/feilet -- degraderer grasiøst, komponenten viser bare "ingen data".
setIsLive(false)
},
{ enableHighAccuracy: true, maximumAge: 1000 },
)
return () => {
if (watchIdRef.current !== null && typeof navigator !== "undefined" && navigator.geolocation) {
navigator.geolocation.clearWatch(watchIdRef.current)
}
watchIdRef.current = null
}
}, [points])
// Fortsatt laster, ELLER banen har rett og slett ingen koordinatdata
// (det store flertallet av runder, TeeOff-baner har ingen GolfAPI-kobling)
// -- ingen synlig rad i det hele tatt, ikke en "ingen data"-lapp på HVER
// eneste runde. Lappen (TargetDistance sin egen null-tilstand) er forbeholdt
// "banen HAR data, men venter på GPS-posisjon ennå" -- da vet vi det er
// verdt å vise noe.
if (points === null || points.length === 0) return null
if (!livePosition) {
return (
<div className="flex shrink-0 justify-center border-b border-border px-4 py-2">
<TargetDistance size={size} distances={null} isLive={false} className={className} />
</div>
)
}
const greenPoints = points.filter((p) => p.poi_type === "green" && p.location)
const front = greenPoints.find((p) => p.location === "front")
const middle = greenPoints.find((p) => p.location === "middle")
const back = greenPoints.find((p) => p.location === "back")
if (!front || !middle || !back) {
return <TargetDistance size={size} distances={null} isLive={false} className={className} />
}
const distances = {
front: Math.round(haversineMeters(livePosition, { lat: front.latitude, lng: front.longitude })),
middle: Math.round(haversineMeters(livePosition, { lat: middle.latitude, lng: middle.longitude })),
back: Math.round(haversineMeters(livePosition, { lat: back.latitude, lng: back.longitude })),
}
const hazards = points.filter((p) => p.poi_type in _HAZARD_LABELS)
let nearestHazard: { label: string; distanceMeters: number } | null = null
for (const h of hazards) {
const d = haversineMeters(livePosition, { lat: h.latitude, lng: h.longitude })
if (nearestHazard === null || d < nearestHazard.distanceMeters) {
const base = _HAZARD_LABELS[h.poi_type]
const label = h.location === "front" || h.location === "back" ? `${base} (${h.location === "front" ? "front" : "bak"})` : base
nearestHazard = { label, distanceMeters: Math.round(d) }
}
}
return (
<div className="flex shrink-0 justify-center border-b border-border px-4 py-2">
<TargetDistance size={size} distances={distances} nearestHazard={nearestHazard} isLive={isLive} className={className} />
</div>
)
}