From 84f11e1a45a3a13c7b15a0ab4507f2821852f42d Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Mon, 17 Aug 2026 23:36:28 +0200 Subject: [PATCH] =?UTF-8?q?Hull-diagram=20v2:=20kategoriske=20venstre/sent?= =?UTF-8?q?er/h=C3=B8yre-akser=20(ADR-084)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Erstatter ADR-083 sin kontinuerlige crossMeters-forskyvning, som ga for liten synlig venstre/høyre-forskjell på ekte banedata (Tjøme hull 18). Baner velges nå kategorisk via side_fairway; green/tee/spiller alltid på senterakse. Ny hazard_group-kolonne (migrasjon 080, ikke rullet ut mot ekte DB ennå) lar to punkter eksplisitt pares til én carry-hindring (forkant+bakkant) uten å risikere å slå sammen to atskilte hindringer på samme side. Co-Authored-By: Claude Sonnet 5 --- 080_target_point_hazard_group.sql | 32 ++ ARCHITECTURE_DECISIONS.md | 91 +++++ CHANGELOG.md | 65 ++++ app/routers/courses.py | 14 +- app/target_points.py | 5 +- frontend/components/hole-diagram-view.tsx | 320 +++++++++--------- frontend/components/hole-target-distance.tsx | 95 +++++- frontend/public/hole-diagram/green-40.png | Bin 0 -> 4222 bytes frontend/public/hole-diagram/hazard-sand.png | Bin 0 -> 2985 bytes frontend/public/hole-diagram/hazard-water.png | Bin 0 -> 2923 bytes 10 files changed, 443 insertions(+), 179 deletions(-) create mode 100644 080_target_point_hazard_group.sql create mode 100644 frontend/public/hole-diagram/green-40.png create mode 100644 frontend/public/hole-diagram/hazard-sand.png create mode 100644 frontend/public/hole-diagram/hazard-water.png diff --git a/080_target_point_hazard_group.sql b/080_target_point_hazard_group.sql new file mode 100644 index 0000000..9eb4410 --- /dev/null +++ b/080_target_point_hazard_group.sql @@ -0,0 +1,32 @@ +-- ===================================================================== +-- TeeCup — migrasjon 080 +-- hazard_group: eksplisitt paring av forkant/bakkant til SAMME hindring +-- ===================================================================== +-- Konkret anledning (ADR-084): hull-diagrammet for rangefinderen +-- (ADR-083) trengte en måte å vise ÉN hindring med både forkant- og +-- bakkant-avstand (f.eks. en bunker sin fremre og bakre kant), i +-- stedet for å vise hvert koordinatpunkt som sin egen markør. Første +-- forsøk grupperte punkter på `poi_type`+`side_fairway` -- avvist av +-- bruker: to ATSKILTE hindringer av samme type på samme side (f.eks. +-- to venstre fairway-bunkere) ville da feilaktig blitt slått sammen +-- til én. +-- +-- Verken `golfapi_course_coordinate` eller `teeoff_course_coordinate` +-- hadde noen eksisterende gruppe-/hindrings-id å bruke i stedet -- +-- `id`-kolonnen er en usorterbar `gen_random_uuid()`, ingen +-- `created_at`, og verken GolfAPI sin kildedata eller den manuelle +-- feltbefarings-flyten (ADR-081) har noe naturlig gruppe-nøkkel. +-- +-- `hazard_group` er derfor en NY, nullable, FRITT SATT tekstkolonne -- +-- samme "menneskelig tolkning ved data-inntasting"-mønster som +-- `poi_type`/`location` allerede er. `NULL` (dagens tilstand for ALT +-- eksisterende data) = enkeltstående punkt, ALDRI slått sammen med +-- noe -- ingen regresjon. Ikke-null = et menneske har eksplisitt +-- bekreftet at to (eller flere) punkter er samme fysiske hindring. +-- Kun meningsfullt unikt INNENFOR ett hull, ikke globalt håndhevet. +-- ===================================================================== + +\set ON_ERROR_STOP on + +ALTER TABLE golfapi_course_coordinate ADD COLUMN hazard_group text; +ALTER TABLE teeoff_course_coordinate ADD COLUMN hazard_group text; diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 3767bf5..e077f7d 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -8363,6 +8363,97 @@ containerlogg, `https://teecup.golf/logg-inn` bekreftet 200 OK. --- +## ADR-084: Hull-diagram v2 -- kategoriske venstre/senter/høyre-akser med egne ikoner (2026-08-17) + +Revisjon av ADR-083 SAMME DAG, etter reelt produksjonsskjermbilde fra +Tjøme hull 18: både bunkeren og vannhinderet havnet visuelt nesten +rett under senterlinjen i ADR-083 sin KONTINUERLIGE +venstre/høyre-projeksjon (`crossMeters`), selv om bunkeren faktisk ligger +til venstre og vannet er senter. Årsak: det faktiske sideveis-avviket i +meter var for lite til synlig forskjell på ekte banedata -- geometrisk +riktig, men ikke lesbart i sollys. + +**Ny løsning, bekreftet med bruker over flere spørsmål/svar-runder:** +- Tre FASTE kategoriske "baner" (venstre/senter/høyre), drevet av + `side_fairway`-feltet direkte (allerede hentet, men ikke brukt før nå) + -- IKKE av utledet `crossMeters`. Garantert tydelig atskillelse + uansett faktisk avstand. +- Green og tee er ALLTID på midtaksen, uansett egen `side_fairway`. + Spillerens "Deg"-posisjon likeså -- ingen kategorisk side for GPS. +- Hindringer med et ekte forkant+bakkant-par vises som ETT ikon: + avstand til forkant rett UNDER, avstand til bakkant rett OVER + (speiler green sitt front/senter/bak). Enkeltpunkt: én avstand, + alltid under. +- Egne PNG-ikoner (bruker lastet opp, 32×31 sand/vann, 40×40 green) i + `frontend/public/hole-diagram/` -- IKKE de tre opprinnelig opplastede + landskaps-SVG-ene (fulle illustrerte banekart, 237-495 path-elementer + hver), som ble vurdert og avvist: for detaljerte til å være lesbare + nedskalert til 32-40px, ville brutt CLAUDE.md sin STÅENDE + tilgjengelighetsregel. Typer uten dedikert ikon (i dag kun `rock`) + faller tilbake til lucide sin `TriangleAlert`. + +**Viktig rettelse fra bruker underveis (endret datalaget):** første +forsøk grupperte hindringspunkter til forkant+bakkant-par basert på +`poi_type`+`side_fairway` -- avvist: to ATSKILTE hindringer av samme +type på samme side (f.eks. to venstre fairway-bunkere) ville da +feilaktig blitt slått sammen. Undersøkt: verken `golfapi_course_ +coordinate` eller `teeoff_course_coordinate` hadde noen eksisterende +gruppe-/hindrings-id (kun en usorterbar `gen_random_uuid()`, ingen +`created_at`), og verken GolfAPI sin kildedata eller den manuelle +feltbefarings-flyten (ADR-081) har noe naturlig gruppe-nøkkel. + +**Ny migrasjon 080:** `hazard_group text` (nullable) lagt til begge +tabeller. `NULL` (dagens tilstand for ALT eksisterende data, inkl. +Tjøme/Nesbyen) = enkeltstående punkt, ALDRI slått sammen -- ingen +regresjon. Ikke-null = et menneske har eksplisitt bekreftet at to +punkter er samme fysiske hindring, satt ved data-inntasting (samme +tillitsnivå som `poi_type`/`location` i ADR-081). Eksponert i +`app/target_points.py` (begge SELECT-lister) og +`app/routers/courses.py` (`CoursePointIn`/`CoursePointOut`, +`_COORDINATE_COLUMNS`, INSERT i `replace_course_coordinates` -- eneste +skrive-vei, kun for offisielle TeeOff-baner; `golfapi_course_ +coordinate`, Tjømes personlige bane, har ingen skrive-endepunkt i det +hele tatt, kun engangsskript). + +**Kjent, akseptert etterfølger-oppgave:** eksisterende Tjøme/Nesbyen- +data har INGEN `hazard_group` satt ennå -- bunkeren+vannet fra +skjermbildet vises derfor fortsatt som enkeltpunkt (uendret, korrekt +oppførsel) inntil noen manuelt tagger dem like. + +**Frontend:** `hole-target-distance.tsx` fikk `groupDiagramHazards()` +som erstatter den gamle per-punkt-mappingen -- grupperer UTELUKKENDE på +delt, ikke-null `hazard_group`. `hole-diagram-view.tsx` skrevet om fra +kontinuerlig `crossToLeftPct`/`MAX_CROSS_METERS`-logikk til +`grid-cols-3`-layout med tre uavhengige baner, hver med egen +kollisjons-forskyvning. `TargetDistance`-fallback (baner uten +tee-koordinater) er UENDRET. + +**Verifisert:** `tsc --noEmit` rent, full `vitest run` 55/55, full +`pytest` 111/111 (migrasjon 080 kjørt problemfritt i scratch-testsuiten +sin egen per-kjøring-database). Egen scratch-database+scratch +`teecup_api` (rebygd lokalt bilde for å inkludere backend-endringene) ++ lokal `next dev`, presist kjent syntetisk datasett: to ATSKILTE +venstre fairway-bunkere (én paret via `hazard_group`, én enkeltstående) +-- bekreftet TO atskilte ikoner, IKKE slått sammen, riktig +forkant(54m)/bakkant(63m) på den parede, riktig enkelttall(103m) på +den enkeltstående. Vannhinder (senter, enkeltpunkt, 130m) og rock +(høyre, generic-ikon, 18m) begge riktig plassert og med riktig +enkelttall. Green(40px)/tee bekreftet alltid midtbane. Lys+mørk +bekreftet. Scratch-stacken fullstendig revet ned. + +**Migrasjon 080 rullet ut mot ekte database:** ikke ennå -- venter på +bekreftelse fra bruker (eksakt `ALTER TABLE`-SQL vist FØR kjøring, per +CLAUDE.md sin ufravikelige sikkerhetsregel). + +**Gjenstår:** liste over `_HAZARD_CONFIG`-typer uten eget ikon leveres +til bruker etter utrulling (i dag kun `rock`/"Fjellknaus"). V0-prompt +for polert visning skrives etter at bruker har godkjent den +hånd-kodede versjonen. + +**Rullet ut: ikke ennå** -- venter på migrasjonsbekreftelse (se over). + +--- + ## Utviklingsplan (rekkefølge) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42c4a75..5ced362 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12042,3 +12042,68 @@ Neste steg: **Rullet ut 2026-08-17** -- bruker bekreftet. `docker compose build teecup_frontend && up -d`, ingen migrasjon. Ren containerlogg, `https://teecup.golf/logg-inn` 200 OK. + +101. **Hull-diagram v2: kategoriske akser + hazard_group (ADR-084) — + 2026-08-17.** Bruker viste et ekte skjermbilde fra Tjøme hull 18: + ADR-083 sin kontinuerlige `crossMeters`-forskyvning ga for lite + synlig venstre/høyre-forskjell på ekte koordinater -- bunker + (faktisk venstre) og vann (faktisk senter) havnet begge nesten på + senterlinjen. Løsning: tre FASTE kategoriske baner + (venstre/senter/høyre) drevet direkte av `side_fairway`, ikke av + utledet `crossMeters`. Green og tee alltid senterakse (uansett egen + `side_fairway`); spiller ("Deg") også alltid senterakse, kun + langs-hull-posisjon. Carry-hindringer (front+bak-par) -> ett ikon, + forkant under/bakkant over; enkeltpunkt -> én avstand under. + + Første forsøk grupperte forkant/bakkant via `poi_type`+ + `side_fairway`-heuristikk -- bruker avviste eksplisitt: to atskilte + hindringer av samme type på samme side (f.eks. to venstre-bunkere) + må ALDRI slås sammen. Undersøkt: ingen eksisterende gruppe-/ + hindrings-id fantes i verken `golfapi_course_coordinate` eller + `teeoff_course_coordinate`. Løst med ny migrasjon 080: + `hazard_group text` (nullable) på begge tabeller -- `NULL` (alt + eksisterende data i dag) = alltid enkeltstående, aldri slått + sammen; ikke-null = eksplisitt menneskelig bekreftet pardata, satt + ved data-inntasting (samme tillitsnivå som `poi_type`/`location`, + ADR-081). Eksponert i `app/target_points.py` og + `app/routers/courses.py` (`CoursePointIn`/`Out`, + `_COORDINATE_COLUMNS`, INSERT i `replace_course_coordinates` -- + eneste skrive-vei, kun offisielle TeeOff-baner; Tjømes personlige + `golfapi_course_coordinate`-data har ingen skrive-endepunkt, kun + engangsskript). + + Nye ikoner fra bruker (PNG, 32×31 sand/vann, 40×40 green) i + `frontend/public/hole-diagram/` -- IKKE de tre opplastede + landskaps-SVG-ene (fulle illustrerte banekart, 237-495 path- + elementer hver), avvist som for detaljerte til å være lesbare + nedskalert til 32-40px (CLAUDE.md sin ståenede tilgjengelighets- + regel). Typer uten dedikert ikon (i dag kun `rock`) faller tilbake + til `TriangleAlert`. + + `hole-target-distance.tsx` fikk `groupDiagramHazards()` (grupperer + UTELUKKENDE på delt, ikke-null `hazard_group`) som erstatter den + gamle per-punkt-mappingen til `HoleDiagram`; den flate `hazards`- + listen til `TargetDistance`-fallback (baner uten tee-koordinater) + er uendret. `hole-diagram-view.tsx` skrevet om fra kontinuerlig + `crossToLeftPct` til `grid-cols-3` med tre uavhengige baner, hver + med egen kollisjons-forskyvning. + + **Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55, `pytest` + 111/111 (migrasjon 080 kjørt i scratch-testsuiten). Egen scratch- + database+rebygd scratch `teecup_api`+lokal `next dev`, presist + kjent syntetisk datasett med to ATSKILTE venstre fairway-bunkere + (én paret via `hazard_group`, én enkeltstående) -- bekreftet TO + atskilte ikoner (ikke slått sammen), riktig forkant(54m)/ + bakkant(63m) på den parede, riktig enkelttall(103m) på den + enkeltstående. Vannhinder (senter, 130m) og rock (høyre, generic- + ikon, 18m) riktig plassert. Green/tee bekreftet alltid midtbane. + Lys+mørk bekreftet. Scratch-stacken revet ned. + + **Ingen migrasjon rullet ut mot ekte database ennå** -- venter på + eksplisitt brukerbekreftelse av migrasjon 080 mot ekte `teecup_db`, + per CLAUDE.md sin ufravikelige sikkerhetsregel. + + **Gjenstår:** liste over `_HAZARD_CONFIG`-typer uten eget ikon + (i dag kun `rock`/"Fjellknaus") leveres til bruker etter utrulling. + V0-prompt for polert visning skrives etter godkjenning av den + hånd-kodede versjonen. diff --git a/app/routers/courses.py b/app/routers/courses.py index 6c58640..58cd699 100644 --- a/app/routers/courses.py +++ b/app/routers/courses.py @@ -636,6 +636,11 @@ class CoursePointIn(BaseModel): side_fairway: str | None = None latitude: float = Field(ge=-90, le=90) longitude: float = Field(ge=-180, le=180) + # Eksplisitt, menneske-satt paring av forkant/bakkant til SAMME + # fysiske hindring (migrasjon 080/ADR-084) -- se hazard_group- + # kommentaren i migrasjonsfilen. Frivillig, ingen validering utover + # tekst -- kun meningsfullt unikt innenfor ett hull. + hazard_group: str | None = None class CoursePointOut(BaseModel): @@ -645,9 +650,10 @@ class CoursePointOut(BaseModel): side_fairway: str | None latitude: float longitude: float + hazard_group: str | None -_COORDINATE_COLUMNS = "hole_number, poi_type, location, side_fairway, latitude, longitude" +_COORDINATE_COLUMNS = "hole_number, poi_type, location, side_fairway, latitude, longitude, hazard_group" @router.get("/orgs/{organization_id}/courses/{course_id}/coordinates", response_model=list[CoursePointOut]) @@ -694,10 +700,10 @@ async def replace_course_coordinates( await conn.execute( """ INSERT INTO teeoff_course_coordinate - (external_course_ref, hole_number, poi_type, location, side_fairway, latitude, longitude) - VALUES ($1, $2, $3, $4, $5, $6, $7) + (external_course_ref, hole_number, poi_type, location, side_fairway, latitude, longitude, hazard_group) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) """, - external_ref, p.hole_number, p.poi_type, p.location, p.side_fairway, p.latitude, p.longitude, + external_ref, p.hole_number, p.poi_type, p.location, p.side_fairway, p.latitude, p.longitude, p.hazard_group, ) rows = await conn.fetch( f""" diff --git a/app/target_points.py b/app/target_points.py index c6f784a..c63aa88 100644 --- a/app/target_points.py +++ b/app/target_points.py @@ -29,6 +29,7 @@ class TargetPoint(BaseModel): side_fairway: str | None latitude: float longitude: float + hazard_group: str | None async def get_target_points(conn: Connection, course_key: CourseKey | None, hole_number: int) -> list[TargetPoint]: @@ -39,7 +40,7 @@ async def get_target_points(conn: Connection, course_key: CourseKey | None, hole _, facility_slug, teeoff_course_id = course_key rows = await conn.fetch( """ - SELECT poi_type, location, side_fairway, latitude, longitude + SELECT poi_type, location, side_fairway, latitude, longitude, hazard_group FROM teeoff_course_coordinate WHERE external_course_ref = $1 AND hole_number = $2 """, @@ -50,7 +51,7 @@ async def get_target_points(conn: Connection, course_key: CourseKey | None, hole _, golfapi_course_id = course_key rows = await conn.fetch( """ - SELECT poi_type, location, side_fairway, latitude, longitude + SELECT poi_type, location, side_fairway, latitude, longitude, hazard_group FROM golfapi_course_coordinate WHERE golfapi_course_id = $1 AND hole_number = $2 """, diff --git a/frontend/components/hole-diagram-view.tsx b/frontend/components/hole-diagram-view.tsx index 787f977..d6bcbba 100644 --- a/frontend/components/hole-diagram-view.tsx +++ b/frontend/components/hole-diagram-view.tsx @@ -1,43 +1,45 @@ "use client" -// V0-eksportert visning (2026-08-17, ADR-083) -- ren kontrollert -// komponent, ingen egen GPS-/geometrilogikk. All geometri-utregning -// (projectOntoAxis) bor i hole-target-distance.tsx, allerede bevist -// riktig med enhetstester + scratch-verifisering FØR denne visningen -// ble bestilt. Erstattet en midlertidig hånd-kodet stand-in med samme -// props-kontrakt -- samme mønster som FlagPlantSheet/PlayerImportPanel. +// Hull-diagram v2 (2026-08-17, ADR-084) -- kategoriske venstre/senter/ +// høyre-akser i stedet for kontinuerlig sideveis-forskyvning (ADR-083). +// Se ADR-084: kontinuerlig `crossMeters` ga for liten synlig forskjell +// på ekte banedata (Tjøme hull 18 -- bunker+vann havnet begge nesten på +// senterlinjen selv om de faktisk ligger på ulik side). Banevalget +// kommer nå fra `hazards[].lane`, satt av datalaget i +// hole-target-distance.tsx basert på `side_fairway` -- IKKE utledet her. +// Ren kontrollert komponent, ingen egen GPS-/geometrilogikk. -import { Flag, MapPin, TriangleAlert } from "lucide-react" +import { MapPin, TriangleAlert } from "lucide-react" import { cn } from "@/lib/utils" +export type HazardKind = "sand" | "water" | "generic" + export type DiagramHazard = { + key: string + kind: HazardKind label: string - /** Live distance FROM THE PLAYER, always accurate. */ - distanceMeters: number - /** 0 = at tee, 100 = at green. May be <0 or >100. */ + lane: "left" | "center" | "right" + /** 0 = ved tee, 100 = ved green. */ alongPercent: number - /** Positive = right of the tee->green axis, negative = left. */ - crossMeters: number + /** Avstand FRA SPILLEREN, vises rett under ikonet. Alltid satt. */ + below: number + /** Avstand til bakkant, vises rett over ikonet -- KUN satt når + * hindringen er et ekte forkant+bakkant-par (se hazard_group i + * datalaget). `null` = enkeltpunkt, ingen tekst over ikonet. */ + above: number | null } type Props = { distances: { front: number; middle: number; back: number } - playerPosition: { alongPercent: number; crossMeters: number } - /** Already sorted nearest-to-player first. */ + playerPosition: { alongPercent: number } hazards: DiagramHazard[] size?: "compact" | "full" className?: string } -// --- Diagram geometry (percentages of the field box) --------------------- -// Fixed endpoints: green stays at the top, tee stays at the bottom, always. const TRACK_TOP = 12 // where alongPercent === 100 lands (leaves room for the flag) const TRACK_BOTTOM = 88 // where alongPercent === 0 lands (leaves room for the tee dot) -// Sideways scale: this many meters of cross offset maps to the max visual swing. -const MAX_CROSS_METERS = 30 -const MAX_OFFSET_PCT = 40 // ±40% of the field width, per spec -// Label collision handling: minimum vertical gap between stacked labels. -const LABEL_MIN_GAP_PCT = 11 +const LABEL_MIN_GAP_PCT = 16 // hindringer i samme bane trenger mer luft nå (ikon + to tall) function clamp(n: number, lo: number, hi: number) { return Math.min(hi, Math.max(lo, n)) @@ -48,26 +50,57 @@ function alongToTopPct(alongPercent: number) { return TRACK_TOP + ((100 - a) / 100) * (TRACK_BOTTOM - TRACK_TOP) } -function crossToLeftPct(crossMeters: number) { - const offset = clamp( - (crossMeters / MAX_CROSS_METERS) * MAX_OFFSET_PCT, - -MAX_OFFSET_PCT, - MAX_OFFSET_PCT, - ) - return 50 + offset -} - function formatMeters(m: number) { return `${Math.round(m)} m` } -export function HoleDiagram({ - distances, - playerPosition, - hazards, - size = "full", - className, -}: Props) { +function HazardIcon({ kind }: { kind: HazardKind }) { + if (kind === "sand") { + return + } + if (kind === "water") { + return + } + return +} + +/** Nudger ikoner i SAMME bane fra øverst til nederst hvis de er for + * nære vertikalt -- samme kollisjons-mønster som før, men kjørt per + * bane siden hindringer nå er spredt over tre baner. */ +function layoutLane(hazards: DiagramHazard[]) { + const sorted = [...hazards].sort((a, b) => alongToTopPct(a.alongPercent) - alongToTopPct(b.alongPercent)) + const placed: (DiagramHazard & { topPct: number })[] = [] + let lastTop = Number.NEGATIVE_INFINITY + for (const h of sorted) { + const desired = alongToTopPct(h.alongPercent) + const topPct = Math.max(desired, lastTop + LABEL_MIN_GAP_PCT) + placed.push({ ...h, topPct }) + lastTop = topPct + } + return placed +} + +function HazardMarker({ hazard, topPct }: { hazard: DiagramHazard; topPct: number }) { + return ( + + ) +} + +export function HoleDiagram({ distances, playerPosition, hazards, size = "full", className }: Props) { if (size === "compact") { return (
{hazards.length > 0 && (
    - {hazards.map((h, i) => ( -
  • -
  • +
  • ))} @@ -101,136 +131,91 @@ export function HoleDiagram({ ) } - // --- Full variant ------------------------------------------------------- - // Precompute placements and a light label-collision nudge (top -> bottom). - const placed = hazards.map((h, i) => ({ - ...h, - key: `${h.label}-${i}`, - topPct: alongToTopPct(h.alongPercent), - leftPct: crossToLeftPct(h.crossMeters), - })) - - const byTop = [...placed].sort((a, b) => a.topPct - b.topPct) - const labelTopByKey = new Map() - let lastLabelTop = Number.NEGATIVE_INFINITY - for (const p of byTop) { - const desired = p.topPct - const labelTop = Math.max(desired, lastLabelTop + LABEL_MIN_GAP_PCT) - labelTopByKey.set(p.key, labelTop) - lastLabelTop = labelTop - } - + const leftHazards = layoutLane(hazards.filter((h) => h.lane === "left")) + const centerHazards = layoutLane(hazards.filter((h) => h.lane === "center")) + const rightHazards = layoutLane(hazards.filter((h) => h.lane === "right")) const playerTop = alongToTopPct(playerPosition.alongPercent) - const playerLeft = crossToLeftPct(playerPosition.crossMeters) return (
    - {/* Distance readout: center emphasized + largest, in the tee-green color. */}
    - {/* The hole diagram. Green fixed top, tee fixed bottom. */}
    - {/* Discreet tee->green centerline */} -