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)
|
||||
|
||||
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
|
||||
teecup_frontend && up -d`, ingen migrasjon. Ren containerlogg,
|
||||
`https://teecup.golf/logg-inn` 200 OK.
|
||||
|
||||
101. **Hull-diagram v2: kategoriske akser + hazard_group (ADR-084) —
|
||||
2026-08-17.** Bruker viste et ekte skjermbilde fra Tjøme hull 18:
|
||||
ADR-083 sin kontinuerlige `crossMeters`-forskyvning ga for lite
|
||||
synlig venstre/høyre-forskjell på ekte koordinater -- bunker
|
||||
(faktisk venstre) og vann (faktisk senter) havnet begge nesten på
|
||||
senterlinjen. Løsning: tre FASTE kategoriske baner
|
||||
(venstre/senter/høyre) drevet direkte av `side_fairway`, ikke av
|
||||
utledet `crossMeters`. Green og tee alltid senterakse (uansett egen
|
||||
`side_fairway`); spiller ("Deg") også alltid senterakse, kun
|
||||
langs-hull-posisjon. Carry-hindringer (front+bak-par) -> ett ikon,
|
||||
forkant under/bakkant over; enkeltpunkt -> én avstand under.
|
||||
|
||||
Første forsøk grupperte forkant/bakkant via `poi_type`+
|
||||
`side_fairway`-heuristikk -- bruker avviste eksplisitt: to atskilte
|
||||
hindringer av samme type på samme side (f.eks. to venstre-bunkere)
|
||||
må ALDRI slås sammen. Undersøkt: ingen eksisterende gruppe-/
|
||||
hindrings-id fantes i verken `golfapi_course_coordinate` eller
|
||||
`teeoff_course_coordinate`. Løst med ny migrasjon 080:
|
||||
`hazard_group text` (nullable) på begge tabeller -- `NULL` (alt
|
||||
eksisterende data i dag) = alltid enkeltstående, aldri slått
|
||||
sammen; ikke-null = eksplisitt menneskelig bekreftet pardata, satt
|
||||
ved data-inntasting (samme tillitsnivå som `poi_type`/`location`,
|
||||
ADR-081). Eksponert i `app/target_points.py` og
|
||||
`app/routers/courses.py` (`CoursePointIn`/`Out`,
|
||||
`_COORDINATE_COLUMNS`, INSERT i `replace_course_coordinates` --
|
||||
eneste skrive-vei, kun offisielle TeeOff-baner; Tjømes personlige
|
||||
`golfapi_course_coordinate`-data har ingen skrive-endepunkt, kun
|
||||
engangsskript).
|
||||
|
||||
Nye ikoner fra bruker (PNG, 32×31 sand/vann, 40×40 green) i
|
||||
`frontend/public/hole-diagram/` -- IKKE de tre opplastede
|
||||
landskaps-SVG-ene (fulle illustrerte banekart, 237-495 path-
|
||||
elementer hver), avvist som for detaljerte til å være lesbare
|
||||
nedskalert til 32-40px (CLAUDE.md sin ståenede tilgjengelighets-
|
||||
regel). Typer uten dedikert ikon (i dag kun `rock`) faller tilbake
|
||||
til `TriangleAlert`.
|
||||
|
||||
`hole-target-distance.tsx` fikk `groupDiagramHazards()` (grupperer
|
||||
UTELUKKENDE på delt, ikke-null `hazard_group`) som erstatter den
|
||||
gamle per-punkt-mappingen til `HoleDiagram`; den flate `hazards`-
|
||||
listen til `TargetDistance`-fallback (baner uten tee-koordinater)
|
||||
er uendret. `hole-diagram-view.tsx` skrevet om fra kontinuerlig
|
||||
`crossToLeftPct` til `grid-cols-3` med tre uavhengige baner, hver
|
||||
med egen kollisjons-forskyvning.
|
||||
|
||||
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55, `pytest`
|
||||
111/111 (migrasjon 080 kjørt i scratch-testsuiten). Egen scratch-
|
||||
database+rebygd scratch `teecup_api`+lokal `next dev`, presist
|
||||
kjent syntetisk datasett med to ATSKILTE venstre fairway-bunkere
|
||||
(én paret via `hazard_group`, én enkeltstående) -- bekreftet TO
|
||||
atskilte ikoner (ikke slått sammen), riktig forkant(54m)/
|
||||
bakkant(63m) på den parede, riktig enkelttall(103m) på den
|
||||
enkeltstående. Vannhinder (senter, 130m) og rock (høyre, generic-
|
||||
ikon, 18m) riktig plassert. Green/tee bekreftet alltid midtbane.
|
||||
Lys+mørk bekreftet. Scratch-stacken revet ned.
|
||||
|
||||
**Ingen migrasjon rullet ut mot ekte database ennå** -- venter på
|
||||
eksplisitt brukerbekreftelse av migrasjon 080 mot ekte `teecup_db`,
|
||||
per CLAUDE.md sin ufravikelige sikkerhetsregel.
|
||||
|
||||
**Gjenstår:** liste over `_HAZARD_CONFIG`-typer uten eget ikon
|
||||
(i dag kun `rock`/"Fjellknaus") leveres til bruker etter utrulling.
|
||||
V0-prompt for polert visning skrives etter godkjenning av den
|
||||
hånd-kodede versjonen.
|
||||
|
|
|
|||
|
|
@ -636,6 +636,11 @@ class CoursePointIn(BaseModel):
|
|||
side_fairway: str | None = None
|
||||
latitude: float = Field(ge=-90, le=90)
|
||||
longitude: float = Field(ge=-180, le=180)
|
||||
# Eksplisitt, menneske-satt paring av forkant/bakkant til SAMME
|
||||
# fysiske hindring (migrasjon 080/ADR-084) -- se hazard_group-
|
||||
# kommentaren i migrasjonsfilen. Frivillig, ingen validering utover
|
||||
# tekst -- kun meningsfullt unikt innenfor ett hull.
|
||||
hazard_group: str | None = None
|
||||
|
||||
|
||||
class CoursePointOut(BaseModel):
|
||||
|
|
@ -645,9 +650,10 @@ class CoursePointOut(BaseModel):
|
|||
side_fairway: str | None
|
||||
latitude: float
|
||||
longitude: float
|
||||
hazard_group: str | None
|
||||
|
||||
|
||||
_COORDINATE_COLUMNS = "hole_number, poi_type, location, side_fairway, latitude, longitude"
|
||||
_COORDINATE_COLUMNS = "hole_number, poi_type, location, side_fairway, latitude, longitude, hazard_group"
|
||||
|
||||
|
||||
@router.get("/orgs/{organization_id}/courses/{course_id}/coordinates", response_model=list[CoursePointOut])
|
||||
|
|
@ -694,10 +700,10 @@ async def replace_course_coordinates(
|
|||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO teeoff_course_coordinate
|
||||
(external_course_ref, hole_number, poi_type, location, side_fairway, latitude, longitude)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
(external_course_ref, hole_number, poi_type, location, side_fairway, latitude, longitude, hazard_group)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
""",
|
||||
external_ref, p.hole_number, p.poi_type, p.location, p.side_fairway, p.latitude, p.longitude,
|
||||
external_ref, p.hole_number, p.poi_type, p.location, p.side_fairway, p.latitude, p.longitude, p.hazard_group,
|
||||
)
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ class TargetPoint(BaseModel):
|
|||
side_fairway: str | None
|
||||
latitude: float
|
||||
longitude: float
|
||||
hazard_group: str | None
|
||||
|
||||
|
||||
async def get_target_points(conn: Connection, course_key: CourseKey | None, hole_number: int) -> list[TargetPoint]:
|
||||
|
|
@ -39,7 +40,7 @@ async def get_target_points(conn: Connection, course_key: CourseKey | None, hole
|
|||
_, facility_slug, teeoff_course_id = course_key
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT poi_type, location, side_fairway, latitude, longitude
|
||||
SELECT poi_type, location, side_fairway, latitude, longitude, hazard_group
|
||||
FROM teeoff_course_coordinate
|
||||
WHERE external_course_ref = $1 AND hole_number = $2
|
||||
""",
|
||||
|
|
@ -50,7 +51,7 @@ async def get_target_points(conn: Connection, course_key: CourseKey | None, hole
|
|||
_, golfapi_course_id = course_key
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT poi_type, location, side_fairway, latitude, longitude
|
||||
SELECT poi_type, location, side_fairway, latitude, longitude, hazard_group
|
||||
FROM golfapi_course_coordinate
|
||||
WHERE golfapi_course_id = $1 AND hole_number = $2
|
||||
""",
|
||||
|
|
|
|||
|
|
@ -1,43 +1,45 @@
|
|||
"use client"
|
||||
|
||||
// V0-eksportert visning (2026-08-17, ADR-083) -- ren kontrollert
|
||||
// komponent, ingen egen GPS-/geometrilogikk. All geometri-utregning
|
||||
// (projectOntoAxis) bor i hole-target-distance.tsx, allerede bevist
|
||||
// riktig med enhetstester + scratch-verifisering FØR denne visningen
|
||||
// ble bestilt. Erstattet en midlertidig hånd-kodet stand-in med samme
|
||||
// props-kontrakt -- samme mønster som FlagPlantSheet/PlayerImportPanel.
|
||||
// Hull-diagram v2 (2026-08-17, ADR-084) -- kategoriske venstre/senter/
|
||||
// høyre-akser i stedet for kontinuerlig sideveis-forskyvning (ADR-083).
|
||||
// Se ADR-084: kontinuerlig `crossMeters` ga for liten synlig forskjell
|
||||
// på ekte banedata (Tjøme hull 18 -- bunker+vann havnet begge nesten på
|
||||
// senterlinjen selv om de faktisk ligger på ulik side). Banevalget
|
||||
// kommer nå fra `hazards[].lane`, satt av datalaget i
|
||||
// hole-target-distance.tsx basert på `side_fairway` -- IKKE utledet her.
|
||||
// Ren kontrollert komponent, ingen egen GPS-/geometrilogikk.
|
||||
|
||||
import { Flag, MapPin, TriangleAlert } from "lucide-react"
|
||||
import { MapPin, TriangleAlert } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type HazardKind = "sand" | "water" | "generic"
|
||||
|
||||
export type DiagramHazard = {
|
||||
key: string
|
||||
kind: HazardKind
|
||||
label: string
|
||||
/** Live distance FROM THE PLAYER, always accurate. */
|
||||
distanceMeters: number
|
||||
/** 0 = at tee, 100 = at green. May be <0 or >100. */
|
||||
lane: "left" | "center" | "right"
|
||||
/** 0 = ved tee, 100 = ved green. */
|
||||
alongPercent: number
|
||||
/** Positive = right of the tee->green axis, negative = left. */
|
||||
crossMeters: number
|
||||
/** Avstand FRA SPILLEREN, vises rett under ikonet. Alltid satt. */
|
||||
below: number
|
||||
/** Avstand til bakkant, vises rett over ikonet -- KUN satt når
|
||||
* hindringen er et ekte forkant+bakkant-par (se hazard_group i
|
||||
* datalaget). `null` = enkeltpunkt, ingen tekst over ikonet. */
|
||||
above: number | null
|
||||
}
|
||||
|
||||
type Props = {
|
||||
distances: { front: number; middle: number; back: number }
|
||||
playerPosition: { alongPercent: number; crossMeters: number }
|
||||
/** Already sorted nearest-to-player first. */
|
||||
playerPosition: { alongPercent: number }
|
||||
hazards: DiagramHazard[]
|
||||
size?: "compact" | "full"
|
||||
className?: string
|
||||
}
|
||||
|
||||
// --- Diagram geometry (percentages of the field box) ---------------------
|
||||
// Fixed endpoints: green stays at the top, tee stays at the bottom, always.
|
||||
const TRACK_TOP = 12 // where alongPercent === 100 lands (leaves room for the flag)
|
||||
const TRACK_BOTTOM = 88 // where alongPercent === 0 lands (leaves room for the tee dot)
|
||||
// Sideways scale: this many meters of cross offset maps to the max visual swing.
|
||||
const MAX_CROSS_METERS = 30
|
||||
const MAX_OFFSET_PCT = 40 // ±40% of the field width, per spec
|
||||
// Label collision handling: minimum vertical gap between stacked labels.
|
||||
const LABEL_MIN_GAP_PCT = 11
|
||||
const LABEL_MIN_GAP_PCT = 16 // hindringer i samme bane trenger mer luft nå (ikon + to tall)
|
||||
|
||||
function clamp(n: number, lo: number, hi: number) {
|
||||
return Math.min(hi, Math.max(lo, n))
|
||||
|
|
@ -48,26 +50,57 @@ function alongToTopPct(alongPercent: number) {
|
|||
return TRACK_TOP + ((100 - a) / 100) * (TRACK_BOTTOM - TRACK_TOP)
|
||||
}
|
||||
|
||||
function crossToLeftPct(crossMeters: number) {
|
||||
const offset = clamp(
|
||||
(crossMeters / MAX_CROSS_METERS) * MAX_OFFSET_PCT,
|
||||
-MAX_OFFSET_PCT,
|
||||
MAX_OFFSET_PCT,
|
||||
)
|
||||
return 50 + offset
|
||||
}
|
||||
|
||||
function formatMeters(m: number) {
|
||||
return `${Math.round(m)} m`
|
||||
}
|
||||
|
||||
export function HoleDiagram({
|
||||
distances,
|
||||
playerPosition,
|
||||
hazards,
|
||||
size = "full",
|
||||
className,
|
||||
}: Props) {
|
||||
function HazardIcon({ kind }: { kind: HazardKind }) {
|
||||
if (kind === "sand") {
|
||||
return <img src="/hole-diagram/hazard-sand.png" width={32} height={31} alt="" className="block" />
|
||||
}
|
||||
if (kind === "water") {
|
||||
return <img src="/hole-diagram/hazard-water.png" width={32} height={31} alt="" className="block" />
|
||||
}
|
||||
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") {
|
||||
return (
|
||||
<div
|
||||
|
|
@ -83,15 +116,12 @@ export function HoleDiagram({
|
|||
</div>
|
||||
{hazards.length > 0 && (
|
||||
<ul className="flex flex-col gap-1 border-t border-clubhouse-border pt-2">
|
||||
{hazards.map((h, i) => (
|
||||
<li key={`${h.label}-${i}`} className="flex items-center gap-1.5 text-sm">
|
||||
<TriangleAlert
|
||||
aria-hidden="true"
|
||||
className="size-3.5 shrink-0 text-cup-strong"
|
||||
/>
|
||||
{hazards.map((h) => (
|
||||
<li key={h.key} className="flex items-center gap-1.5 text-sm">
|
||||
<TriangleAlert 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="shrink-0 font-bold tabular-nums text-clubhouse-ink">
|
||||
{formatMeters(h.distanceMeters)}
|
||||
{formatMeters(h.below)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
|
|
@ -101,61 +131,43 @@ export function HoleDiagram({
|
|||
)
|
||||
}
|
||||
|
||||
// --- Full variant -------------------------------------------------------
|
||||
// Precompute placements and a light label-collision nudge (top -> bottom).
|
||||
const placed = hazards.map((h, i) => ({
|
||||
...h,
|
||||
key: `${h.label}-${i}`,
|
||||
topPct: alongToTopPct(h.alongPercent),
|
||||
leftPct: crossToLeftPct(h.crossMeters),
|
||||
}))
|
||||
|
||||
const byTop = [...placed].sort((a, b) => a.topPct - b.topPct)
|
||||
const labelTopByKey = new Map<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 leftHazards = layoutLane(hazards.filter((h) => h.lane === "left"))
|
||||
const centerHazards = layoutLane(hazards.filter((h) => h.lane === "center"))
|
||||
const rightHazards = layoutLane(hazards.filter((h) => h.lane === "right"))
|
||||
const playerTop = alongToTopPct(playerPosition.alongPercent)
|
||||
const playerLeft = crossToLeftPct(playerPosition.crossMeters)
|
||||
|
||||
return (
|
||||
<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">
|
||||
<BigNumber label="Front" value={distances.front} />
|
||||
<BigNumber label="Senter" value={distances.middle} emphasized />
|
||||
<BigNumber label="Bak" value={distances.back} />
|
||||
</div>
|
||||
|
||||
{/* The hole diagram. Green fixed top, tee fixed bottom. */}
|
||||
<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"
|
||||
aria-label={buildDiagramAria(distances, hazards, playerPosition)}
|
||||
>
|
||||
{/* Discreet tee->green centerline */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute top-[10%] bottom-[10%] left-1/2 w-px -translate-x-1/2 bg-clubhouse-border"
|
||||
/>
|
||||
{/* Venstre bane */}
|
||||
<div className="relative">
|
||||
{leftHazards.map((h) => (
|
||||
<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
|
||||
aria-hidden="true"
|
||||
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">
|
||||
Green
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tee marker (fixed bottom) */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
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" />
|
||||
</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
|
||||
aria-hidden="true"
|
||||
className="absolute z-30 flex -translate-x-1/2 -translate-y-full flex-col items-center"
|
||||
style={{ top: `${playerTop}%`, left: `${playerLeft}%` }}
|
||||
className="absolute left-1/2 z-30 flex -translate-x-1/2 -translate-y-full flex-col items-center"
|
||||
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">
|
||||
Deg
|
||||
</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>
|
||||
|
||||
{/* Accessible, screen-reader-friendly text list — the same info as the diagram. */}
|
||||
{hazards.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-bold uppercase tracking-wide text-clubhouse-muted">
|
||||
Hindringer
|
||||
</h3>
|
||||
<ul className="flex flex-col divide-y divide-clubhouse-border rounded-xl border border-clubhouse-border bg-clubhouse-card">
|
||||
{hazards.map((h, i) => (
|
||||
<li key={`${h.label}-${i}`} 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}
|
||||
/>
|
||||
<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>
|
||||
))}
|
||||
{hazards.flatMap((h) =>
|
||||
h.above !== null
|
||||
? [
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -238,15 +223,17 @@ export function HoleDiagram({
|
|||
)
|
||||
}
|
||||
|
||||
function BigNumber({
|
||||
label,
|
||||
value,
|
||||
emphasized,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
emphasized?: boolean
|
||||
}) {
|
||||
function HazardRow({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<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} />
|
||||
<span className="min-w-0 flex-1 text-pretty text-base text-clubhouse-ink">{label}</span>
|
||||
<span className="shrink-0 text-lg font-bold tabular-nums text-clubhouse-ink">{formatMeters(value)}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function BigNumber({ label, value, emphasized }: { label: string; value: number; emphasized?: boolean }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<span
|
||||
|
|
@ -269,15 +256,7 @@ function BigNumber({
|
|||
)
|
||||
}
|
||||
|
||||
function CompactNumber({
|
||||
label,
|
||||
value,
|
||||
emphasized,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
emphasized?: boolean
|
||||
}) {
|
||||
function CompactNumber({ label, value, emphasized }: { label: string; value: number; emphasized?: boolean }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<span
|
||||
|
|
@ -288,9 +267,7 @@ function CompactNumber({
|
|||
>
|
||||
{Math.round(value)}
|
||||
</span>
|
||||
<span className="text-[0.65rem] font-bold uppercase tracking-wide text-clubhouse-muted">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-[0.65rem] font-bold uppercase tracking-wide text-clubhouse-muted">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -300,10 +277,15 @@ function buildDiagramAria(
|
|||
hazards: DiagramHazard[],
|
||||
player: Props["playerPosition"],
|
||||
) {
|
||||
void player
|
||||
const base = `Baneskisse. Senter ${Math.round(distances.middle)} meter til green. Din posisjon markert.`
|
||||
if (hazards.length === 0) return `${base} Ingen hindringer.`
|
||||
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(", ")
|
||||
return `${base} Hindringer: ${list}.`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,10 @@ type ApiTargetPoint = {
|
|||
side_fairway: "left" | "center" | "right" | null
|
||||
latitude: 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> = {
|
||||
|
|
@ -52,6 +56,92 @@ const _HAZARD_LABELS: Record<string, string> = {
|
|||
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({
|
||||
baseUrl,
|
||||
holeNumber,
|
||||
|
|
@ -189,10 +279,7 @@ export function HoleTargetDistance({
|
|||
if (teeRef) {
|
||||
const greenRef = { lat: middle.latitude, lng: middle.longitude }
|
||||
const player = projectOntoAxis(teeRef, greenRef, livePosition)
|
||||
const diagramHazards = hazards.map((h) => ({
|
||||
...h,
|
||||
...projectOntoAxis(teeRef, greenRef, { lat: h.lat, lng: h.lng }),
|
||||
}))
|
||||
const diagramHazards = groupDiagramHazards(hazardPoints, livePosition, teeRef, greenRef)
|
||||
return (
|
||||
<HoleDiagram
|
||||
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