Visuelt hull-diagram for rangefinderen (tee nederst, green øverst)
Fikser to ting rapportert fra ekte produksjonsskjermbilde: (1) kun nærmeste hindring ble vist -- target-distance.tsx viser nå ALLE hindringer, sortert nærmest først, uavhengig av om et diagram kan tegnes. (2) Nytt hull-diagram (hole-diagram-view.tsx) plasserer hindringer og spiller i 2D langs en fast tee->green-akse, tee nederst/ green øverst konsistent uansett gangretning. Ny geometri-modul frontend/lib/hole-geometry.ts (projectOntoAxis, bygget på eksisterende haversineMeters/bearingDegrees) med 10 enhetstester som beviser matematikken riktig før noe visuelt ble bygget. Verifisert i scratch mot presist kjente syntetiske koordinater -- alle tall og posisjoner stemte eksakt med håndregnede fasitverdier. Midlertidig hånd-kodet visning, venter på V0-eksport for den polerte versjonen (samme mønster som FlagPlantSheet/PlayerImportPanel). Ingen migrasjon, ingen backend-endring. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
206aaa6217
commit
6f79df6eab
7 changed files with 536 additions and 21 deletions
|
|
@ -8242,6 +8242,104 @@ inn` bekreftet 200 OK.
|
|||
|
||||
---
|
||||
|
||||
## ADR-083: Visuelt hull-diagram for rangefinderen (tee nederst, green øverst, alle hindringer) (2026-08-17)
|
||||
|
||||
Foranledning: bruker rapporterte fra et ekte skjermbilde (produksjon,
|
||||
personlig Tjøme-runde) at rangefinderen kun viste ÉN hindring
|
||||
("Bunker (fairway)") selv om hullet også har en greenbunker i
|
||||
datasettet, og ba om at tee vises nederst/senter green øverst "på alle
|
||||
skjermer". Bekreftet via AskUserQuestion: (1) de 82 km i skjermbildet
|
||||
var ikke en feil (testet ikke fysisk på banen), (2) "tee nederst/green
|
||||
øverst" betyr et EKTE visuelt hull-diagram, (3) full 2D-plassering
|
||||
(langs OG til venstre/høyre for hullet), ikke bare en enkel vertikal
|
||||
rekkefølge.
|
||||
|
||||
**To atskilte fikser, bevisst i rekkefølge:**
|
||||
|
||||
1. **Selve "kun nærmeste"-buggen** (`hole-target-distance.tsx:155-164`,
|
||||
ADR-064-arven) -- reduserte ALLE hindringer (green_bunker/
|
||||
fairway_bunker/water/rock) til ett `nearestHazard`-objekt. Erstattet
|
||||
med en full, sortert (nærmest spilleren først) liste. `target-
|
||||
distance.tsx` sin `HazardLine` fikk en søster `HazardList` som
|
||||
stabler ALLE hindringer -- denne fiksen gjelder for ENHVER bane,
|
||||
uavhengig av om et diagram kan tegnes (se punkt 2), og var det
|
||||
raskeste å verifisere og fikk lavest risiko å skille ut for seg selv.
|
||||
|
||||
2. **Ny geometri-modul `frontend/lib/hole-geometry.ts`** --
|
||||
`projectOntoAxis(axisStart, axisEnd, point)` projiserer et vilkårlig
|
||||
punkt ned på en fast tee->green-akse (langs-prosent + tverr-meter),
|
||||
bygget på de eksisterende `haversineMeters`/`bearingDegrees`-
|
||||
primitivene i `geo.ts` (ren plan trigonometri, ingen nye
|
||||
avhengigheter). **Egne enhetstester** (`hole-geometry.test.ts`, 10
|
||||
tester) beviste geometrien riktig FØR noe visuelt ble bygget -- kjente
|
||||
fasitverdier (start→0%, slutt→100%, midtpunkt→50%, kjent vinkelrett
|
||||
offset→riktig tverr-meter, punkt utenfor akse→hhv. negativ/>100%).
|
||||
`hole-target-distance.tsx` henter nå ut en FAST tee-referanse
|
||||
(midtpunkt av `tee_front`+`tee_back` hvis begge finnes, ellers den
|
||||
ene som finnes) -- **bevisst IKKE spillerens bevegelige `livePosition`**,
|
||||
som ville fått hele diagrammet til å rotere rundt mens spilleren går.
|
||||
Selve avstandstallene som vises er FORTSATT alltid live fra
|
||||
spilleren -- kun selve TEGNE-posisjonen bruker den faste aksen.
|
||||
|
||||
**Anbefalt og valgt retning: ren SVG/CSS-skjematisk fremstilling, IKKE
|
||||
et nytt Mapbox-kart.** Begrunnet i research: (a) matcher `geo.ts` sitt
|
||||
eksplisitte "ingen Mapbox-kall der ren klientberegning holder"-prinsipp
|
||||
(ADR-048 kostnadskontroll-invariant B), (b) rangefinderen brukes løpende
|
||||
gjennom en runde -- gjentatte kartlastninger ville kostet mer (penger+
|
||||
batteri) enn nødvendig, (c) et ekte kart tvinger frem nord-orientering/
|
||||
kompleks rotasjon for å få "tee nederst/green øverst" konsistent -- en
|
||||
geometrisk projeksjon gir dette gratis, alltid riktig orientert uansett
|
||||
spillerens gangretning. `flag-map-overview.tsx` (et ekte, midlertidig
|
||||
Mapbox-satellittkart, venter selv på egen V0-erstatning) ble vurdert som
|
||||
mønster, men forkastet -- annet formål, ingen "tee nederst"-orientering.
|
||||
|
||||
**Midlertidig hånd-kodet visning bygget FØRST** (samme "Claude eier
|
||||
logikk, V0 eier presentasjon senere"-rekkefølge som FlagPlantSheet/
|
||||
PlayerImportPanel): `frontend/components/hole-diagram-view.tsx` --
|
||||
vertikal "bane", green-markør fast øverst, tee-markør fast nederst,
|
||||
spillerens posisjon OG hver hindring plassert ved `alongPercent`
|
||||
(vertikalt, klippet 0-100% KUN ved visning) + `crossMeters` (horisontalt,
|
||||
klippet til ±30 m fra midtlinjen -- faktisk fairway-bredde er ikke kjent
|
||||
fra dataen, en fornuftig typisk verdi). Rendres KUN når geometri er
|
||||
tilgjengelig (tee-punkt finnes) -- `target-distance.tsx` sin flate
|
||||
visning (nå med full, sortert hindringsliste fra punkt 1) forblir
|
||||
UENDRET fallback for baner uten tee-koordinater, ingen regresjon.
|
||||
Gjelder automatisk alle tre rangefinder-kallesteder (frittstående
|
||||
runder, individuell org-turnering, lag-matchplay) siden alle tre
|
||||
allerede deler samme `HoleTargetDistance`-datalag (ADR-081).
|
||||
|
||||
**Verifisert:**
|
||||
1. `hole-geometry.test.ts` (10 tester) + full `vitest run` (55/55) +
|
||||
`tsc --noEmit` rent -- geometrien bevist matematisk riktig FØR
|
||||
scratch-verifisering.
|
||||
2. Scratch-database + scratch `teecup_api` + lokal `next dev`, ETT hull
|
||||
med PRESIST KJENTE, syntetiske koordinater (tee->green rett nord,
|
||||
200 m, én hindring 100 m ut/20 m høyre, én hindring 180 m ut/15 m
|
||||
venstre) -- GPS simulert via et `initScript` (headless Chrome nekter
|
||||
ekte geolokasjon uansett posisjonsoverstyring, kjent begrensning fra
|
||||
tidligere denne økten) på et kjent punkt 50 m fra tee. ALLE viste tall
|
||||
stemte EKSAKT med håndregnede fasitverdier (front 140/senter 150/bak
|
||||
160/hindring 54 m/hindring 131 m via Pythagoras). Visuelt bekreftet:
|
||||
green øverst, tee nederst, 54 m-hindringen riktig plassert til HØYRE
|
||||
midt på hullet, 131 m-hindringen riktig plassert til VENSTRE nær
|
||||
green, spilleren riktig plassert 25% ut fra tee. Fallback til den
|
||||
flate visningen bekreftet feilfritt på et hull MED green men UTEN
|
||||
tee-koordinater (fortsatt viste alle hindringer, bare uten diagram).
|
||||
Lys+mørk bekreftet. Scratch-stacken fullstendig revet ned.
|
||||
|
||||
**Ingen migrasjon** -- ren frontend-endring, ingen backend-endring
|
||||
(target-points-endepunktene returnerte allerede `tee_front`/`tee_back`
|
||||
uendret, bare ikke brukt av frontend før nå).
|
||||
|
||||
**Gjenstår:** V0-prompt for `hole-diagram-view.tsx` sin polerte visning
|
||||
skrives og sendes til bruker; midlertidig hånd-kodet versjon er allerede
|
||||
bevist geometrisk korrekt og fungerer, men er visuelt uferdig.
|
||||
|
||||
**Rullet ut: ikke ennå** -- geometri+datalag+fallback er klare og
|
||||
scratch-bevist, venter på V0-integrering før samlet utrulling.
|
||||
|
||||
---
|
||||
|
||||
## Utviklingsplan (rekkefølge)
|
||||
|
||||
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
||||
|
|
|
|||
44
CHANGELOG.md
44
CHANGELOG.md
|
|
@ -11987,3 +11987,47 @@ Neste steg:
|
|||
ut"). `docker compose build teecup_api teecup_frontend && up -d`,
|
||||
ingen migrasjon. Rene containerlogger, `https://teecup.golf/logg-
|
||||
inn` 200 OK.
|
||||
|
||||
100. **Visuelt hull-diagram for rangefinderen (ADR-083) — 2026-08-17.**
|
||||
Bruker rapporterte fra et ekte skjermbilde (produksjon) at kun ÉN
|
||||
hindring vises selv om hullet har flere, og ba om at tee vises
|
||||
nederst/senter green øverst -- bekreftet som ønske om et ekte
|
||||
visuelt diagram med full 2D-plassering (langs + venstre/høyre), ikke
|
||||
bare listerekkefølge.
|
||||
|
||||
To atskilte fikser: (1) `hole-target-distance.tsx` sin "kun
|
||||
nærmeste"-reduksjon erstattet med en full, sortert hindringsliste
|
||||
(`target-distance.tsx` fikk en `HazardList`) -- gjelder ALLE baner,
|
||||
uavhengig av diagram. (2) Ny geometri-modul
|
||||
`frontend/lib/hole-geometry.ts` (`projectOntoAxis`, bygget på
|
||||
eksisterende `haversineMeters`/`bearingDegrees`) projiserer
|
||||
hindringer+spiller ned på en FAST tee->green-akse (tee_front/
|
||||
tee_back-midtpunkt, IKKE spillerens bevegelige posisjon). 10 nye
|
||||
enhetstester beviste geometrien matematisk riktig FØR noe visuelt
|
||||
ble bygget. Valgt ren SVG/CSS-skjematisk fremstilling fremfor et nytt
|
||||
Mapbox-kart (kostnadskontroll-prinsipp fra ADR-048, konsistent
|
||||
orientering uansett gangretning).
|
||||
|
||||
Midlertidig hånd-kodet visning (`hole-diagram-view.tsx`) bygget
|
||||
først for å bevise kjeden -- venter på V0-eksport for den polerte
|
||||
versjonen, samme rekkefølge som FlagPlantSheet/PlayerImportPanel.
|
||||
Rendres kun når tee-geometri finnes; `target-distance.tsx` sin flate
|
||||
visning (nå med full hindringsliste) forblir uendret fallback.
|
||||
Gjelder automatisk alle tre rangefinder-kallesteder (frittstående
|
||||
runder, individuell org-turnering, lag-matchplay).
|
||||
|
||||
**Verifisert:** `hole-geometry.test.ts` 10/10, full `vitest run`
|
||||
55/55, `tsc --noEmit` rent. Scratch-database med ETT hull med
|
||||
presist kjente syntetiske koordinater -- GPS simulert via
|
||||
`initScript` (headless Chrome nekter ekte geolokasjon). ALLE viste
|
||||
tall stemte eksakt med håndregnede fasitverdier (front 140/senter
|
||||
150/bak 160/hindringer 54 m og 131 m). Visuelt bekreftet riktig
|
||||
venstre/høyre- og langs-plassering av begge hindringene og
|
||||
spilleren. Fallback bekreftet på hull uten tee-koordinater. Lys+mørk
|
||||
bekreftet. Scratch-stacken revet ned.
|
||||
|
||||
**Ingen migrasjon** -- ren frontend, backend uendret (target-points
|
||||
returnerte allerede tee_front/tee_back, bare ikke brukt før nå).
|
||||
|
||||
**Rullet ut: ikke ennå** -- venter på V0-eksport for den polerte
|
||||
visningen.
|
||||
|
|
|
|||
197
frontend/components/hole-diagram-view.tsx
Normal file
197
frontend/components/hole-diagram-view.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
"use client"
|
||||
|
||||
// MIDLERTIDIG hånd-kodet hull-diagram (ADR-083, 2026-08-17) -- bygget for
|
||||
// å bevise geometrikjeden (frontend/lib/hole-geometry.ts) fungerer riktig
|
||||
// ende-til-ende FØR en V0-prompt skrives, samme rekkefølge som
|
||||
// FlagPlantSheet/PlayerImportPanel tidligere denne økten. Erstattes med en
|
||||
// V0-eksportert visning senere -- selve geometri-utregningen (i
|
||||
// hole-target-distance.tsx) endres ikke ved bytte, kun denne rene
|
||||
// visningen.
|
||||
//
|
||||
// Tee vises alltid nederst, green (senter) alltid øverst -- fast akse
|
||||
// (IKKE spillerens gangretning), se hole-target-distance.tsx sin
|
||||
// teeRef-uthenting. alongPercent/crossMeters kommer ferdigregnet inn,
|
||||
// klippes KUN her (ved selve tegningen), ikke i dataen.
|
||||
|
||||
import { Flag, MapPin } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type DiagramHazard = {
|
||||
label: string
|
||||
distanceMeters: number
|
||||
alongPercent: number
|
||||
crossMeters: number
|
||||
}
|
||||
|
||||
export function HoleDiagram({
|
||||
distances,
|
||||
playerPosition,
|
||||
hazards,
|
||||
size = "full",
|
||||
className,
|
||||
}: {
|
||||
distances: { front: number; middle: number; back: number }
|
||||
playerPosition: { alongPercent: number; crossMeters: number }
|
||||
hazards: DiagramHazard[]
|
||||
size?: "compact" | "full"
|
||||
className?: string
|
||||
}) {
|
||||
// Diagrammet trenger plass -- kompakt-visningen (badge ved GIR-merket)
|
||||
// viser fortsatt kun tallene + full hindringsliste, ikke selve tegningen.
|
||||
if (size === "compact") {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex flex-col gap-1 rounded-xl border border-clubhouse-border bg-clubhouse-card px-3 py-2 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-end gap-3">
|
||||
<NumberColumn label="Front" value={distances.front} />
|
||||
<NumberColumn label="Senter" value={distances.middle} emphasized />
|
||||
<NumberColumn label="Bak" value={distances.back} />
|
||||
</div>
|
||||
<HazardTextList hazards={hazards} compact />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Hull-diagram og avstand til green"
|
||||
className={cn(
|
||||
"flex w-full flex-col gap-4 rounded-2xl border border-clubhouse-border bg-clubhouse-card p-5 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-end justify-between gap-2">
|
||||
<NumberColumn label="Front" value={distances.front} />
|
||||
<NumberColumn label="Senter" value={distances.middle} emphasized />
|
||||
<NumberColumn label="Bak" value={distances.back} />
|
||||
</div>
|
||||
|
||||
<Lane playerPosition={playerPosition} hazards={hazards} />
|
||||
|
||||
{hazards.length > 0 && (
|
||||
<div className="border-t border-clubhouse-border pt-3">
|
||||
<HazardTextList hazards={hazards} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const LANE_HEIGHT_PX = 260
|
||||
// Faktisk fairway-bredde er ikke kjent fra dataen -- klipper visuelt til
|
||||
// ±30 m fra midtlinjen, en fornuftig typisk verdi (se ADR-083). Selve
|
||||
// TALLENE (distanceMeters) er alltid nøyaktige uansett -- kun selve
|
||||
// PLASSERINGEN i tegningen klippes.
|
||||
const MAX_CROSS_METERS = 30
|
||||
|
||||
function clampAlong(percent: number): number {
|
||||
return Math.min(100, Math.max(0, percent))
|
||||
}
|
||||
|
||||
function crossToOffsetPercent(crossMeters: number): number {
|
||||
const clamped = Math.min(MAX_CROSS_METERS, Math.max(-MAX_CROSS_METERS, crossMeters))
|
||||
return (clamped / MAX_CROSS_METERS) * 42 // maks ±42% fra senter, litt luft i kantene
|
||||
}
|
||||
|
||||
function Lane({
|
||||
playerPosition,
|
||||
hazards,
|
||||
}: {
|
||||
playerPosition: { alongPercent: number; crossMeters: number }
|
||||
hazards: DiagramHazard[]
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="relative w-full overflow-hidden rounded-xl border border-clubhouse-border bg-clubhouse-field"
|
||||
style={{ height: LANE_HEIGHT_PX }}
|
||||
>
|
||||
{/* Midtlinje -- ren visuell referanse, ikke en ekte fairway-kant. */}
|
||||
<div className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-clubhouse-border" aria-hidden="true" />
|
||||
|
||||
{/* Green -- fast øverst. */}
|
||||
<div className="absolute left-1/2 top-2 flex -translate-x-1/2 flex-col items-center gap-0.5" aria-hidden="true">
|
||||
<Flag className="size-5 text-tee-strong" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide text-clubhouse-muted">Green</span>
|
||||
</div>
|
||||
|
||||
{/* Tee -- fast nederst. */}
|
||||
<div className="absolute bottom-2 left-1/2 flex -translate-x-1/2 flex-col items-center gap-0.5" aria-hidden="true">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide text-clubhouse-muted">Tee</span>
|
||||
<span className="size-2.5 rounded-full bg-clubhouse-muted" />
|
||||
</div>
|
||||
|
||||
{/* Hindringer, plassert langs+til side av aksen. */}
|
||||
{hazards.map((h, i) => (
|
||||
<div
|
||||
key={`${h.label}-${i}`}
|
||||
className="absolute flex -translate-x-1/2 translate-y-1/2 flex-col items-center gap-0.5"
|
||||
style={{
|
||||
bottom: `${clampAlong(h.alongPercent)}%`,
|
||||
left: `calc(50% + ${crossToOffsetPercent(h.crossMeters)}%)`,
|
||||
}}
|
||||
>
|
||||
<span className="size-2.5 rounded-full border-2 border-cup-strong bg-clubhouse-card" aria-hidden="true" />
|
||||
<span className="whitespace-nowrap rounded-md bg-clubhouse-card px-1 text-[10px] font-bold tabular-nums text-cup-strong shadow-sm">
|
||||
{h.distanceMeters}m
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Spilleren -- "du er her". */}
|
||||
<div
|
||||
className="absolute -translate-x-1/2 translate-y-1/2"
|
||||
style={{
|
||||
bottom: `${clampAlong(playerPosition.alongPercent)}%`,
|
||||
left: `calc(50% + ${crossToOffsetPercent(playerPosition.crossMeters)}%)`,
|
||||
}}
|
||||
>
|
||||
<MapPin aria-label="Din posisjon" className="size-6 fill-tee-strong text-clubhouse-card" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NumberColumn({
|
||||
label,
|
||||
value,
|
||||
emphasized,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
emphasized?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-end gap-0.5">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-clubhouse-muted">{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-baseline gap-0.5 leading-none tabular-nums",
|
||||
emphasized ? "text-6xl font-black text-tee-strong" : "text-4xl font-bold text-clubhouse-ink",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
<span className="text-sm font-semibold text-clubhouse-muted">m</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HazardTextList({ hazards, compact }: { hazards: DiagramHazard[]; compact?: boolean }) {
|
||||
if (hazards.length === 0) return null
|
||||
return (
|
||||
<ul className={cn("flex flex-col", compact ? "gap-0.5" : "gap-1")}>
|
||||
{hazards.map((h, i) => (
|
||||
<li
|
||||
key={`${h.label}-${i}`}
|
||||
className={cn("font-medium text-cup-strong", compact ? "text-[11px]" : "text-sm")}
|
||||
>
|
||||
{h.label}: <span className="tabular-nums font-bold">{h.distanceMeters}</span> m
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
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
|
||||
|
|
@ -152,18 +154,57 @@ export function HoleTargetDistance({
|
|||
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) {
|
||||
// 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
|
||||
nearestHazard = { label, distanceMeters: Math.round(d) }
|
||||
}
|
||||
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 = hazards.map((h) => ({
|
||||
...h,
|
||||
...projectOntoAxis(teeRef, greenRef, { lat: h.lat, lng: h.lng }),
|
||||
}))
|
||||
return (
|
||||
<HoleDiagram
|
||||
size={size}
|
||||
className={className}
|
||||
distances={distances}
|
||||
playerPosition={player}
|
||||
hazards={diagramHazards}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TargetDistance size={size} distances={distances} nearestHazard={nearestHazard} className={className} />
|
||||
<TargetDistance size={size} distances={distances} hazards={hazards} className={className} />
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,16 +16,18 @@ export type TargetDistanceProps = {
|
|||
middle: number
|
||||
back: number
|
||||
} | null // null = ingen koordinatdata for dette hullet (normal, forventet tilstand)
|
||||
nearestHazard?: {
|
||||
/** ALLE hindringer på hullet (ikke bare nærmeste, se ADR-083), sortert
|
||||
* nærmest spilleren først -- kalleren styrer rekkefølgen. */
|
||||
hazards?: {
|
||||
label: string // f.eks. "Bunker (front)"
|
||||
distanceMeters: number
|
||||
} | null
|
||||
}[]
|
||||
/** "compact" = badge ved siden av GIR-merket, "full" = eget panel. */
|
||||
size?: "compact" | "full"
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Hindring-linje: kun synlig når det finnes en relevant hindring. Oransje varsel, ikke rødt. */
|
||||
/** Én hindring-linje. Oransje varsel, ikke rødt. */
|
||||
function HazardLine({
|
||||
hazard,
|
||||
compact,
|
||||
|
|
@ -48,6 +50,24 @@ function HazardLine({
|
|||
)
|
||||
}
|
||||
|
||||
/** Alle hindringer, stablet -- erstatter den gamle "kun nærmeste"-visningen. */
|
||||
function HazardList({
|
||||
hazards,
|
||||
compact,
|
||||
}: {
|
||||
hazards: { label: string; distanceMeters: number }[]
|
||||
compact?: boolean
|
||||
}) {
|
||||
if (hazards.length === 0) return null
|
||||
return (
|
||||
<div className={cn("flex flex-col", compact ? "gap-0.5" : "gap-1")}>
|
||||
{hazards.map((h, i) => (
|
||||
<HazardLine key={`${h.label}-${i}`} hazard={h} compact={compact} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** En avstandskolonne (Front/Midt/Bak). Alltid tekst-label + tall + enhet. */
|
||||
function DistanceColumn({
|
||||
label,
|
||||
|
|
@ -117,7 +137,7 @@ function NoData({ compact, className }: { compact?: boolean; className?: string
|
|||
|
||||
export function TargetDistance({
|
||||
distances,
|
||||
nearestHazard,
|
||||
hazards = [],
|
||||
size = "full",
|
||||
className,
|
||||
}: TargetDistanceProps) {
|
||||
|
|
@ -141,11 +161,7 @@ export function TargetDistance({
|
|||
<DistanceColumn label="Senter" value={distances.middle} emphasized compact />
|
||||
<DistanceColumn label="Bak" value={distances.back} compact />
|
||||
</div>
|
||||
{nearestHazard ? (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<HazardLine hazard={nearestHazard} compact />
|
||||
</div>
|
||||
) : null}
|
||||
<HazardList hazards={hazards} compact />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -166,11 +182,11 @@ export function TargetDistance({
|
|||
<DistanceColumn label="Bak" value={distances.back} />
|
||||
</div>
|
||||
|
||||
{nearestHazard ? (
|
||||
{hazards.length > 0 && (
|
||||
<div className="border-t border-clubhouse-border pt-3">
|
||||
<HazardLine hazard={nearestHazard} />
|
||||
<HazardList hazards={hazards} />
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
80
frontend/lib/hole-geometry.test.ts
Normal file
80
frontend/lib/hole-geometry.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
import { midpoint, projectOntoAxis } from "./hole-geometry"
|
||||
|
||||
// Testrigg: en rein nord-sør-akse (tee i sør, green 200 m rett nord) --
|
||||
// lett å regne fasit for. 111_320 m per breddegrad er samme tilnærming
|
||||
// som geo.test.ts allerede bruker.
|
||||
const METERS_PER_DEGREE_LAT = 111_320
|
||||
const tee = { lat: 59.9, lng: 10.75 }
|
||||
const green = { lat: 59.9 + 200 / METERS_PER_DEGREE_LAT, lng: 10.75 }
|
||||
|
||||
describe("projectOntoAxis", () => {
|
||||
it("punktet på start (tee) gir alongPercent=0, crossMeters≈0", () => {
|
||||
const { alongPercent, crossMeters } = projectOntoAxis(tee, green, tee)
|
||||
expect(alongPercent).toBeCloseTo(0, 3)
|
||||
expect(crossMeters).toBeCloseTo(0, 3)
|
||||
})
|
||||
|
||||
it("punktet på slutt (green) gir alongPercent=100, crossMeters≈0", () => {
|
||||
const { alongPercent, crossMeters } = projectOntoAxis(tee, green, green)
|
||||
expect(alongPercent).toBeCloseTo(100, 0)
|
||||
expect(crossMeters).toBeCloseTo(0, 1)
|
||||
})
|
||||
|
||||
it("midtpunktet på aksen gir alongPercent≈50, crossMeters≈0", () => {
|
||||
const mid = midpoint(tee, green)
|
||||
const { alongPercent, crossMeters } = projectOntoAxis(tee, green, mid)
|
||||
expect(alongPercent).toBeCloseTo(50, 0)
|
||||
expect(crossMeters).toBeCloseTo(0, 1)
|
||||
})
|
||||
|
||||
it("et punkt rett øst for midtpunktet gir riktig crossMeters mot høyre", () => {
|
||||
// Aksen peker rett nord (bearing 0) -- et punkt rett øst for et punkt
|
||||
// PÅ aksen skal derfor ligge til HØYRE (positiv cross), på samme
|
||||
// langs-posisjon (alongPercent uendret).
|
||||
const mid = midpoint(tee, green)
|
||||
const offsetEast = { lat: mid.lat, lng: mid.lng + 20 / (METERS_PER_DEGREE_LAT * Math.cos((mid.lat * Math.PI) / 180)) }
|
||||
const { alongPercent, crossMeters } = projectOntoAxis(tee, green, offsetEast)
|
||||
expect(alongPercent).toBeCloseTo(50, 0)
|
||||
expect(crossMeters).toBeGreaterThan(19)
|
||||
expect(crossMeters).toBeLessThan(21)
|
||||
})
|
||||
|
||||
it("et punkt rett vest for midtpunktet gir negativ crossMeters (venstre)", () => {
|
||||
const mid = midpoint(tee, green)
|
||||
const offsetWest = { lat: mid.lat, lng: mid.lng - 20 / (METERS_PER_DEGREE_LAT * Math.cos((mid.lat * Math.PI) / 180)) }
|
||||
const { crossMeters } = projectOntoAxis(tee, green, offsetWest)
|
||||
expect(crossMeters).toBeLessThan(-19)
|
||||
expect(crossMeters).toBeGreaterThan(-21)
|
||||
})
|
||||
|
||||
it("et punkt bak tee (før start) gir negativ alongPercent", () => {
|
||||
const behindTee = { lat: tee.lat - 50 / METERS_PER_DEGREE_LAT, lng: tee.lng }
|
||||
const { alongPercent } = projectOntoAxis(tee, green, behindTee)
|
||||
expect(alongPercent).toBeLessThan(0)
|
||||
})
|
||||
|
||||
it("et punkt forbi green gir alongPercent > 100", () => {
|
||||
const beyondGreen = { lat: green.lat + 50 / METERS_PER_DEGREE_LAT, lng: green.lng }
|
||||
const { alongPercent } = projectOntoAxis(tee, green, beyondGreen)
|
||||
expect(alongPercent).toBeGreaterThan(100)
|
||||
})
|
||||
|
||||
it("axisStart == axisEnd degraderer grasiøst (ingen NaN/deling på null)", () => {
|
||||
const { alongPercent, crossMeters } = projectOntoAxis(tee, tee, green)
|
||||
expect(Number.isFinite(alongPercent)).toBe(true)
|
||||
expect(Number.isFinite(crossMeters)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("midpoint", () => {
|
||||
it("midtpunktet av et punkt med seg selv er punktet selv", () => {
|
||||
expect(midpoint(tee, tee)).toEqual(tee)
|
||||
})
|
||||
|
||||
it("midtpunktet ligger halvveis mellom to punkt", () => {
|
||||
const m = midpoint(tee, green)
|
||||
expect(m.lat).toBeCloseTo((tee.lat + green.lat) / 2, 10)
|
||||
expect(m.lng).toBeCloseTo((tee.lng + green.lng) / 2, 10)
|
||||
})
|
||||
})
|
||||
39
frontend/lib/hole-geometry.ts
Normal file
39
frontend/lib/hole-geometry.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { type LatLng, bearingDegrees, haversineMeters } from "./geo"
|
||||
|
||||
/**
|
||||
* Projiserer et vilkårlig punkt ned på en fast akse (typisk tee->green),
|
||||
* for et hull-diagram der "langs hullet" og "til siden" skal vises
|
||||
* uavhengig av spillerens gangretning -- ren klientberegning, ingen
|
||||
* Mapbox-kall (samme ADR-048-kostnadskontroll-prinsipp som geo.ts).
|
||||
*
|
||||
* `alongPercent`: 0 ved axisStart, 100 ved axisEnd -- IKKE klippet her
|
||||
* (kan bli <0 eller >100 hvis punktet ligger bak start/forbi slutt),
|
||||
* klipping skjer kun ved selve visningen.
|
||||
* `crossMeters`: avstand til siden av aksen. Positiv = til høyre for
|
||||
* aksens retning (sett fra axisStart mot axisEnd), negativ = venstre --
|
||||
* samme klokke-positive konvensjon som bearingDegrees.
|
||||
*/
|
||||
export function projectOntoAxis(
|
||||
axisStart: LatLng,
|
||||
axisEnd: LatLng,
|
||||
point: LatLng,
|
||||
): { alongPercent: number; crossMeters: number } {
|
||||
const axisLength = haversineMeters(axisStart, axisEnd)
|
||||
if (axisLength === 0) {
|
||||
return { alongPercent: 0, crossMeters: haversineMeters(axisStart, point) }
|
||||
}
|
||||
const axisBearing = bearingDegrees(axisStart, axisEnd)
|
||||
const pointDistance = haversineMeters(axisStart, point)
|
||||
const pointBearing = bearingDegrees(axisStart, point)
|
||||
// Normaliser vinkeldifferansen til (-180, 180] før trigonometri.
|
||||
const angleDiff = (((pointBearing - axisBearing + 540) % 360) - 180) * (Math.PI / 180)
|
||||
const along = pointDistance * Math.cos(angleDiff)
|
||||
const cross = pointDistance * Math.sin(angleDiff)
|
||||
return { alongPercent: (along / axisLength) * 100, crossMeters: cross }
|
||||
}
|
||||
|
||||
/** Midtpunkt mellom to koordinater (enkel plan-tilnærming -- presist nok
|
||||
* på hull-skala, samme antakelse som resten av geo.ts). */
|
||||
export function midpoint(a: LatLng, b: LatLng): LatLng {
|
||||
return { lat: (a.lat + b.lat) / 2, lng: (a.lng + b.lng) / 2 }
|
||||
}
|
||||
Loading…
Reference in a new issue