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:
parent
1bfac02944
commit
f3b3d30e4e
10 changed files with 324 additions and 37 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
70
CHANGELOG.md
70
CHANGELOG.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
if (kind === "water") {
|
tree: "/hole-diagram/hazard-tree.png",
|
||||||
return <img src="/hole-diagram/hazard-water.png" width={32} height={31} alt="" className="block" />
|
dogleg: "/hole-diagram/hazard-dogleg.png",
|
||||||
|
layup: "/hole-diagram/hazard-layup.png",
|
||||||
|
landmark: "/hole-diagram/hazard-landmark.png",
|
||||||
|
}
|
||||||
|
|
||||||
|
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 nå er spredt over tre baner. */
|
* bane siden hindringer nå 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}.`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
BIN
frontend/public/hole-diagram/hazard-creek.png
Normal file
BIN
frontend/public/hole-diagram/hazard-creek.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
BIN
frontend/public/hole-diagram/hazard-dogleg.png
Normal file
BIN
frontend/public/hole-diagram/hazard-dogleg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
BIN
frontend/public/hole-diagram/hazard-landmark.png
Normal file
BIN
frontend/public/hole-diagram/hazard-landmark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
BIN
frontend/public/hole-diagram/hazard-layup.png
Normal file
BIN
frontend/public/hole-diagram/hazard-layup.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
BIN
frontend/public/hole-diagram/hazard-rock.png
Normal file
BIN
frontend/public/hole-diagram/hazard-rock.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
BIN
frontend/public/hole-diagram/hazard-tree.png
Normal file
BIN
frontend/public/hole-diagram/hazard-tree.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3 KiB |
Loading…
Reference in a new issue