teecup/frontend/components/hole-diagram-view.tsx
Erol Haagenrud 84f11e1a45 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>
2026-08-17 23:36:28 +02:00

291 lines
11 KiB
TypeScript

"use client"
// 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 { 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
lane: "left" | "center" | "right"
/** 0 = ved tee, 100 = ved green. */
alongPercent: 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 }
hazards: DiagramHazard[]
size?: "compact" | "full"
className?: string
}
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 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))
}
function alongToTopPct(alongPercent: number) {
const a = clamp(alongPercent, 0, 100)
return TRACK_TOP + ((100 - a) / 100) * (TRACK_BOTTOM - TRACK_TOP)
}
function formatMeters(m: number) {
return `${Math.round(m)} m`
}
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
className={cn(
"flex flex-col gap-2 rounded-xl border border-clubhouse-border bg-clubhouse-card p-3 text-clubhouse-ink",
className,
)}
>
<div className="flex items-baseline justify-center gap-3 tabular-nums">
<CompactNumber label="Front" value={distances.front} />
<CompactNumber label="Senter" value={distances.middle} emphasized />
<CompactNumber label="Bak" value={distances.back} />
</div>
{hazards.length > 0 && (
<ul className="flex flex-col gap-1 border-t border-clubhouse-border pt-2">
{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.below)}
</span>
</li>
))}
</ul>
)}
</div>
)
}
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)
return (
<div className={cn("flex w-full flex-col gap-4 text-clubhouse-ink", className)}>
<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>
<div
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)}
>
{/* Venstre bane */}
<div className="relative">
{leftHazards.map((h) => (
<HazardMarker key={h.key} hazard={h} topPct={h.topPct} />
))}
</div>
{/* 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"
>
<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>
<div
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>
<div
aria-hidden="true"
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-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>
{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.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>
)}
</div>
)
}
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
className={cn(
"font-black leading-none tabular-nums",
emphasized ? "text-6xl text-tee-strong" : "text-4xl text-clubhouse-ink",
)}
>
{Math.round(value)}
</span>
<span
className={cn(
"mt-1 font-bold uppercase tracking-wide",
emphasized ? "text-sm text-tee-strong" : "text-xs text-clubhouse-muted",
)}
>
{label}
</span>
</div>
)
}
function CompactNumber({ label, value, emphasized }: { label: string; value: number; emphasized?: boolean }) {
return (
<div className="flex flex-col items-center">
<span
className={cn(
"font-black leading-none tabular-nums",
emphasized ? "text-3xl text-tee-strong" : "text-xl text-clubhouse-ink",
)}
>
{Math.round(value)}
</span>
<span className="text-[0.65rem] font-bold uppercase tracking-wide text-clubhouse-muted">{label}</span>
</div>
)
}
function buildDiagramAria(
distances: Props["distances"],
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.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}.`
}