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

313 lines
12 KiB
TypeScript
Raw Normal View History

"use client"
import { useEffect, useRef, useState } from "react"
import { TargetDistance } from "@/components/target-distance"
import { HoleDiagram } from "@/components/hole-diagram-view"
import { haversineMeters, type LatLng } from "@/lib/geo"
import { midpoint, projectOntoAxis } from "@/lib/hole-geometry"
// Rangefinder-datalag (ADR-064) -- henter rundens rå banepunkter (green
// front/senter/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`, grasiøs degradering ved avslått tillatelse,
// `clearWatch` ved avmontering. `TargetDistance` selv forblir "dum"
// (V0-eksportert, ren visning) -- all logikk bor her. Ingen egen
// rad-wrapper (border/padding) her -- kalleren styrer plassering/bredde.
// Montert i PlayerHoleCards sin hull-navigator (round-detail.tsx),
// size="full" -- IKKE i ScoringWizard-headeren (der den satt først,
// 2026-08-12): brukeren pekte ut hovedvisningen som riktig sted og ba
// om mer bredde enn den opprinnelige kompaktvarianten, 2026-08-13.
//
// `baseUrl` (ikke `roundId`) siden migrasjon 079 -- komponenten gjenbrukes
// nå av tre kallesteder med tre ulike URL-familier (frittstående runde
// `/rounds/{id}`, individuell org-turnering `/orgs/{org}/tournaments/{t}/
// rounds/{r}`, lag-matchplay `/orgs/{org}/matches/{m}`) -- kalleren bygger
// selv `${baseUrl}/holes/{n}/target-points`, komponenten trenger ikke vite
// hvilken flyt den sitter i.
//
// Strømsparing (2026-08-13, brukerrapport om høyt batteriforbruk):
// (1) `maximumAge` satt til 4000ms (var 1000ms) -- en golfer beveger seg
// ~1,5 m/s, oppdatering hvert 4. sekund er umerkelig i praksis men ber
// GPS-brikken om et ferskt fix mye sjeldnere. (2) `watchPosition` pauses
// helt når fanen/appen ikke er synlig (`document.visibilitychange`) --
// ingen vits å holde GPS-en aktiv for en skjerm ingen ser på (skjerm låst,
// annen app i forgrunnen), og gjenopptas automatisk når brukeren kommer
// tilbake. `enableHighAccuracy` beholdt UENDRET -- lav nøyaktighet ville
// gitt titalls meters feilmargin, for upresist til at front/midt/bak-
// tallene gir mening.
type ApiTargetPoint = {
poi_type: string
location: "front" | "middle" | "back" | null
side_fairway: "left" | "center" | "right" | null
latitude: number
longitude: number
// Eksplisitt, menneske-satt paring av forkant/bakkant til SAMME
// fysiske hindring (migrasjon 080/ADR-084). `null` = enkeltstående
// punkt, ALDRI slått sammen med noe -- se groupDiagramHazards().
hazard_group: string | null
}
const _HAZARD_LABELS: Record<string, string> = {
green_bunker: "Bunker (green)",
fairway_bunker: "Bunker (fairway)",
water: "Vannhinder",
rock: "Fjellknaus",
trees: "Trær",
dogleg: "Dogleg",
layup: "Layup-punkt",
landmark: "Landemerke",
}
// ADR-084: hvilket ikon en hindringstype får i diagrammet. "generic"
// (typer uten dedikert ikon -- i dag ingen, `road` ville falt hit hvis
// den noen gang klassifiseres som hindring) faller tilbake til lucide
// sin TriangleAlert -- se hole-diagram-view.tsx.
type HazardKind = "sand" | "water" | "rock" | "tree" | "dogleg" | "layup" | "landmark" | "generic"
const _HAZARD_KIND: Record<string, HazardKind> = {
green_bunker: "sand",
fairway_bunker: "sand",
water: "water",
rock: "rock",
trees: "tree",
dogleg: "dogleg",
layup: "layup",
landmark: "landmark",
}
// Grupperer rå hindringspunkter til hindrings-ENTITETER for diagrammet.
// VIKTIG (bruker-rettelse, ADR-084): to punkter er KUN samme fysiske
// hindring hvis de deler samme, ikke-null hazard_group -- ALDRI basert
// på poi_type+side_fairway alene (ville feilaktig slått sammen to
// atskilte hindringer av samme type på samme side). Punkter med
// hazard_group=null forblir alltid enkeltstående.
function groupDiagramHazards(
hazardPoints: ApiTargetPoint[],
livePosition: LatLng,
teeRef: LatLng,
greenRef: LatLng,
) {
const grouped = new Map<string, ApiTargetPoint[]>()
const standalone: ApiTargetPoint[] = []
for (const p of hazardPoints) {
if (p.hazard_group) {
const list = grouped.get(p.hazard_group) ?? []
list.push(p)
grouped.set(p.hazard_group, list)
} else {
standalone.push(p)
}
}
type Entity = {
key: string
kind: HazardKind
label: string
lane: "left" | "center" | "right"
alongPercent: number
below: number
above: number | null
// Kun meningsfullt for enkeltstående punkt (above === null) --
// et parret forkant+bakkant-hinder (above !== null) trenger ikke
// dette, UI-en viser da alltid eksplisitt "forkant"/"bakkant".
location: "front" | "middle" | "back" | null
}
const entities: Entity[] = []
function distanceTo(p: ApiTargetPoint) {
return Math.round(haversineMeters(livePosition, { lat: p.latitude, lng: p.longitude }))
}
function along(p: ApiTargetPoint) {
return projectOntoAxis(teeRef, greenRef, { lat: p.latitude, lng: p.longitude }).alongPercent
}
for (const [groupKey, pts] of grouped) {
const front = pts.find((p) => p.location === "front")
const back = pts.find((p) => p.location === "back")
const primary = front ?? back ?? pts[0]
const kind = _HAZARD_KIND[primary.poi_type]
if (!kind) continue
const alongValues = [front, back].filter((p): p is ApiTargetPoint => !!p).map(along)
entities.push({
key: `group-${groupKey}`,
kind,
label: _HAZARD_LABELS[primary.poi_type],
lane: primary.side_fairway ?? "center",
alongPercent: alongValues.reduce((a, b) => a + b, 0) / alongValues.length,
below: front ? distanceTo(front) : distanceTo(primary),
above: front && back ? distanceTo(back) : null,
location: front && back ? null : primary.location,
})
}
for (const p of standalone) {
const kind = _HAZARD_KIND[p.poi_type]
if (!kind) continue
entities.push({
key: `point-${p.latitude}-${p.longitude}-${p.poi_type}`,
kind,
label: _HAZARD_LABELS[p.poi_type],
lane: p.side_fairway ?? "center",
alongPercent: along(p),
below: distanceTo(p),
above: null,
location: p.location,
})
}
return entities
}
export function HoleTargetDistance({
baseUrl,
holeNumber,
size = "compact",
className,
}: {
baseUrl: string
holeNumber: number
size?: "compact" | "full"
className?: string
}) {
const [points, setPoints] = useState<ApiTargetPoint[] | null>(null)
const [livePosition, setLivePosition] = useState<LatLng | null>(null)
const watchIdRef = useRef<number | null>(null)
useEffect(() => {
let cancelled = false
setPoints(null)
fetch(`${baseUrl}/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
}
}, [baseUrl, 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
if (typeof document === "undefined") return
function startWatch() {
watchIdRef.current = navigator.geolocation.watchPosition(
(pos) => {
setLivePosition({ lat: pos.coords.latitude, lng: pos.coords.longitude })
},
() => {
// Avslått/feilet -- degraderer grasiøst, komponenten viser bare "ingen data".
},
{ enableHighAccuracy: true, maximumAge: 4000 },
)
}
function stopWatch() {
if (watchIdRef.current !== null && typeof navigator !== "undefined" && navigator.geolocation) {
navigator.geolocation.clearWatch(watchIdRef.current)
}
watchIdRef.current = null
}
// Pauser GPS-sporingen helt når fanen/appen ikke er synlig (skjerm
// låst, annen app i forgrunnen) -- gjenopptas automatisk når brukeren
// kommer tilbake.
function handleVisibilityChange() {
if (document.hidden) {
stopWatch()
} else {
startWatch()
}
}
if (!document.hidden) startWatch()
document.addEventListener("visibilitychange", handleVisibilityChange)
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange)
stopWatch()
}
}, [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 <TargetDistance size={size} distances={null} className={className} />
}
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} 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 })),
}
// ADR-083: ALLE hindringer, ikke bare nærmeste (tidligere reduserte denne
// løkken til ett enkelt nearestHazard-objekt) -- sortert nærmest
// spilleren først. Avstandene er ALLTID live fra spilleren, uavhengig av
// om et geometrisk diagram kan tegnes under.
const hazardPoints = points.filter((p) => p.poi_type in _HAZARD_LABELS)
const hazards = hazardPoints
.map((h) => {
const base = _HAZARD_LABELS[h.poi_type]
const label =
h.location === "front" || h.location === "back"
? `${base} (${h.location === "front" ? "front" : "bak"})`
: base
return { label, distanceMeters: Math.round(haversineMeters(livePosition, { lat: h.latitude, lng: h.longitude })), lat: h.latitude, lng: h.longitude }
})
.sort((a, b) => a.distanceMeters - b.distanceMeters)
// Fast tee-referanse (IKKE spillerens bevegelige livePosition) for selve
// hull-diagrammet -- se ADR-083. Midtpunkt hvis begge tee_front/tee_back
// finnes, ellers den ene som finnes. Mangler begge (forekommer, ikke alle
// baner har tee-koordinater): faller tilbake til den flate listen under,
// ingen regresjon.
const teeFront = points.find((p) => p.poi_type === "tee_front")
const teeBack = points.find((p) => p.poi_type === "tee_back")
const teeRef =
teeFront && teeBack
? midpoint({ lat: teeFront.latitude, lng: teeFront.longitude }, { lat: teeBack.latitude, lng: teeBack.longitude })
: teeFront
? { lat: teeFront.latitude, lng: teeFront.longitude }
: teeBack
? { lat: teeBack.latitude, lng: teeBack.longitude }
: null
if (teeRef) {
const greenRef = { lat: middle.latitude, lng: middle.longitude }
const player = projectOntoAxis(teeRef, greenRef, livePosition)
const diagramHazards = groupDiagramHazards(hazardPoints, livePosition, teeRef, greenRef)
return (
<HoleDiagram
size={size}
className={className}
distances={distances}
playerPosition={player}
hazards={diagramHazards}
/>
)
}
return (
<TargetDistance size={size} distances={distances} hazards={hazards} className={className} />
)
}