Hull-diagram v2: rettelser fra ekte produksjonsbruk (ADR-084 del 2)

Ett-ikon-overlapp-fiks i midtbanen (green-ikonet manglet reservert
plass), bak-green-rendering (hindringer bak selve greenen vises nå over
green-ikonet i stedet for klemt inn i vanlig-sonen), fem nye
hindringsikoner, og en omskrevet liste (én rad per hindring, med
bane/posisjon-info).

Setter i tillegg ekte hazard_group-verdier på Tjømes koordinatdata,
utledet fra klubbens eget koordinat-regneark (som viste seg å ha en
egen gruppenummer-kolonne) i stedet for en heuristikk -- løser
definitivt de flerbunker-hullene forrige runde bevisst lot stå
uklassifisert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erol Haagenrud 2026-08-18 06:43:57 +02:00
parent 1bfac02944
commit f3b3d30e4e
10 changed files with 324 additions and 37 deletions

View file

@ -8453,6 +8453,99 @@ til bruker (i dag kun `rock`/"Fjellknaus"). V0-prompt for polert
visning skrives etter at bruker har godkjent den hånd-kodede visning skrives etter at bruker har godkjent den hånd-kodede
versjonen. versjonen.
### ADR-084 del 2: retting fra ekte produksjonsbruk, ny hazard_group-data for Tjøme (2026-08-18)
Bruker viste et ekte skjermbilde av v2-diagrammet i produksjon (samme
dag som utrullingen over) og meldte fem konkrete avvik, pluss lastet
opp fem nye 32px-ikoner (fjellknaus/trær/dogleg/layup/landemerke) og et
regneark med Tjømes koordinater. Alle fem rettet:
1. **Ett ikon per hindring, ikke to** -- viste seg IKKE å være en kode-
feil, men at ekte Tjøme-data manglet `hazard_group` (forventet, se
"gjenstår" over). Løst ved å faktisk sette `hazard_group` på ekte
data (se under).
2. **Ikon-overlapp med green** -- reell kode-feil: `layoutLane()` sin
kollisjons-kaskade for midtbanen tok ikke hensyn til at green-ikonet
selv opptar plass i samme sone. Fikset med `CENTER_TRACK_TOP`
(reservert sone under green-ikonet, kun for midtbanen).
3. **Skal ikonene i listen under diagrammet gjenbrukes, evt. i større
størrelse?** Bruker spurte oss om å vurdere -- svarte ja, 36px (opp
fra det gamle universelle 20px `TriangleAlert`-ikonet), forskjellig
ikon per type.
4. **Listen manglet forkant/senter/bakkant og venstre/senter/høyre-info**
-- lagt til som en undertekst-linje per rad.
5. **Listen viste hvert par to ganger** -- skrevet om til én rad per
hindring, med "Forkant X m / Bakkant Y m" på samme rad når parret.
**Under samme runde, ny rettelse fra bruker etter at "bakre
greenbunker" (hull 4) skulle pares:** "(Den må jo for øvrig vises OVER
greenikonet?)" -- riktig observasjon: `alongPercent` (fra
`projectOntoAxis`, se hole-geometry.ts) klippes bevisst IKKE til
[0,100] i geometrimodulen, men visningen klippet den likevel ved selve
tegningen, så en hindring bak selve greenen (alongPercent > 100) endte
opp i SAMME sone som "helt inntil green"-hindringer -- fysisk feil
side av green-ikonet. Fikset i `hole-diagram-view.tsx`: når minst én
midtbane-hindring har alongPercent > 100, flyttes green-ikonet lenger
ned (`GREEN_TOP_PCT_PUSHED`), og en egen liten sone åpnes OVER det
flyttede ikonet (`BEHIND_GREEN_ZONE_TOP/BOTTOM`), med kollisjons-
kaskade i motsatt retning (`layoutBehindGreenLane`, kaskaderer OPPOVER
i stedet for nedover). Ingen tilsvarende sone bak TEE ennå -- ingen
kjent forekomst i data, men samme mønster kan gjenbrukes symmetrisk.
**Ny `hazard_group`-data for Tjøme, fra ekte kildedata (ikke
heuristikk):** brukeren lastet opp klubbens eget regneark med
koordinater, som viste seg å ha en EKSPLISITT gruppenummer-kolonne som
parer forkant/bakkant til samme fysiske hindring -- akkurat den
identiteten `hazard_group` (del 1, migrasjon 080) var designet for å
representere. Kryssjekket regnearkets 38 gruppenumre mot databasens
180 koordinatpunkter via lat/long-matching (Python-script, engangs,
scratchpad): 76 av 76 gyldige punkter identifisert eksakt. Løste
DEFINITIVT de to tilfellene forrige runde bevisst utelot som
tvetydige (hull 4: tre høyre fairway-bunkere, hull 13: to) ved
eliminasjon -- 2 av regnearkets rader hadde feil koordinatformat (UTM/
EPSG:25833 i stedet for WGS84, sannsynligvis kopi-lim-feil ved
regnearkets forfatning), men gruppenummeret var likevel nok til å
identifisere DB-raden ved eliminasjon (de gjenværende umatchede
punktene på riktig hull+side+type). Fant i tillegg at `rock`
("fjellknaus") også har forkant/bakkant-par på fire hull -- ikke
fanget opp i del 1 siden rock da bare hadde generisk ikon. Bruker
bekreftet i tillegg manuelt én ekstra paring regnearket selv ikke
hadde gruppenummer for (hull 4 sin "bakre greenbunker").
**Resultat: 78 koordinatrader satt til 39 distinkte `hazard_group`-
verdier** (format `{hull}-{regneark-gruppenummer}`, sporbart tilbake
til kilden), kjørt som en frittstående `UPDATE ... FROM (VALUES ...)`
mot ekte `teecup_db` (ikke en migrasjon -- datainnhold, ikke skjema).
**Bevisst IKKE gjort:** bekk (creek) fikk et eget ikon fra bruker
(`hazard-creek.png`, kopiert til `frontend/public/hole-diagram/`, IKKE
koblet til noe ennå) -- domenet `course_poi_type` har ingen egen verdi
for bekk i dag (kun `water` dekker både dam og bekk), så å skille dem
visuelt krever en ny domeneverdi + reklassifisering av de spesifikke
bekk-punktene regnearket identifiserer. Bruker ba eksplisitt om at
dette gjøres som en EGEN runde/beslutning, ikke bakt inn i denne.
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55 (to ganger,
etter hvert av de to kode-rundene). Ny scratch-runde (database+API+
next dev, samme disiplin) med presist konstruert "bak green"-scenario
-- bekreftet visuelt at bakre greenbunker faktisk rendres OVER
green-ikonet, ikke i normal-sonen, med normal-sonens egne hindringer
uendret ved siden av. Lys+mørk bekreftet begge runder. Begge scratch-
stackene fullstendig revet ned.
**Rullet ut 2026-08-18** -- bruker bekreftet begge deler eksplisitt
(kode + data). `docker compose build teecup_frontend && up -d`
(ingen migrasjon -- kun frontend-kode denne runden), deretter
`UPDATE`-en over kjørt mot ekte `teecup_db`. Rene containerlogger,
`https://teecup.golf/logg-inn` 200 OK, 78/78 rader bekreftet satt
(`count(*) ... WHERE hazard_group IS NOT NULL` = 78, 39 distinkte).
**Gjenstår:** bekk (creek) som egen `poi_type` -- egen runde, bruker
har bedt om dette eksplisitt. Liste over `_HAZARD_CONFIG`-typer uten
eget ikon oppdatert: nå KUN `road` (ingen ikon lastet opp for denne).
V0-prompt for polert visning fortsatt ikke skrevet -- venter på at
bruker har sett/godkjent denne runden i produksjon også.
--- ---
## Utviklingsplan (rekkefølge) ## Utviklingsplan (rekkefølge)

View file

@ -12110,3 +12110,73 @@ Neste steg:
(i dag kun `rock`/"Fjellknaus") leveres til bruker. V0-prompt for (i dag kun `rock`/"Fjellknaus") leveres til bruker. V0-prompt for
polert visning skrives etter godkjenning av den hånd-kodede polert visning skrives etter godkjenning av den hånd-kodede
versjonen. versjonen.
102. **Hull-diagram v2, retting fra ekte produksjonsbruk (ADR-084 del
2) — 2026-08-18.** Bruker viste et ekte skjermbilde av v2-
diagrammet i produksjon og meldte fem avvik: (1) hindringer med
forkant+bakkant viste to ikoner i stedet for ett -- skyldtes at
ekte Tjøme-data ennå ikke hadde `hazard_group` satt, ikke en
kode-feil. (2) Et ikon overlappet green-ikonet i midtbanen -- ekte
kode-feil, `layoutLane()` tok ikke hensyn til green-ikonets egen
plass; fikset med en reservert sone (`CENTER_TRACK_TOP`). (3-4)
Listen under diagrammet manglet forkant/senter/bakkant- og
venstre/senter/høyre-info, og viste hvert par to ganger -- skrevet
om til én rad per hindring med begge avstander på samme rad, pluss
undertekst for bane/posisjon. (5) Ikonene i listen byttet fra
universelt 20px `TriangleAlert` til type-spesifikke ikoner ved
36px.
Underveis, da hull 4 sin "bakre greenbunker" skulle pares: bruker
påpekte at den fysisk ligger BAK selve greenen og derfor må vises
OVER green-ikonet, ikke i vanlig-sonen. `alongPercent` fra
`projectOntoAxis` klippes bevisst ikke i geometrimodulen (kan bli
>100), men visningen klippet den likevel -- fikset ved å flytte
green-ikonet ned og åpne en egen sone over det
(`GREEN_TOP_PCT_PUSHED`/`BEHIND_GREEN_ZONE_*`,
`layoutBehindGreenLane` kaskaderer oppover) når minst én
midtbane-hindring faktisk ligger bak green.
Fem nye ikoner (fjellknaus/trær/dogleg/layup/landemerke, alle
32px) integrert -- disse typene var i domenet fra før men manglet
egen visning i diagrammet (fjellknaus hadde generisk ikon, resten
var usynlige der).
**Ny ekte `hazard_group`-data for Tjøme, fra kildedata, ikke
heuristikk:** bruker lastet opp klubbens eget koordinat-regneark,
som viste seg å ha en EKSPLISITT gruppenummer-kolonne som allerede
parer forkant/bakkant -- akkurat identiteten `hazard_group` (del 1)
var designet for. Kryssjekket regnearkets 38 grupper mot databasens
180 punkter via lat/long (engangs Python-script) -- løste
DEFINITIVT de to sakene forrige runde bevisst lot stå
(hull 4: tre høyre fairway-bunkere, hull 13: to) ved eliminasjon,
til tross for at 2 rader i regnearket hadde feil koordinatformat
(UTM/EPSG:25833 -- trolig kopi-lim-feil, uskadelig siden kun
gruppenummeret var nødvendig). Fant i tillegg at `rock` også har
forkant/bakkant-par på fire hull (ikke fanget opp i del 1). Bruker
bekreftet manuelt én ekstra paring uten gruppenummer i regnearket
(hull 4 sin bakre greenbunker). Resultat: 78 rader satt til 39
distinkte `hazard_group`-verdier (`{hull}-{regneark-gruppe}`,
sporbart til kilden) via en frittstående `UPDATE ... FROM
(VALUES ...)` -- datainnhold, ikke skjema, ingen ny migrasjon.
**Bevisst utsatt:** bruker lastet opp et bekk (creek)-ikon, men
domenet `course_poi_type` skiller ikke bekk fra dam (begge
`water`) -- å vise dem ulikt krever en ny domeneverdi +
reklassifisering av spesifikke punkter. Bruker ba eksplisitt om at
dette blir en EGEN runde, ikke bakt inn her.
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55 (to ganger).
Ny scratch-runde med presist konstruert "bak green"-scenario --
bekreftet visuelt at bakre greenbunker rendres over green-ikonet,
normal-sonens hindringer uendret ved siden av. Lys+mørk bekreftet
begge runder. Begge scratch-stackene revet ned.
**Rullet ut 2026-08-18** -- bruker bekreftet kode og data hver for
seg. `docker compose build teecup_frontend && up -d` (kun
frontend-kode, ingen migrasjon), deretter UPDATE-en mot ekte
`teecup_db`. Rene containerlogger, `https://teecup.golf/logg-inn`
200 OK, 78/78 rader bekreftet satt (39 distinkte grupper).
**Gjenstår:** bekk som egen `poi_type` (egen runde, bedt om
eksplisitt av bruker). Ikon-mangel-lista er nå kun `road`. V0-
prompt for polert visning fortsatt ikke skrevet.

View file

@ -12,7 +12,7 @@
import { 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 HazardKind = "sand" | "water" | "rock" | "tree" | "dogleg" | "layup" | "landmark" | "generic"
export type DiagramHazard = { export type DiagramHazard = {
key: string key: string
@ -27,6 +27,21 @@ export type DiagramHazard = {
* hindringen er et ekte forkant+bakkant-par (se hazard_group i * hindringen er et ekte forkant+bakkant-par (se hazard_group i
* datalaget). `null` = enkeltpunkt, ingen tekst over ikonet. */ * datalaget). `null` = enkeltpunkt, ingen tekst over ikonet. */
above: number | null above: number | null
/** Kun meningsfullt når `above` er `null` -- brukes i listen under
* diagrammet til å si "forkant"/"senter"/"bakkant" om et
* enkeltstående punkt. */
location: "front" | "middle" | "back" | null
}
const _LANE_LABEL: Record<DiagramHazard["lane"], string> = {
left: "Venstre",
center: "Senter",
right: "Høyre",
}
const _LOCATION_LABEL: Record<"front" | "middle" | "back", string> = {
front: "forkant",
middle: "midtpunkt",
back: "bakkant",
} }
type Props = { type Props = {
@ -39,40 +54,78 @@ type Props = {
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)
// Midtbanen har i tillegg faste green-/tee-ankre tegnet OVENPÅ selve
// sporet (green-ikon+etikett øverst, tee-prikk+etikett nederst) -- en
// hindring plassert etter samme skala som venstre/høyre-banen kunne
// dermed havne visuelt oppå green-ikonet. Reservert sone er banens
// egen skala, kun for midtbanen.
const CENTER_TRACK_TOP = 29
const CENTER_TRACK_BOTTOM = 80
const LABEL_MIN_GAP_PCT = 16 // hindringer i samme bane trenger mer luft nå (ikon + to tall) const LABEL_MIN_GAP_PCT = 16 // hindringer i samme bane trenger mer luft nå (ikon + to tall)
// En hindring KAN ligge bak selve greenen (alongPercent > 100 -- f.eks.
// en bakre greenbunker) -- projectOntoAxis() klipper bevisst IKKE dette
// bort (se hole-geometry.ts). Uten særbehandling ville en slik hindring
// blitt klippet til samme posisjon som "helt inntil green" og dermed
// visuelt smeltet sammen med front-of-green-hindringer, selv om den
// fysisk ligger på MOTSATT side av green-ikonet. Løsning: når minst én
// senterbane-hindring er bak green, flyttes green-ankeret lenger ned
// (fra faste 3% til GREEN_TOP_PCT_PUSHED), og en egen, liten sone
// ovenfor det flyttede ankeret reserveres til disse hindringene --
// nærmest green kaskaderer OPPOVER (motsatt retning av layoutLane) ved
// kollisjon. Ingen tilsvarende sone finnes (ennå) for hindringer bak
// TEE -- ingen kjent forekomst i dagens data, men samme mønster kan
// gjenbrukes symmetrisk om det trengs.
const GREEN_TOP_PCT_DEFAULT = 3
const GREEN_TOP_PCT_PUSHED = 22
const CENTER_TRACK_TOP_PUSHED = 40
const BEHIND_GREEN_ZONE_TOP = 3
const BEHIND_GREEN_ZONE_BOTTOM = 19
const BEHIND_GREEN_MAX_OVERSHOOT = 40 // % av hull-lengden bak green før visuelt klippet til toppen
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))
} }
function alongToTopPct(alongPercent: number) { function alongToTopPct(alongPercent: number, top = TRACK_TOP, bottom = TRACK_BOTTOM) {
const a = clamp(alongPercent, 0, 100) const a = clamp(alongPercent, 0, 100)
return TRACK_TOP + ((100 - a) / 100) * (TRACK_BOTTOM - TRACK_TOP) return top + ((100 - a) / 100) * (bottom - top)
} }
function formatMeters(m: number) { function formatMeters(m: number) {
return `${Math.round(m)} m` return `${Math.round(m)} m`
} }
function HazardIcon({ kind }: { kind: HazardKind }) { const _ICON_SRC: Partial<Record<HazardKind, string>> = {
if (kind === "sand") { sand: "/hole-diagram/hazard-sand.png",
return <img src="/hole-diagram/hazard-sand.png" width={32} height={31} alt="" className="block" /> water: "/hole-diagram/hazard-water.png",
rock: "/hole-diagram/hazard-rock.png",
tree: "/hole-diagram/hazard-tree.png",
dogleg: "/hole-diagram/hazard-dogleg.png",
layup: "/hole-diagram/hazard-layup.png",
landmark: "/hole-diagram/hazard-landmark.png",
} }
if (kind === "water") {
return <img src="/hole-diagram/hazard-water.png" width={32} height={31} alt="" className="block" /> function HazardIcon({ kind, size = 32 }: { kind: HazardKind; size?: number }) {
const src = _ICON_SRC[kind]
if (src) {
return <img src={src} width={size} height={size} alt="" className="block" style={{ height: "auto" }} />
} }
return <TriangleAlert className="size-7 text-cup-strong" strokeWidth={2.5} /> return <TriangleAlert className="size-7 text-cup-strong" strokeWidth={2.5} />
} }
/** Nudger ikoner i SAMME bane fra øverst til nederst hvis de er for /** 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 * nære vertikalt -- samme kollisjons-mønster som før, men kjørt per
* bane siden hindringer er spredt over tre baner. */ * bane siden hindringer er spredt over tre baner. `top`/`bottom`
function layoutLane(hazards: DiagramHazard[]) { * lar midtbanen reservere plass til de faste green-/tee-ankrene. */
const sorted = [...hazards].sort((a, b) => alongToTopPct(a.alongPercent) - alongToTopPct(b.alongPercent)) function layoutLane(hazards: DiagramHazard[], top = TRACK_TOP, bottom = TRACK_BOTTOM) {
const sorted = [...hazards].sort(
(a, b) => alongToTopPct(a.alongPercent, top, bottom) - alongToTopPct(b.alongPercent, top, bottom),
)
const placed: (DiagramHazard & { topPct: number })[] = [] const placed: (DiagramHazard & { topPct: number })[] = []
let lastTop = Number.NEGATIVE_INFINITY let lastTop = Number.NEGATIVE_INFINITY
for (const h of sorted) { for (const h of sorted) {
const desired = alongToTopPct(h.alongPercent) const desired = alongToTopPct(h.alongPercent, top, bottom)
const topPct = Math.max(desired, lastTop + LABEL_MIN_GAP_PCT) const topPct = Math.max(desired, lastTop + LABEL_MIN_GAP_PCT)
placed.push({ ...h, topPct }) placed.push({ ...h, topPct })
lastTop = topPct lastTop = topPct
@ -80,6 +133,34 @@ function layoutLane(hazards: DiagramHazard[]) {
return placed return placed
} }
function behindGreenDesiredTopPct(alongPercent: number) {
const overshoot = clamp(alongPercent - 100, 0, BEHIND_GREEN_MAX_OVERSHOOT)
// 0 overshoot (rett bak green) -> bunnen av sonen (nærmest green-ikonet).
// Maks overshoot -> toppen av sonen (lengst unna, øverst i diagrammet).
return (
BEHIND_GREEN_ZONE_BOTTOM -
(overshoot / BEHIND_GREEN_MAX_OVERSHOOT) * (BEHIND_GREEN_ZONE_BOTTOM - BEHIND_GREEN_ZONE_TOP)
)
}
/** Speilvendt av layoutLane() -- kaskaderer OPPOVER (mot diagrammets
* toppkant) ved kollisjon, siden disse hindringene ligger bak green,
* ikke foran den. */
function layoutBehindGreenLane(hazards: DiagramHazard[]) {
const sorted = [...hazards].sort(
(a, b) => behindGreenDesiredTopPct(b.alongPercent) - behindGreenDesiredTopPct(a.alongPercent),
)
const placed: (DiagramHazard & { topPct: number })[] = []
let lastTop = Number.POSITIVE_INFINITY
for (const h of sorted) {
const desired = behindGreenDesiredTopPct(h.alongPercent)
const topPct = Math.min(desired, lastTop - LABEL_MIN_GAP_PCT)
placed.push({ ...h, topPct })
lastTop = topPct
}
return placed
}
function HazardMarker({ hazard, topPct }: { hazard: DiagramHazard; topPct: number }) { function HazardMarker({ hazard, topPct }: { hazard: DiagramHazard; topPct: number }) {
return ( return (
<div <div
@ -132,7 +213,14 @@ export function HoleDiagram({ distances, playerPosition, hazards, size = "full",
} }
const leftHazards = layoutLane(hazards.filter((h) => h.lane === "left")) const leftHazards = layoutLane(hazards.filter((h) => h.lane === "left"))
const centerHazards = layoutLane(hazards.filter((h) => h.lane === "center")) const centerAll = hazards.filter((h) => h.lane === "center")
const centerBehindGreen = centerAll.filter((h) => h.alongPercent > 100)
const centerNormal = centerAll.filter((h) => h.alongPercent <= 100)
const hasBehindGreen = centerBehindGreen.length > 0
const greenTopPct = hasBehindGreen ? GREEN_TOP_PCT_PUSHED : GREEN_TOP_PCT_DEFAULT
const centerTrackTop = hasBehindGreen ? CENTER_TRACK_TOP_PUSHED : CENTER_TRACK_TOP
const centerHazards = layoutLane(centerNormal, centerTrackTop, CENTER_TRACK_BOTTOM)
const behindGreenHazards = layoutBehindGreenLane(centerBehindGreen)
const rightHazards = layoutLane(hazards.filter((h) => h.lane === "right")) const rightHazards = layoutLane(hazards.filter((h) => h.lane === "right"))
const playerTop = alongToTopPct(playerPosition.alongPercent) const playerTop = alongToTopPct(playerPosition.alongPercent)
@ -160,7 +248,8 @@ export function HoleDiagram({ distances, playerPosition, hazards, size = "full",
<div className="relative border-x border-clubhouse-border/60"> <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 flex -translate-x-1/2 flex-col items-center gap-0.5"
style={{ top: `${greenTopPct}%` }}
> >
<img src="/hole-diagram/green-40.png" width={40} height={40} alt="" className="block" /> <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">
@ -168,6 +257,10 @@ export function HoleDiagram({ distances, playerPosition, hazards, size = "full",
</span> </span>
</div> </div>
{behindGreenHazards.map((h) => (
<HazardMarker key={h.key} hazard={h} topPct={h.topPct} />
))}
<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"
@ -208,14 +301,9 @@ export function HoleDiagram({ distances, playerPosition, hazards, size = "full",
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.flatMap((h) => {hazards.map((h) => (
h.above !== null <HazardRow key={h.key} hazard={h} />
? [ ))}
<HazardRow key={`${h.key}-front`} label={`${h.label} (front)`} value={h.below} />,
<HazardRow key={`${h.key}-back`} label={`${h.label} (bak)`} value={h.above} />,
]
: [<HazardRow key={h.key} label={h.label} value={h.below} />],
)}
</ul> </ul>
</div> </div>
)} )}
@ -223,12 +311,30 @@ export function HoleDiagram({ distances, playerPosition, hazards, size = "full",
) )
} }
function HazardRow({ label, value }: { label: string; value: number }) { function HazardRow({ hazard }: { hazard: DiagramHazard }) {
const meta = [
_LANE_LABEL[hazard.lane],
hazard.above === null && hazard.location ? _LOCATION_LABEL[hazard.location] : null,
]
.filter(Boolean)
.join(" · ")
return ( return (
<li className="flex items-center gap-3 px-3 py-2.5"> <li className="flex items-center gap-3 px-3 py-2.5">
<TriangleAlert aria-hidden="true" className="size-5 shrink-0 text-cup-strong" strokeWidth={2.5} /> <HazardIcon kind={hazard.kind} size={36} />
<span className="min-w-0 flex-1 text-pretty text-base text-clubhouse-ink">{label}</span> <div className="min-w-0 flex-1">
<span className="shrink-0 text-lg font-bold tabular-nums text-clubhouse-ink">{formatMeters(value)}</span> <div className="text-pretty text-base text-clubhouse-ink">{hazard.label}</div>
<div className="text-sm text-clubhouse-muted">{meta}</div>
</div>
{hazard.above !== null ? (
<div className="flex shrink-0 flex-col items-end text-clubhouse-ink">
<span className="text-sm font-bold tabular-nums">Forkant {formatMeters(hazard.below)}</span>
<span className="text-sm font-bold tabular-nums">Bakkant {formatMeters(hazard.above)}</span>
</div>
) : (
<span className="shrink-0 text-lg font-bold tabular-nums text-clubhouse-ink">
{formatMeters(hazard.below)}
</span>
)}
</li> </li>
) )
} }
@ -281,11 +387,14 @@ function buildDiagramAria(
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) => .map((h) => {
h.above !== null const lane = _LANE_LABEL[h.lane].toLowerCase()
? `${h.label} forkant ${Math.round(h.below)} meter, bakkant ${Math.round(h.above)} meter` if (h.above !== null) {
: `${h.label} ${Math.round(h.below)} meter`, return `${h.label}, ${lane}, forkant ${Math.round(h.below)} meter, bakkant ${Math.round(h.above)} meter`
) }
.join(", ") const location = h.location ? `, ${_LOCATION_LABEL[h.location]}` : ""
return `${h.label}, ${lane}${location}, ${Math.round(h.below)} meter`
})
.join("; ")
return `${base} Hindringer: ${list}.` return `${base} Hindringer: ${list}.`
} }

View file

@ -54,17 +54,26 @@ const _HAZARD_LABELS: Record<string, string> = {
fairway_bunker: "Bunker (fairway)", fairway_bunker: "Bunker (fairway)",
water: "Vannhinder", water: "Vannhinder",
rock: "Fjellknaus", rock: "Fjellknaus",
trees: "Trær",
dogleg: "Dogleg",
layup: "Layup-punkt",
landmark: "Landemerke",
} }
// ADR-084: hvilket ikon en hindringstype får i diagrammet. "generic" // ADR-084: hvilket ikon en hindringstype får i diagrammet. "generic"
// (typer uten dedikert ikon, i dag kun rock) faller tilbake til // (typer uten dedikert ikon -- i dag ingen, `road` ville falt hit hvis
// lucide sin TriangleAlert -- se hole-diagram-view.tsx. // den noen gang klassifiseres som hindring) faller tilbake til lucide
type HazardKind = "sand" | "water" | "generic" // sin TriangleAlert -- se hole-diagram-view.tsx.
type HazardKind = "sand" | "water" | "rock" | "tree" | "dogleg" | "layup" | "landmark" | "generic"
const _HAZARD_KIND: Record<string, HazardKind> = { const _HAZARD_KIND: Record<string, HazardKind> = {
green_bunker: "sand", green_bunker: "sand",
fairway_bunker: "sand", fairway_bunker: "sand",
water: "water", water: "water",
rock: "generic", rock: "rock",
trees: "tree",
dogleg: "dogleg",
layup: "layup",
landmark: "landmark",
} }
// Grupperer rå hindringspunkter til hindrings-ENTITETER for diagrammet. // Grupperer rå hindringspunkter til hindrings-ENTITETER for diagrammet.
@ -99,6 +108,10 @@ function groupDiagramHazards(
alongPercent: number alongPercent: number
below: number below: number
above: number | null above: number | null
// Kun meningsfullt for enkeltstående punkt (above === null) --
// et parret forkant+bakkant-hinder (above !== null) trenger ikke
// dette, UI-en viser da alltid eksplisitt "forkant"/"bakkant".
location: "front" | "middle" | "back" | null
} }
const entities: Entity[] = [] const entities: Entity[] = []
@ -124,6 +137,7 @@ function groupDiagramHazards(
alongPercent: alongValues.reduce((a, b) => a + b, 0) / alongValues.length, alongPercent: alongValues.reduce((a, b) => a + b, 0) / alongValues.length,
below: front ? distanceTo(front) : distanceTo(primary), below: front ? distanceTo(front) : distanceTo(primary),
above: front && back ? distanceTo(back) : null, above: front && back ? distanceTo(back) : null,
location: front && back ? null : primary.location,
}) })
} }
for (const p of standalone) { for (const p of standalone) {
@ -137,6 +151,7 @@ function groupDiagramHazards(
alongPercent: along(p), alongPercent: along(p),
below: distanceTo(p), below: distanceTo(p),
above: null, above: null,
location: p.location,
}) })
} }
return entities return entities

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB