- Flytende "Registrer score" trigges nå av faktisk synlighet til side-flyt-knappen, ikke av om banekartet er utvidet -- en lang hindringsliste alene (uten utvidet kart) kunne dytte knappen ut av syne uten at den flytende versjonen noensinne dukket opp. - Hindringslisten sortert fra fjernest (øverst) til nærmest (nederst), matcher loddrett telefon-holdning med green øverst/utslag nederst. Bakkant brukt som sorteringsnøkkel der en finnes. - Kartretning ved slagmåling følger nå ekte utslag->green-geometri (green opp/utslag ned) når begge er kjent, vinner over den eldre forrige-slag-gjetningen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
381 lines
15 KiB
TypeScript
381 lines
15 KiB
TypeScript
"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",
|
|
creek: "Bekk",
|
|
rock: "Fjellknaus",
|
|
trees: "Trær",
|
|
dogleg: "Dogleg",
|
|
layup: "Layup-punkt",
|
|
landmark: "Landemerke",
|
|
road: "Vei",
|
|
fairway: "Fairway",
|
|
stone_fence: "Voll",
|
|
}
|
|
|
|
// ADR-084: hvilket ikon en hindringstype får i diagrammet. "generic"
|
|
// (typer uten dedikert ikon -- i dag ingen) faller tilbake til lucide
|
|
// sin TriangleAlert -- se hole-diagram-view.tsx.
|
|
type HazardKind =
|
|
| "sand"
|
|
| "water"
|
|
| "creek"
|
|
| "rock"
|
|
| "tree"
|
|
| "dogleg"
|
|
| "layup"
|
|
| "landmark"
|
|
| "road"
|
|
| "fairway"
|
|
| "stone_fence"
|
|
| "generic"
|
|
const _HAZARD_KIND: Record<string, HazardKind> = {
|
|
green_bunker: "sand",
|
|
fairway_bunker: "sand",
|
|
water: "water",
|
|
creek: "creek",
|
|
rock: "rock",
|
|
trees: "tree",
|
|
dogleg: "dogleg",
|
|
layup: "layup",
|
|
landmark: "landmark",
|
|
road: "road",
|
|
fairway: "fairway",
|
|
stone_fence: "stone_fence",
|
|
}
|
|
|
|
// Hvor mange prosent av hull-lengden en hindring får "henge igjen" etter at
|
|
// spilleren i teorien har passert den (ADR-085, brukerønske 2026-08-20:
|
|
// hindringer bak spilleren er unødvendig informasjon -- skal ikke lenger
|
|
// vises). Ren GPS-støy-margin, ikke en spilleregel -- uten denne ville en
|
|
// hindring rett ved spillerens fot kunne blinke inn/ut av listen mellom to
|
|
// GPS-oppdateringer idet spilleren går forbi.
|
|
const HAZARD_PASSED_TOLERANCE_PCT = 1.5
|
|
|
|
// 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.
|
|
// `playerAlongPercent` (ADR-085) -- en hindring vises KUN hvis den fortsatt
|
|
// ligger MELLOM spilleren og greenen (brukerønske: passerte hindringer er
|
|
// unødvendig informasjon). For et parret forkant+bakkant-hinder brukes
|
|
// bakkanten (den ytterste kanten, nærmest green) -- hele hindringen regnes
|
|
// som passert først når spilleren er forbi BEGGE kantene, ikke bare
|
|
// forkanten.
|
|
function groupDiagramHazards(
|
|
hazardPoints: ApiTargetPoint[],
|
|
livePosition: LatLng,
|
|
teeRef: LatLng,
|
|
greenRef: LatLng,
|
|
playerAlongPercent: number,
|
|
) {
|
|
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)
|
|
const farEdgeAlong = Math.max(...alongValues)
|
|
if (farEdgeAlong < playerAlongPercent - HAZARD_PASSED_TOLERANCE_PCT) continue
|
|
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
|
|
if (along(p) < playerAlongPercent - HAZARD_PASSED_TOLERANCE_PCT) 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,
|
|
})
|
|
}
|
|
// Rettet 2026-08-21 (brukerfunn, skjermdump): listen skal lese som
|
|
// kartet den står under -- holdt loddrett med greenen øverst (lengst
|
|
// unna) og utslaget nederst (nærmest), skal hindringene imellom følge
|
|
// SAMME rekkefølge, ikke motsatt. Sorterer derfor synkende på
|
|
// BAKKANTEN når en finnes (`above` -- den ferneste kanten av en parret
|
|
// hindring, eksplisitt bedt om av bruker: "avstanden til bakkant [som]
|
|
// vi hensyntar når vi skal sortere"), ellers enkeltpunktets egen
|
|
// avstand (`below`, kun eksisterende verdi for et upar ret punkt).
|
|
return entities.sort((a, b) => (b.above ?? b.below) - (a.above ?? a.below))
|
|
}
|
|
|
|
export function HoleTargetDistance({
|
|
baseUrl,
|
|
holeNumber,
|
|
size = "compact",
|
|
className,
|
|
onAvailabilityChange,
|
|
}: {
|
|
baseUrl: string
|
|
holeNumber: number
|
|
size?: "compact" | "full"
|
|
className?: string
|
|
/** Kalles hver gang det blir klart om banen faktisk har koordinatdata for
|
|
* hullet eller ikke -- lar en kaller (f.eks. en utvid/skjul-knapp rundt
|
|
* komponenten) vite om det er noe å vise, uten selv å duplisere fetch-en. */
|
|
onAvailabilityChange?: (available: boolean) => void
|
|
}) {
|
|
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(() => {
|
|
if (points === null) return
|
|
onAvailabilityChange?.(points.length > 0)
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [points])
|
|
|
|
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). Avstandene er ALLTID live
|
|
// fra spilleren, uavhengig av om et geometrisk diagram kan tegnes under.
|
|
// Sortert lengst unna FØRST (2026-08-21, samme retting/begrunnelse som
|
|
// groupDiagramHazards under -- listen leses loddrett med greenen
|
|
// øverst) -- denne grenen (ingen tee-koordinater) har ingen forkant/
|
|
// bakkant-parring i det hele tatt, kun ett enkelt avstandstall per
|
|
// punkt, så ingen "bakkant"-nyanse å ta hensyn til her.
|
|
const hazardPoints = points.filter((p) => p.poi_type in _HAZARD_LABELS)
|
|
// ADR-085 (brukerønske 2026-08-20): passerte hindringer skal ikke vises.
|
|
// Denne (tee-referanse mangler) grenen har ingen akse å projisere langs,
|
|
// så "mellom spiller og green" tilnærmes med "nærmere green enn
|
|
// spilleren selv" -- mindre presist enn along-percent-varianten under
|
|
// (tar ikke hensyn til sideveis avstand), men fungerer som en fornuftig
|
|
// proxy for det sjeldne tilfellet en bane mangler tee-koordinater.
|
|
const hazardPointsAhead = hazardPoints.filter(
|
|
(h) => haversineMeters({ lat: h.latitude, lng: h.longitude }, { lat: middle.latitude, lng: middle.longitude }) < distances.middle,
|
|
)
|
|
const hazards = hazardPointsAhead
|
|
.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) => b.distanceMeters - a.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, player.alongPercent)
|
|
return (
|
|
<HoleDiagram
|
|
size={size}
|
|
className={className}
|
|
distances={distances}
|
|
playerPosition={player}
|
|
hazards={diagramHazards}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<TargetDistance size={size} distances={distances} hazards={hazards} className={className} />
|
|
)
|
|
}
|