Hull-diagram v2: kategoriske venstre/senter/høyre-akser (ADR-084)

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 <noreply@anthropic.com>
This commit is contained in:
Erol Haagenrud 2026-08-17 23:36:28 +02:00
parent d4f635fcfd
commit 84f11e1a45
10 changed files with 443 additions and 179 deletions

View file

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

View file

@ -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) ## Utviklingsplan (rekkefølge)
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)

View file

@ -12042,3 +12042,68 @@ Neste steg:
**Rullet ut 2026-08-17** -- bruker bekreftet. `docker compose build **Rullet ut 2026-08-17** -- bruker bekreftet. `docker compose build
teecup_frontend && up -d`, ingen migrasjon. Ren containerlogg, teecup_frontend && up -d`, ingen migrasjon. Ren containerlogg,
`https://teecup.golf/logg-inn` 200 OK. `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.

View file

@ -636,6 +636,11 @@ class CoursePointIn(BaseModel):
side_fairway: str | None = None side_fairway: str | None = None
latitude: float = Field(ge=-90, le=90) latitude: float = Field(ge=-90, le=90)
longitude: float = Field(ge=-180, le=180) 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): class CoursePointOut(BaseModel):
@ -645,9 +650,10 @@ class CoursePointOut(BaseModel):
side_fairway: str | None side_fairway: str | None
latitude: float latitude: float
longitude: 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]) @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( await conn.execute(
""" """
INSERT INTO teeoff_course_coordinate INSERT INTO teeoff_course_coordinate
(external_course_ref, hole_number, poi_type, location, side_fairway, latitude, longitude) (external_course_ref, hole_number, poi_type, location, side_fairway, latitude, longitude, hazard_group)
VALUES ($1, $2, $3, $4, $5, $6, $7) 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( rows = await conn.fetch(
f""" f"""

View file

@ -29,6 +29,7 @@ class TargetPoint(BaseModel):
side_fairway: str | None side_fairway: str | None
latitude: float latitude: float
longitude: float longitude: float
hazard_group: str | None
async def get_target_points(conn: Connection, course_key: CourseKey | None, hole_number: int) -> list[TargetPoint]: 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 _, facility_slug, teeoff_course_id = course_key
rows = await conn.fetch( 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 FROM teeoff_course_coordinate
WHERE external_course_ref = $1 AND hole_number = $2 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 _, golfapi_course_id = course_key
rows = await conn.fetch( 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 FROM golfapi_course_coordinate
WHERE golfapi_course_id = $1 AND hole_number = $2 WHERE golfapi_course_id = $1 AND hole_number = $2
""", """,

View file

@ -1,43 +1,45 @@
"use client" "use client"
// V0-eksportert visning (2026-08-17, ADR-083) -- ren kontrollert // Hull-diagram v2 (2026-08-17, ADR-084) -- kategoriske venstre/senter/
// komponent, ingen egen GPS-/geometrilogikk. All geometri-utregning // høyre-akser i stedet for kontinuerlig sideveis-forskyvning (ADR-083).
// (projectOntoAxis) bor i hole-target-distance.tsx, allerede bevist // Se ADR-084: kontinuerlig `crossMeters` ga for liten synlig forskjell
// riktig med enhetstester + scratch-verifisering FØR denne visningen // på ekte banedata (Tjøme hull 18 -- bunker+vann havnet begge nesten på
// ble bestilt. Erstattet en midlertidig hånd-kodet stand-in med samme // senterlinjen selv om de faktisk ligger på ulik side). Banevalget
// props-kontrakt -- samme mønster som FlagPlantSheet/PlayerImportPanel. // 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" import { cn } from "@/lib/utils"
export type HazardKind = "sand" | "water" | "generic"
export type DiagramHazard = { export type DiagramHazard = {
key: string
kind: HazardKind
label: string label: string
/** Live distance FROM THE PLAYER, always accurate. */ lane: "left" | "center" | "right"
distanceMeters: number /** 0 = ved tee, 100 = ved green. */
/** 0 = at tee, 100 = at green. May be <0 or >100. */
alongPercent: number alongPercent: number
/** Positive = right of the tee->green axis, negative = left. */ /** Avstand FRA SPILLEREN, vises rett under ikonet. Alltid satt. */
crossMeters: number 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 = { type Props = {
distances: { front: number; middle: number; back: number } distances: { front: number; middle: number; back: number }
playerPosition: { alongPercent: number; crossMeters: number } playerPosition: { alongPercent: number }
/** Already sorted nearest-to-player first. */
hazards: DiagramHazard[] hazards: DiagramHazard[]
size?: "compact" | "full" size?: "compact" | "full"
className?: string 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_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) 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 LABEL_MIN_GAP_PCT = 16 // hindringer i samme bane trenger mer luft nå (ikon + to tall)
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
function clamp(n: number, lo: number, hi: number) { function clamp(n: number, lo: number, hi: number) {
return Math.min(hi, Math.max(lo, n)) 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) 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) { function formatMeters(m: number) {
return `${Math.round(m)} m` return `${Math.round(m)} m`
} }
export function HoleDiagram({ function HazardIcon({ kind }: { kind: HazardKind }) {
distances, if (kind === "sand") {
playerPosition, return <img src="/hole-diagram/hazard-sand.png" width={32} height={31} alt="" className="block" />
hazards, }
size = "full", if (kind === "water") {
className, return <img src="/hole-diagram/hazard-water.png" width={32} height={31} alt="" className="block" />
}: Props) { }
return <TriangleAlert className="size-7 text-cup-strong" strokeWidth={2.5} />
}
/** 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 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 (
<div
aria-hidden="true"
className="absolute left-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-0.5"
style={{ top: `${topPct}%` }}
>
{hazard.above !== null && (
<span className="whitespace-nowrap rounded-md bg-clubhouse-card px-1.5 py-0.5 text-xs font-bold tabular-nums text-clubhouse-ink shadow-sm ring-1 ring-clubhouse-border">
{formatMeters(hazard.above)}
</span>
)}
<HazardIcon kind={hazard.kind} />
<span className="whitespace-nowrap rounded-md bg-clubhouse-card px-1.5 py-0.5 text-xs font-bold tabular-nums text-clubhouse-ink shadow-sm ring-1 ring-clubhouse-border">
{formatMeters(hazard.below)}
</span>
</div>
)
}
export function HoleDiagram({ distances, playerPosition, hazards, size = "full", className }: Props) {
if (size === "compact") { if (size === "compact") {
return ( return (
<div <div
@ -83,15 +116,12 @@ export function HoleDiagram({
</div> </div>
{hazards.length > 0 && ( {hazards.length > 0 && (
<ul className="flex flex-col gap-1 border-t border-clubhouse-border pt-2"> <ul className="flex flex-col gap-1 border-t border-clubhouse-border pt-2">
{hazards.map((h, i) => ( {hazards.map((h) => (
<li key={`${h.label}-${i}`} className="flex items-center gap-1.5 text-sm"> <li key={h.key} className="flex items-center gap-1.5 text-sm">
<TriangleAlert <TriangleAlert aria-hidden="true" className="size-3.5 shrink-0 text-cup-strong" />
aria-hidden="true"
className="size-3.5 shrink-0 text-cup-strong"
/>
<span className="min-w-0 flex-1 truncate text-clubhouse-muted">{h.label}</span> <span className="min-w-0 flex-1 truncate text-clubhouse-muted">{h.label}</span>
<span className="shrink-0 font-bold tabular-nums text-clubhouse-ink"> <span className="shrink-0 font-bold tabular-nums text-clubhouse-ink">
{formatMeters(h.distanceMeters)} {formatMeters(h.below)}
</span> </span>
</li> </li>
))} ))}
@ -101,61 +131,43 @@ export function HoleDiagram({
) )
} }
// --- Full variant ------------------------------------------------------- const leftHazards = layoutLane(hazards.filter((h) => h.lane === "left"))
// Precompute placements and a light label-collision nudge (top -> bottom). const centerHazards = layoutLane(hazards.filter((h) => h.lane === "center"))
const placed = hazards.map((h, i) => ({ const rightHazards = layoutLane(hazards.filter((h) => h.lane === "right"))
...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<string, number>()
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 playerTop = alongToTopPct(playerPosition.alongPercent) const playerTop = alongToTopPct(playerPosition.alongPercent)
const playerLeft = crossToLeftPct(playerPosition.crossMeters)
return ( return (
<div className={cn("flex w-full flex-col gap-4 text-clubhouse-ink", className)}> <div className={cn("flex w-full flex-col gap-4 text-clubhouse-ink", className)}>
{/* Distance readout: center emphasized + largest, in the tee-green color. */}
<div className="flex items-end justify-center gap-6 tabular-nums"> <div className="flex items-end justify-center gap-6 tabular-nums">
<BigNumber label="Front" value={distances.front} /> <BigNumber label="Front" value={distances.front} />
<BigNumber label="Senter" value={distances.middle} emphasized /> <BigNumber label="Senter" value={distances.middle} emphasized />
<BigNumber label="Bak" value={distances.back} /> <BigNumber label="Bak" value={distances.back} />
</div> </div>
{/* The hole diagram. Green fixed top, tee fixed bottom. */}
<div <div
className="relative h-[24rem] w-full overflow-hidden rounded-2xl border border-clubhouse-border bg-clubhouse-field" className="relative grid h-[24rem] w-full grid-cols-3 overflow-hidden rounded-2xl border border-clubhouse-border bg-clubhouse-field"
role="img" role="img"
aria-label={buildDiagramAria(distances, hazards, playerPosition)} aria-label={buildDiagramAria(distances, hazards, playerPosition)}
> >
{/* Discreet tee->green centerline */} {/* Venstre bane */}
<div <div className="relative">
aria-hidden="true" {leftHazards.map((h) => (
className="absolute top-[10%] bottom-[10%] left-1/2 w-px -translate-x-1/2 bg-clubhouse-border" <HazardMarker key={h.key} hazard={h} topPct={h.topPct} />
/> ))}
</div>
{/* Green marker (fixed top) */} {/* Midtbane -- eneste bane med green/tee/spiller, alltid faste ankre her */}
<div className="relative border-x border-clubhouse-border/60">
<div <div
aria-hidden="true" aria-hidden="true"
className="absolute left-1/2 top-[3%] flex -translate-x-1/2 flex-col items-center gap-0.5" className="absolute left-1/2 top-[3%] flex -translate-x-1/2 flex-col items-center gap-0.5"
> >
<Flag className="size-6 text-tee-strong" strokeWidth={2.5} /> <img src="/hole-diagram/green-40.png" width={40} height={40} alt="" className="block" />
<span className="rounded bg-clubhouse-card px-1.5 py-0.5 text-xs font-bold text-tee-strong"> <span className="rounded bg-clubhouse-card px-1.5 py-0.5 text-xs font-bold text-tee-strong">
Green Green
</span> </span>
</div> </div>
{/* Tee marker (fixed bottom) */}
<div <div
aria-hidden="true" aria-hidden="true"
className="absolute bottom-[3%] left-1/2 flex -translate-x-1/2 flex-col items-center gap-0.5" className="absolute bottom-[3%] left-1/2 flex -translate-x-1/2 flex-col items-center gap-0.5"
@ -166,71 +178,44 @@ export function HoleDiagram({
<span className="size-3 rounded-full border-2 border-clubhouse-muted bg-clubhouse-card" /> <span className="size-3 rounded-full border-2 border-clubhouse-muted bg-clubhouse-card" />
</div> </div>
{/* Hazard markers + always-visible distance labels */}
{placed.map((p) => {
const labelTop = labelTopByKey.get(p.key) ?? p.topPct
const labelOnLeft = p.leftPct > 55
return (
<div key={p.key} aria-hidden="true">
{/* marker dot */}
<div
className="absolute z-10 flex size-7 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border-2 border-clubhouse-card bg-cup-strong shadow-sm"
style={{ top: `${p.topPct}%`, left: `${p.leftPct}%` }}
>
<TriangleAlert className="size-4 text-clubhouse-card" strokeWidth={2.5} />
</div>
{/* distance label (nudged vertically on collision) */}
<div
className={cn(
"absolute z-20 -translate-y-1/2 whitespace-nowrap rounded-md bg-clubhouse-card px-1.5 py-0.5 text-xs font-bold tabular-nums text-clubhouse-ink shadow-sm ring-1 ring-clubhouse-border",
labelOnLeft ? "-translate-x-full" : "translate-x-0",
)}
style={{
top: `${labelTop}%`,
left: labelOnLeft ? `calc(${p.leftPct}% - 1rem)` : `calc(${p.leftPct}% + 1rem)`,
}}
>
{formatMeters(p.distanceMeters)}
</div>
</div>
)
})}
{/* Player position ("you are here") — a pin, shape-distinct from hazards */}
<div <div
aria-hidden="true" aria-hidden="true"
className="absolute z-30 flex -translate-x-1/2 -translate-y-full flex-col items-center" className="absolute left-1/2 z-30 flex -translate-x-1/2 -translate-y-full flex-col items-center"
style={{ top: `${playerTop}%`, left: `${playerLeft}%` }} style={{ top: `${playerTop}%` }}
> >
<span className="mb-0.5 rounded bg-clubhouse-card px-1.5 py-0.5 text-xs font-bold text-tee-strong ring-1 ring-clubhouse-border"> <span className="mb-0.5 rounded bg-clubhouse-card px-1.5 py-0.5 text-xs font-bold text-tee-strong ring-1 ring-clubhouse-border">
Deg Deg
</span> </span>
<MapPin className="size-7 fill-tee-strong text-clubhouse-card" strokeWidth={2} /> <MapPin className="size-6 fill-tee-strong text-clubhouse-card" strokeWidth={2} />
</div>
{centerHazards.map((h) => (
<HazardMarker key={h.key} hazard={h} topPct={h.topPct} />
))}
</div>
{/* Høyre bane */}
<div className="relative">
{rightHazards.map((h) => (
<HazardMarker key={h.key} hazard={h} topPct={h.topPct} />
))}
</div> </div>
</div> </div>
{/* Accessible, screen-reader-friendly text list — the same info as the diagram. */}
{hazards.length > 0 && ( {hazards.length > 0 && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h3 className="text-sm font-bold uppercase tracking-wide text-clubhouse-muted"> <h3 className="text-sm font-bold uppercase tracking-wide text-clubhouse-muted">
Hindringer Hindringer
</h3> </h3>
<ul className="flex flex-col divide-y divide-clubhouse-border rounded-xl border border-clubhouse-border bg-clubhouse-card"> <ul className="flex flex-col divide-y divide-clubhouse-border rounded-xl border border-clubhouse-border bg-clubhouse-card">
{hazards.map((h, i) => ( {hazards.flatMap((h) =>
<li key={`${h.label}-${i}`} className="flex items-center gap-3 px-3 py-2.5"> h.above !== null
<TriangleAlert ? [
aria-hidden="true" <HazardRow key={`${h.key}-front`} label={`${h.label} (front)`} value={h.below} />,
className="size-5 shrink-0 text-cup-strong" <HazardRow key={`${h.key}-back`} label={`${h.label} (bak)`} value={h.above} />,
strokeWidth={2.5} ]
/> : [<HazardRow key={h.key} label={h.label} value={h.below} />],
<span className="min-w-0 flex-1 text-pretty text-base text-clubhouse-ink"> )}
{h.label}
</span>
<span className="shrink-0 text-lg font-bold tabular-nums text-clubhouse-ink">
{formatMeters(h.distanceMeters)}
</span>
</li>
))}
</ul> </ul>
</div> </div>
)} )}
@ -238,15 +223,17 @@ export function HoleDiagram({
) )
} }
function BigNumber({ function HazardRow({ label, value }: { label: string; value: number }) {
label, return (
value, <li className="flex items-center gap-3 px-3 py-2.5">
emphasized, <TriangleAlert aria-hidden="true" className="size-5 shrink-0 text-cup-strong" strokeWidth={2.5} />
}: { <span className="min-w-0 flex-1 text-pretty text-base text-clubhouse-ink">{label}</span>
label: string <span className="shrink-0 text-lg font-bold tabular-nums text-clubhouse-ink">{formatMeters(value)}</span>
value: number </li>
emphasized?: boolean )
}) { }
function BigNumber({ label, value, emphasized }: { label: string; value: number; emphasized?: boolean }) {
return ( return (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<span <span
@ -269,15 +256,7 @@ function BigNumber({
) )
} }
function CompactNumber({ function CompactNumber({ label, value, emphasized }: { label: string; value: number; emphasized?: boolean }) {
label,
value,
emphasized,
}: {
label: string
value: number
emphasized?: boolean
}) {
return ( return (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<span <span
@ -288,9 +267,7 @@ function CompactNumber({
> >
{Math.round(value)} {Math.round(value)}
</span> </span>
<span className="text-[0.65rem] font-bold uppercase tracking-wide text-clubhouse-muted"> <span className="text-[0.65rem] font-bold uppercase tracking-wide text-clubhouse-muted">{label}</span>
{label}
</span>
</div> </div>
) )
} }
@ -300,10 +277,15 @@ function buildDiagramAria(
hazards: DiagramHazard[], hazards: DiagramHazard[],
player: Props["playerPosition"], player: Props["playerPosition"],
) { ) {
void player
const base = `Baneskisse. Senter ${Math.round(distances.middle)} meter til green. Din posisjon markert.` const base = `Baneskisse. Senter ${Math.round(distances.middle)} meter til green. Din posisjon markert.`
if (hazards.length === 0) return `${base} Ingen hindringer.` if (hazards.length === 0) return `${base} Ingen hindringer.`
const list = hazards const list = hazards
.map((h) => `${h.label} ${Math.round(h.distanceMeters)} meter`) .map((h) =>
h.above !== null
? `${h.label} forkant ${Math.round(h.below)} meter, bakkant ${Math.round(h.above)} meter`
: `${h.label} ${Math.round(h.below)} meter`,
)
.join(", ") .join(", ")
return `${base} Hindringer: ${list}.` return `${base} Hindringer: ${list}.`
} }

View file

@ -43,6 +43,10 @@ type ApiTargetPoint = {
side_fairway: "left" | "center" | "right" | null side_fairway: "left" | "center" | "right" | null
latitude: number latitude: number
longitude: number longitude: number
// Eksplisitt, menneske-satt paring av forkant/bakkant til SAMME
// fysiske hindring (migrasjon 080/ADR-084). `null` = enkeltstående
// punkt, ALDRI slått sammen med noe -- se groupDiagramHazards().
hazard_group: string | null
} }
const _HAZARD_LABELS: Record<string, string> = { const _HAZARD_LABELS: Record<string, string> = {
@ -52,6 +56,92 @@ const _HAZARD_LABELS: Record<string, string> = {
rock: "Fjellknaus", rock: "Fjellknaus",
} }
// ADR-084: hvilket ikon en hindringstype får i diagrammet. "generic"
// (typer uten dedikert ikon, i dag kun rock) faller tilbake til
// lucide sin TriangleAlert -- se hole-diagram-view.tsx.
type HazardKind = "sand" | "water" | "generic"
const _HAZARD_KIND: Record<string, HazardKind> = {
green_bunker: "sand",
fairway_bunker: "sand",
water: "water",
rock: "generic",
}
// Grupperer rå hindringspunkter til hindrings-ENTITETER for diagrammet.
// VIKTIG (bruker-rettelse, ADR-084): to punkter er KUN samme fysiske
// hindring hvis de deler samme, ikke-null hazard_group -- ALDRI basert
// på poi_type+side_fairway alene (ville feilaktig slått sammen to
// atskilte hindringer av samme type på samme side). Punkter med
// hazard_group=null forblir alltid enkeltstående.
function groupDiagramHazards(
hazardPoints: ApiTargetPoint[],
livePosition: LatLng,
teeRef: LatLng,
greenRef: LatLng,
) {
const grouped = new Map<string, ApiTargetPoint[]>()
const standalone: ApiTargetPoint[] = []
for (const p of hazardPoints) {
if (p.hazard_group) {
const list = grouped.get(p.hazard_group) ?? []
list.push(p)
grouped.set(p.hazard_group, list)
} else {
standalone.push(p)
}
}
type Entity = {
key: string
kind: HazardKind
label: string
lane: "left" | "center" | "right"
alongPercent: number
below: number
above: number | null
}
const entities: Entity[] = []
function distanceTo(p: ApiTargetPoint) {
return Math.round(haversineMeters(livePosition, { lat: p.latitude, lng: p.longitude }))
}
function along(p: ApiTargetPoint) {
return projectOntoAxis(teeRef, greenRef, { lat: p.latitude, lng: p.longitude }).alongPercent
}
for (const [groupKey, pts] of grouped) {
const front = pts.find((p) => p.location === "front")
const back = pts.find((p) => p.location === "back")
const primary = front ?? back ?? pts[0]
const kind = _HAZARD_KIND[primary.poi_type]
if (!kind) continue
const alongValues = [front, back].filter((p): p is ApiTargetPoint => !!p).map(along)
entities.push({
key: `group-${groupKey}`,
kind,
label: _HAZARD_LABELS[primary.poi_type],
lane: primary.side_fairway ?? "center",
alongPercent: alongValues.reduce((a, b) => a + b, 0) / alongValues.length,
below: front ? distanceTo(front) : distanceTo(primary),
above: front && back ? distanceTo(back) : null,
})
}
for (const p of standalone) {
const kind = _HAZARD_KIND[p.poi_type]
if (!kind) continue
entities.push({
key: `point-${p.latitude}-${p.longitude}-${p.poi_type}`,
kind,
label: _HAZARD_LABELS[p.poi_type],
lane: p.side_fairway ?? "center",
alongPercent: along(p),
below: distanceTo(p),
above: null,
})
}
return entities
}
export function HoleTargetDistance({ export function HoleTargetDistance({
baseUrl, baseUrl,
holeNumber, holeNumber,
@ -189,10 +279,7 @@ export function HoleTargetDistance({
if (teeRef) { if (teeRef) {
const greenRef = { lat: middle.latitude, lng: middle.longitude } const greenRef = { lat: middle.latitude, lng: middle.longitude }
const player = projectOntoAxis(teeRef, greenRef, livePosition) const player = projectOntoAxis(teeRef, greenRef, livePosition)
const diagramHazards = hazards.map((h) => ({ const diagramHazards = groupDiagramHazards(hazardPoints, livePosition, teeRef, greenRef)
...h,
...projectOntoAxis(teeRef, greenRef, { lat: h.lat, lng: h.lng }),
}))
return ( return (
<HoleDiagram <HoleDiagram
size={size} size={size}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB