V0-integrering: polert hull-diagram
Integrerer V0-eksporten for hole-diagram-view.tsx. Traff props- kontrakten i prompten eksakt -- ingen integreringsjustering nødvendig, tsc rent på første forsøk. Egne tillegg fra V0 utover prompten: full aria-label-oppsummering av hele diagrammet for skjermlesere, lett kollisjons-unngåelse for nære hindringsetiketter. Re-verifisert i scratch mot samme kjente syntetiske geometri som den midlertidige hånd-kodede versjonen -- identiske tall/posisjoner. Lys+mørk bekreftet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
6f79df6eab
commit
dabf3478ec
3 changed files with 286 additions and 141 deletions
|
|
@ -8331,12 +8331,35 @@ allerede deler samme `HoleTargetDistance`-datalag (ADR-081).
|
||||||
(target-points-endepunktene returnerte allerede `tee_front`/`tee_back`
|
(target-points-endepunktene returnerte allerede `tee_front`/`tee_back`
|
||||||
uendret, bare ikke brukt av frontend før nå).
|
uendret, bare ikke brukt av frontend før nå).
|
||||||
|
|
||||||
**Gjenstår:** V0-prompt for `hole-diagram-view.tsx` sin polerte visning
|
**V0-eksport mottatt og integrert samme dag** (samme
|
||||||
skrives og sendes til bruker; midlertidig hånd-kodet versjon er allerede
|
`tee-cup-login-screen.zip`-prosjektnavn V0 alltid re-eksporterer med,
|
||||||
bevist geometrisk korrekt og fungerer, men er visuelt uferdig.
|
kun `components/hole-diagram.tsx` var relevant -- kopiert til
|
||||||
|
`hole-diagram-view.tsx`, ingen andre filer fra eksporten rørt). Traff
|
||||||
|
props-kontrakten i prompten eksakt, ingen integreringsfeil å rette
|
||||||
|
(til forskjell fra forrige V0-runde/kjønn-mismatchen i ADR-082) --
|
||||||
|
`tsc --noEmit` rent på FØRSTE forsøk. Egne, ikke eksplisitt etterspurte
|
||||||
|
kvalitetstillegg fra V0: `role="img"` + en full tekstlig `aria-label`-
|
||||||
|
oppsummering av HELE diagrammet (`buildDiagramAria`) for skjermlesere,
|
||||||
|
og en lett kollisjons-unngåelse for hindringsetiketter som havner nær
|
||||||
|
hverandre vertikalt (sortert etikett-nudge, ikke bedt om i prompten,
|
||||||
|
men matcher "ikke la det se ødelagt ut hvis to hindringer er nære"-
|
||||||
|
kravet godt).
|
||||||
|
|
||||||
**Rullet ut: ikke ennå** -- geometri+datalag+fallback er klare og
|
**Verifisert på nytt med den polerte visningen:** samme kjente,
|
||||||
scratch-bevist, venter på V0-integrering før samlet utrulling.
|
syntetiske hull-fixture som den midlertidige versjonen -- IDENTISKE tall
|
||||||
|
og posisjoner bekreftet (front 140/senter 150/bak 160/hindringer 54 m
|
||||||
|
til høyre midt på hullet, 131 m til venstre nær green, spiller 25% ut
|
||||||
|
fra tee), nå med V0s poleringer (varseltrekant-ikoner, "Deg"-etikett
|
||||||
|
over spillermarkøren, ryddig etikett-plassering). Lys+mørk bekreftet.
|
||||||
|
Scratch-stacken revet ned.
|
||||||
|
|
||||||
|
**Ingen migrasjon** -- ren frontend-endring, ingen backend-endring
|
||||||
|
(target-points-endepunktene returnerte allerede `tee_front`/`tee_back`
|
||||||
|
uendret, bare ikke brukt av frontend før nå).
|
||||||
|
|
||||||
|
**Rullet ut: ikke ennå** -- klar, venter på bekreftelse før `docker
|
||||||
|
compose build teecup_frontend && up -d` (kun frontend, ingen
|
||||||
|
backend-endring i denne runden, ingen migrasjon).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
14
CHANGELOG.md
14
CHANGELOG.md
|
|
@ -12029,5 +12029,15 @@ Neste steg:
|
||||||
**Ingen migrasjon** -- ren frontend, backend uendret (target-points
|
**Ingen migrasjon** -- ren frontend, backend uendret (target-points
|
||||||
returnerte allerede tee_front/tee_back, bare ikke brukt før nå).
|
returnerte allerede tee_front/tee_back, bare ikke brukt før nå).
|
||||||
|
|
||||||
**Rullet ut: ikke ennå** -- venter på V0-eksport for den polerte
|
**V0-eksport mottatt og integrert samme dag.** Traff props-
|
||||||
visningen.
|
kontrakten eksakt -- ingen integreringsfeil å rette denne gangen
|
||||||
|
(til forskjell fra ADR-082/spillerimportens kjønn-mismatch), `tsc`
|
||||||
|
rent på første forsøk. To ekstra kvalitetstillegg fra V0 utover det
|
||||||
|
som ble bedt om: full `aria-label`-oppsummering av hele diagrammet
|
||||||
|
for skjermlesere, og en lett kollisjons-unngåelse for hindrings-
|
||||||
|
etiketter som havner nær hverandre. Re-verifisert i scratch mot
|
||||||
|
samme kjente fixture -- identiske tall/posisjoner som den
|
||||||
|
midlertidige versjonen. Lys+mørk bekreftet.
|
||||||
|
|
||||||
|
**Rullet ut: ikke ennå** -- klar, venter på bekreftelse før
|
||||||
|
`docker compose build teecup_frontend && up -d`.
|
||||||
|
|
|
||||||
|
|
@ -1,161 +1,244 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
// MIDLERTIDIG hånd-kodet hull-diagram (ADR-083, 2026-08-17) -- bygget for
|
// V0-eksportert visning (2026-08-17, ADR-083) -- ren kontrollert
|
||||||
// å bevise geometrikjeden (frontend/lib/hole-geometry.ts) fungerer riktig
|
// komponent, ingen egen GPS-/geometrilogikk. All geometri-utregning
|
||||||
// ende-til-ende FØR en V0-prompt skrives, samme rekkefølge som
|
// (projectOntoAxis) bor i hole-target-distance.tsx, allerede bevist
|
||||||
// FlagPlantSheet/PlayerImportPanel tidligere denne økten. Erstattes med en
|
// riktig med enhetstester + scratch-verifisering FØR denne visningen
|
||||||
// V0-eksportert visning senere -- selve geometri-utregningen (i
|
// ble bestilt. Erstattet en midlertidig hånd-kodet stand-in med samme
|
||||||
// hole-target-distance.tsx) endres ikke ved bytte, kun denne rene
|
// props-kontrakt -- samme mønster som FlagPlantSheet/PlayerImportPanel.
|
||||||
// visningen.
|
|
||||||
//
|
|
||||||
// Tee vises alltid nederst, green (senter) alltid øverst -- fast akse
|
|
||||||
// (IKKE spillerens gangretning), se hole-target-distance.tsx sin
|
|
||||||
// teeRef-uthenting. alongPercent/crossMeters kommer ferdigregnet inn,
|
|
||||||
// klippes KUN her (ved selve tegningen), ikke i dataen.
|
|
||||||
|
|
||||||
import { Flag, MapPin } from "lucide-react"
|
import { Flag, MapPin, TriangleAlert } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
type DiagramHazard = {
|
export type DiagramHazard = {
|
||||||
label: string
|
label: string
|
||||||
|
/** Live distance FROM THE PLAYER, always accurate. */
|
||||||
distanceMeters: number
|
distanceMeters: number
|
||||||
|
/** 0 = at tee, 100 = at green. May be <0 or >100. */
|
||||||
alongPercent: number
|
alongPercent: number
|
||||||
|
/** Positive = right of the tee->green axis, negative = left. */
|
||||||
crossMeters: number
|
crossMeters: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
distances: { front: number; middle: number; back: number }
|
||||||
|
playerPosition: { alongPercent: number; crossMeters: number }
|
||||||
|
/** Already sorted nearest-to-player first. */
|
||||||
|
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
|
||||||
|
|
||||||
|
function clamp(n: number, lo: number, hi: number) {
|
||||||
|
return Math.min(hi, Math.max(lo, n))
|
||||||
|
}
|
||||||
|
|
||||||
|
function alongToTopPct(alongPercent: number) {
|
||||||
|
const a = clamp(alongPercent, 0, 100)
|
||||||
|
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({
|
export function HoleDiagram({
|
||||||
distances,
|
distances,
|
||||||
playerPosition,
|
playerPosition,
|
||||||
hazards,
|
hazards,
|
||||||
size = "full",
|
size = "full",
|
||||||
className,
|
className,
|
||||||
}: {
|
}: Props) {
|
||||||
distances: { front: number; middle: number; back: number }
|
|
||||||
playerPosition: { alongPercent: number; crossMeters: number }
|
|
||||||
hazards: DiagramHazard[]
|
|
||||||
size?: "compact" | "full"
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
// Diagrammet trenger plass -- kompakt-visningen (badge ved GIR-merket)
|
|
||||||
// viser fortsatt kun tallene + full hindringsliste, ikke selve tegningen.
|
|
||||||
if (size === "compact") {
|
if (size === "compact") {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex flex-col gap-1 rounded-xl border border-clubhouse-border bg-clubhouse-card px-3 py-2 shadow-sm",
|
"flex flex-col gap-2 rounded-xl border border-clubhouse-border bg-clubhouse-card p-3 text-clubhouse-ink",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-end gap-3">
|
<div className="flex items-baseline justify-center gap-3 tabular-nums">
|
||||||
<NumberColumn label="Front" value={distances.front} />
|
<CompactNumber label="Front" value={distances.front} />
|
||||||
<NumberColumn label="Senter" value={distances.middle} emphasized />
|
<CompactNumber label="Senter" value={distances.middle} emphasized />
|
||||||
<NumberColumn label="Bak" value={distances.back} />
|
<CompactNumber label="Bak" value={distances.back} />
|
||||||
</div>
|
</div>
|
||||||
<HazardTextList hazards={hazards} compact />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
aria-label="Hull-diagram og avstand til green"
|
|
||||||
className={cn(
|
|
||||||
"flex w-full flex-col gap-4 rounded-2xl border border-clubhouse-border bg-clubhouse-card p-5 shadow-sm",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-end justify-between gap-2">
|
|
||||||
<NumberColumn label="Front" value={distances.front} />
|
|
||||||
<NumberColumn label="Senter" value={distances.middle} emphasized />
|
|
||||||
<NumberColumn label="Bak" value={distances.back} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Lane playerPosition={playerPosition} hazards={hazards} />
|
|
||||||
|
|
||||||
{hazards.length > 0 && (
|
{hazards.length > 0 && (
|
||||||
<div className="border-t border-clubhouse-border pt-3">
|
<ul className="flex flex-col gap-1 border-t border-clubhouse-border pt-2">
|
||||||
<HazardTextList hazards={hazards} />
|
{hazards.map((h, i) => (
|
||||||
</div>
|
<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"
|
||||||
|
/>
|
||||||
|
<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)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
)}
|
)}
|
||||||
</section>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const LANE_HEIGHT_PX = 260
|
// --- Full variant -------------------------------------------------------
|
||||||
// Faktisk fairway-bredde er ikke kjent fra dataen -- klipper visuelt til
|
// Precompute placements and a light label-collision nudge (top -> bottom).
|
||||||
// ±30 m fra midtlinjen, en fornuftig typisk verdi (se ADR-083). Selve
|
const placed = hazards.map((h, i) => ({
|
||||||
// TALLENE (distanceMeters) er alltid nøyaktige uansett -- kun selve
|
...h,
|
||||||
// PLASSERINGEN i tegningen klippes.
|
key: `${h.label}-${i}`,
|
||||||
const MAX_CROSS_METERS = 30
|
topPct: alongToTopPct(h.alongPercent),
|
||||||
|
leftPct: crossToLeftPct(h.crossMeters),
|
||||||
|
}))
|
||||||
|
|
||||||
function clampAlong(percent: number): number {
|
const byTop = [...placed].sort((a, b) => a.topPct - b.topPct)
|
||||||
return Math.min(100, Math.max(0, percent))
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
function crossToOffsetPercent(crossMeters: number): number {
|
const playerTop = alongToTopPct(playerPosition.alongPercent)
|
||||||
const clamped = Math.min(MAX_CROSS_METERS, Math.max(-MAX_CROSS_METERS, crossMeters))
|
const playerLeft = crossToLeftPct(playerPosition.crossMeters)
|
||||||
return (clamped / MAX_CROSS_METERS) * 42 // maks ±42% fra senter, litt luft i kantene
|
|
||||||
}
|
|
||||||
|
|
||||||
function Lane({
|
|
||||||
playerPosition,
|
|
||||||
hazards,
|
|
||||||
}: {
|
|
||||||
playerPosition: { alongPercent: number; crossMeters: number }
|
|
||||||
hazards: DiagramHazard[]
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cn("flex w-full flex-col gap-4 text-clubhouse-ink", className)}>
|
||||||
className="relative w-full overflow-hidden rounded-xl border border-clubhouse-border bg-clubhouse-field"
|
{/* Distance readout: center emphasized + largest, in the tee-green color. */}
|
||||||
style={{ height: LANE_HEIGHT_PX }}
|
<div className="flex items-end justify-center gap-6 tabular-nums">
|
||||||
>
|
<BigNumber label="Front" value={distances.front} />
|
||||||
{/* Midtlinje -- ren visuell referanse, ikke en ekte fairway-kant. */}
|
<BigNumber label="Senter" value={distances.middle} emphasized />
|
||||||
<div className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-clubhouse-border" aria-hidden="true" />
|
<BigNumber label="Bak" value={distances.back} />
|
||||||
|
|
||||||
{/* Green -- fast øverst. */}
|
|
||||||
<div className="absolute left-1/2 top-2 flex -translate-x-1/2 flex-col items-center gap-0.5" aria-hidden="true">
|
|
||||||
<Flag className="size-5 text-tee-strong" />
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wide text-clubhouse-muted">Green</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tee -- fast nederst. */}
|
{/* The hole diagram. Green fixed top, tee fixed bottom. */}
|
||||||
<div className="absolute bottom-2 left-1/2 flex -translate-x-1/2 flex-col items-center gap-0.5" aria-hidden="true">
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wide text-clubhouse-muted">Tee</span>
|
|
||||||
<span className="size-2.5 rounded-full bg-clubhouse-muted" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hindringer, plassert langs+til side av aksen. */}
|
|
||||||
{hazards.map((h, i) => (
|
|
||||||
<div
|
<div
|
||||||
key={`${h.label}-${i}`}
|
className="relative h-[24rem] w-full overflow-hidden rounded-2xl border border-clubhouse-border bg-clubhouse-field"
|
||||||
className="absolute flex -translate-x-1/2 translate-y-1/2 flex-col items-center gap-0.5"
|
role="img"
|
||||||
style={{
|
aria-label={buildDiagramAria(distances, hazards, playerPosition)}
|
||||||
bottom: `${clampAlong(h.alongPercent)}%`,
|
|
||||||
left: `calc(50% + ${crossToOffsetPercent(h.crossMeters)}%)`,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span className="size-2.5 rounded-full border-2 border-cup-strong bg-clubhouse-card" aria-hidden="true" />
|
{/* Discreet tee->green centerline */}
|
||||||
<span className="whitespace-nowrap rounded-md bg-clubhouse-card px-1 text-[10px] font-bold tabular-nums text-cup-strong shadow-sm">
|
<div
|
||||||
{h.distanceMeters}m
|
aria-hidden="true"
|
||||||
|
className="absolute top-[10%] bottom-[10%] left-1/2 w-px -translate-x-1/2 bg-clubhouse-border"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Green marker (fixed top) */}
|
||||||
|
<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} />
|
||||||
|
<span className="rounded bg-clubhouse-card px-1.5 py-0.5 text-xs font-bold text-tee-strong">
|
||||||
|
Green
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Spilleren -- "du er her". */}
|
{/* Tee marker (fixed bottom) */}
|
||||||
<div
|
<div
|
||||||
className="absolute -translate-x-1/2 translate-y-1/2"
|
aria-hidden="true"
|
||||||
|
className="absolute bottom-[3%] left-1/2 flex -translate-x-1/2 flex-col items-center gap-0.5"
|
||||||
|
>
|
||||||
|
<span className="rounded bg-clubhouse-card px-1.5 py-0.5 text-xs font-bold text-clubhouse-muted">
|
||||||
|
Tee
|
||||||
|
</span>
|
||||||
|
<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={{
|
style={{
|
||||||
bottom: `${clampAlong(playerPosition.alongPercent)}%`,
|
top: `${labelTop}%`,
|
||||||
left: `calc(50% + ${crossToOffsetPercent(playerPosition.crossMeters)}%)`,
|
left: labelOnLeft ? `calc(${p.leftPct}% - 1rem)` : `calc(${p.leftPct}% + 1rem)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MapPin aria-label="Din posisjon" className="size-6 fill-tee-strong text-clubhouse-card" />
|
{formatMeters(p.distanceMeters)}
|
||||||
</div>
|
</div>
|
||||||
</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}%` }}
|
||||||
|
>
|
||||||
|
<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} />
|
||||||
|
</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>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NumberColumn({
|
function BigNumber({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
emphasized,
|
emphasized,
|
||||||
|
|
@ -165,33 +248,62 @@ function NumberColumn({
|
||||||
emphasized?: boolean
|
emphasized?: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-end gap-0.5">
|
<div className="flex flex-col items-center">
|
||||||
<span className="text-xs font-semibold uppercase tracking-wide text-clubhouse-muted">{label}</span>
|
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-baseline gap-0.5 leading-none tabular-nums",
|
"font-black leading-none tabular-nums",
|
||||||
emphasized ? "text-6xl font-black text-tee-strong" : "text-4xl font-bold text-clubhouse-ink",
|
emphasized ? "text-6xl text-tee-strong" : "text-4xl text-clubhouse-ink",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{value}
|
{Math.round(value)}
|
||||||
<span className="text-sm font-semibold text-clubhouse-muted">m</span>
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"mt-1 font-bold uppercase tracking-wide",
|
||||||
|
emphasized ? "text-sm text-tee-strong" : "text-xs text-clubhouse-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function HazardTextList({ hazards, compact }: { hazards: DiagramHazard[]; compact?: boolean }) {
|
function CompactNumber({
|
||||||
if (hazards.length === 0) return null
|
label,
|
||||||
|
value,
|
||||||
|
emphasized,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
emphasized?: boolean
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<ul className={cn("flex flex-col", compact ? "gap-0.5" : "gap-1")}>
|
<div className="flex flex-col items-center">
|
||||||
{hazards.map((h, i) => (
|
<span
|
||||||
<li
|
className={cn(
|
||||||
key={`${h.label}-${i}`}
|
"font-black leading-none tabular-nums",
|
||||||
className={cn("font-medium text-cup-strong", compact ? "text-[11px]" : "text-sm")}
|
emphasized ? "text-3xl text-tee-strong" : "text-xl text-clubhouse-ink",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{h.label}: <span className="tabular-nums font-bold">{h.distanceMeters}</span> m
|
{Math.round(value)}
|
||||||
</li>
|
</span>
|
||||||
))}
|
<span className="text-[0.65rem] font-bold uppercase tracking-wide text-clubhouse-muted">
|
||||||
</ul>
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildDiagramAria(
|
||||||
|
distances: Props["distances"],
|
||||||
|
hazards: DiagramHazard[],
|
||||||
|
player: Props["playerPosition"],
|
||||||
|
) {
|
||||||
|
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`)
|
||||||
|
.join(", ")
|
||||||
|
return `${base} Hindringer: ${list}.`
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue