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:
parent
d4f635fcfd
commit
84f11e1a45
10 changed files with 443 additions and 179 deletions
32
080_target_point_hazard_group.sql
Normal file
32
080_target_point_hazard_group.sql
Normal 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;
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
65
CHANGELOG.md
65
CHANGELOG.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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"""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
""",
|
""",
|
||||||
|
|
|
||||||
|
|
@ -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 nå er spredt over tre baner. */
|
||||||
|
function layoutLane(hazards: DiagramHazard[]) {
|
||||||
|
const sorted = [...hazards].sort((a, b) => alongToTopPct(a.alongPercent) - alongToTopPct(b.alongPercent))
|
||||||
|
const placed: (DiagramHazard & { topPct: number })[] = []
|
||||||
|
let lastTop = Number.NEGATIVE_INFINITY
|
||||||
|
for (const h of sorted) {
|
||||||
|
const desired = alongToTopPct(h.alongPercent)
|
||||||
|
const topPct = Math.max(desired, lastTop + LABEL_MIN_GAP_PCT)
|
||||||
|
placed.push({ ...h, topPct })
|
||||||
|
lastTop = topPct
|
||||||
|
}
|
||||||
|
return placed
|
||||||
|
}
|
||||||
|
|
||||||
|
function HazardMarker({ hazard, topPct }: { hazard: DiagramHazard; topPct: number }) {
|
||||||
|
return (
|
||||||
|
<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}.`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
BIN
frontend/public/hole-diagram/green-40.png
Normal file
BIN
frontend/public/hole-diagram/green-40.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
BIN
frontend/public/hole-diagram/hazard-sand.png
Normal file
BIN
frontend/public/hole-diagram/hazard-sand.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
BIN
frontend/public/hole-diagram/hazard-water.png
Normal file
BIN
frontend/public/hole-diagram/hazard-water.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
Loading…
Reference in a new issue