diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md
index 91f022a..45103b2 100644
--- a/ARCHITECTURE_DECISIONS.md
+++ b/ARCHITECTURE_DECISIONS.md
@@ -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)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ec46f83..69f3a20 100644
--- a/CHANGELOG.md
+++ b/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.
diff --git a/frontend/components/hole-diagram-view.tsx b/frontend/components/hole-diagram-view.tsx
new file mode 100644
index 0000000..3813cce
--- /dev/null
+++ b/frontend/components/hole-diagram-view.tsx
@@ -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 (
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ {hazards.length > 0 && (
+
+
+
+ )}
+
+ )
+}
+
+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 (
+
+ {/* Midtlinje -- ren visuell referanse, ikke en ekte fairway-kant. */}
+
+
+ {/* Green -- fast øverst. */}
+
+
+ Green
+
+
+ {/* Tee -- fast nederst. */}
+
+ Tee
+
+
+
+ {/* Hindringer, plassert langs+til side av aksen. */}
+ {hazards.map((h, i) => (
+
+
+
+ {h.distanceMeters}m
+
+
+ ))}
+
+ {/* Spilleren -- "du er her". */}
+
+
+
+
+ )
+}
+
+function NumberColumn({
+ label,
+ value,
+ emphasized,
+}: {
+ label: string
+ value: number
+ emphasized?: boolean
+}) {
+ return (
+
+ {label}
+
+ {value}
+ m
+
+
+ )
+}
+
+function HazardTextList({ hazards, compact }: { hazards: DiagramHazard[]; compact?: boolean }) {
+ if (hazards.length === 0) return null
+ return (
+
+ {hazards.map((h, i) => (
+ -
+ {h.label}: {h.distanceMeters} m
+
+ ))}
+
+ )
+}
diff --git a/frontend/components/hole-target-distance.tsx b/frontend/components/hole-target-distance.tsx
index 6ad3a64..1362dfb 100644
--- a/frontend/components/hole-target-distance.tsx
+++ b/frontend/components/hole-target-distance.tsx
@@ -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 (
+
+ )
}
return (
-
+
)
}
diff --git a/frontend/components/target-distance.tsx b/frontend/components/target-distance.tsx
index edc106e..d488624 100644
--- a/frontend/components/target-distance.tsx
+++ b/frontend/components/target-distance.tsx
@@ -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 (
+
+ {hazards.map((h, i) => (
+
+ ))}
+
+ )
+}
+
/** 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({
- {nearestHazard ? (
-
-
-
- ) : null}
+
)
}
@@ -166,11 +182,11 @@ export function TargetDistance({
- {nearestHazard ? (
+ {hazards.length > 0 && (
-
+
- ) : null}
+ )}
)
}
diff --git a/frontend/lib/hole-geometry.test.ts b/frontend/lib/hole-geometry.test.ts
new file mode 100644
index 0000000..72a9ea8
--- /dev/null
+++ b/frontend/lib/hole-geometry.test.ts
@@ -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)
+ })
+})
diff --git a/frontend/lib/hole-geometry.ts b/frontend/lib/hole-geometry.ts
new file mode 100644
index 0000000..c4cef0d
--- /dev/null
+++ b/frontend/lib/hole-geometry.ts
@@ -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 }
+}