From 6f79df6eab5cb47f192f5ed450afc449ec8df226 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Mon, 17 Aug 2026 16:46:50 +0200 Subject: [PATCH] =?UTF-8?q?Visuelt=20hull-diagram=20for=20rangefinderen=20?= =?UTF-8?q?(tee=20nederst,=20green=20=C3=B8verst)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ARCHITECTURE_DECISIONS.md | 98 +++++++++ CHANGELOG.md | 44 +++++ frontend/components/hole-diagram-view.tsx | 197 +++++++++++++++++++ frontend/components/hole-target-distance.tsx | 59 +++++- frontend/components/target-distance.tsx | 40 ++-- frontend/lib/hole-geometry.test.ts | 80 ++++++++ frontend/lib/hole-geometry.ts | 39 ++++ 7 files changed, 536 insertions(+), 21 deletions(-) create mode 100644 frontend/components/hole-diagram-view.tsx create mode 100644 frontend/lib/hole-geometry.test.ts create mode 100644 frontend/lib/hole-geometry.ts 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. */} + + ) +} + +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 } +}