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:
Erol Haagenrud 2026-08-17 16:46:50 +02:00
parent 206aaa6217
commit 6f79df6eab
7 changed files with 536 additions and 21 deletions

View file

@ -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)

View file

@ -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.

View 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>
)
}

View file

@ -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} />
)
}

View file

@ -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>
)
}

View 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)
})
})

View file

@ -0,0 +1,39 @@
import { type LatLng, bearingDegrees, haversineMeters } from "./geo"
/**
* Projiserer et vilkårlig punkt ned 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
* 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 }
}