Siste klassifiserte hindringstype uten eget ikon. Ren frontend-endring -- road fantes allerede som domeneverdi og ekte data, bare usynlig i diagrammet inntil nå. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
402 lines
16 KiB
TypeScript
402 lines
16 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" | "creek" | "rock" | "tree" | "dogleg" | "layup" | "landmark" | "road" | "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
|
|
/** Kun meningsfullt når `above` er `null` -- brukes i listen under
|
|
* diagrammet til å si "forkant"/"senter"/"bakkant" om et
|
|
* enkeltstående punkt. */
|
|
location: "front" | "middle" | "back" | null
|
|
}
|
|
|
|
const _LANE_LABEL: Record<DiagramHazard["lane"], string> = {
|
|
left: "Venstre",
|
|
center: "Senter",
|
|
right: "Høyre",
|
|
}
|
|
const _LOCATION_LABEL: Record<"front" | "middle" | "back", string> = {
|
|
front: "forkant",
|
|
middle: "midtpunkt",
|
|
back: "bakkant",
|
|
}
|
|
|
|
type Props = {
|
|
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)
|
|
// Midtbanen har i tillegg faste green-/tee-ankre tegnet OVENPÅ selve
|
|
// sporet (green-ikon+etikett øverst, tee-prikk+etikett nederst) -- en
|
|
// hindring plassert etter samme skala som venstre/høyre-banen kunne
|
|
// dermed havne visuelt oppå green-ikonet. Reservert sone er banens
|
|
// egen skala, kun for midtbanen.
|
|
const CENTER_TRACK_TOP = 29
|
|
const CENTER_TRACK_BOTTOM = 80
|
|
const LABEL_MIN_GAP_PCT = 16 // hindringer i samme bane trenger mer luft nå (ikon + to tall)
|
|
|
|
// En hindring KAN ligge bak selve greenen (alongPercent > 100 -- f.eks.
|
|
// en bakre greenbunker) -- projectOntoAxis() klipper bevisst IKKE dette
|
|
// bort (se hole-geometry.ts). Uten særbehandling ville en slik hindring
|
|
// blitt klippet til samme posisjon som "helt inntil green" og dermed
|
|
// visuelt smeltet sammen med front-of-green-hindringer, selv om den
|
|
// fysisk ligger på MOTSATT side av green-ikonet. Løsning: når minst én
|
|
// senterbane-hindring er bak green, flyttes green-ankeret lenger ned
|
|
// (fra faste 3% til GREEN_TOP_PCT_PUSHED), og en egen, liten sone
|
|
// ovenfor det flyttede ankeret reserveres til disse hindringene --
|
|
// nærmest green kaskaderer OPPOVER (motsatt retning av layoutLane) ved
|
|
// kollisjon. Ingen tilsvarende sone finnes (ennå) for hindringer bak
|
|
// TEE -- ingen kjent forekomst i dagens data, men samme mønster kan
|
|
// gjenbrukes symmetrisk om det trengs.
|
|
const GREEN_TOP_PCT_DEFAULT = 3
|
|
const GREEN_TOP_PCT_PUSHED = 22
|
|
const CENTER_TRACK_TOP_PUSHED = 40
|
|
const BEHIND_GREEN_ZONE_TOP = 3
|
|
const BEHIND_GREEN_ZONE_BOTTOM = 19
|
|
const BEHIND_GREEN_MAX_OVERSHOOT = 40 // % av hull-lengden bak green før visuelt klippet til toppen
|
|
|
|
function clamp(n: number, lo: number, hi: number) {
|
|
return Math.min(hi, Math.max(lo, n))
|
|
}
|
|
|
|
function alongToTopPct(alongPercent: number, top = TRACK_TOP, bottom = TRACK_BOTTOM) {
|
|
const a = clamp(alongPercent, 0, 100)
|
|
return top + ((100 - a) / 100) * (bottom - top)
|
|
}
|
|
|
|
function formatMeters(m: number) {
|
|
return `${Math.round(m)} m`
|
|
}
|
|
|
|
const _ICON_SRC: Partial<Record<HazardKind, string>> = {
|
|
sand: "/hole-diagram/hazard-sand.png",
|
|
water: "/hole-diagram/hazard-water.png",
|
|
creek: "/hole-diagram/hazard-creek.png",
|
|
rock: "/hole-diagram/hazard-rock.png",
|
|
tree: "/hole-diagram/hazard-tree.png",
|
|
dogleg: "/hole-diagram/hazard-dogleg.png",
|
|
layup: "/hole-diagram/hazard-layup.png",
|
|
landmark: "/hole-diagram/hazard-landmark.png",
|
|
road: "/hole-diagram/hazard-road.png",
|
|
}
|
|
|
|
function HazardIcon({ kind, size = 32 }: { kind: HazardKind; size?: number }) {
|
|
const src = _ICON_SRC[kind]
|
|
if (src) {
|
|
return <img src={src} width={size} height={size} alt="" className="block" style={{ height: "auto" }} />
|
|
}
|
|
return <TriangleAlert className="size-7 text-cup-strong" strokeWidth={2.5} />
|
|
}
|
|
|
|
/** 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. `top`/`bottom`
|
|
* lar midtbanen reservere plass til de faste green-/tee-ankrene. */
|
|
function layoutLane(hazards: DiagramHazard[], top = TRACK_TOP, bottom = TRACK_BOTTOM) {
|
|
const sorted = [...hazards].sort(
|
|
(a, b) => alongToTopPct(a.alongPercent, top, bottom) - alongToTopPct(b.alongPercent, top, bottom),
|
|
)
|
|
const placed: (DiagramHazard & { topPct: number })[] = []
|
|
let lastTop = Number.NEGATIVE_INFINITY
|
|
for (const h of sorted) {
|
|
const desired = alongToTopPct(h.alongPercent, top, bottom)
|
|
const topPct = Math.max(desired, lastTop + LABEL_MIN_GAP_PCT)
|
|
placed.push({ ...h, topPct })
|
|
lastTop = topPct
|
|
}
|
|
return placed
|
|
}
|
|
|
|
function behindGreenDesiredTopPct(alongPercent: number) {
|
|
const overshoot = clamp(alongPercent - 100, 0, BEHIND_GREEN_MAX_OVERSHOOT)
|
|
// 0 overshoot (rett bak green) -> bunnen av sonen (nærmest green-ikonet).
|
|
// Maks overshoot -> toppen av sonen (lengst unna, øverst i diagrammet).
|
|
return (
|
|
BEHIND_GREEN_ZONE_BOTTOM -
|
|
(overshoot / BEHIND_GREEN_MAX_OVERSHOOT) * (BEHIND_GREEN_ZONE_BOTTOM - BEHIND_GREEN_ZONE_TOP)
|
|
)
|
|
}
|
|
|
|
/** Speilvendt av layoutLane() -- kaskaderer OPPOVER (mot diagrammets
|
|
* toppkant) ved kollisjon, siden disse hindringene ligger bak green,
|
|
* ikke foran den. */
|
|
function layoutBehindGreenLane(hazards: DiagramHazard[]) {
|
|
const sorted = [...hazards].sort(
|
|
(a, b) => behindGreenDesiredTopPct(b.alongPercent) - behindGreenDesiredTopPct(a.alongPercent),
|
|
)
|
|
const placed: (DiagramHazard & { topPct: number })[] = []
|
|
let lastTop = Number.POSITIVE_INFINITY
|
|
for (const h of sorted) {
|
|
const desired = behindGreenDesiredTopPct(h.alongPercent)
|
|
const topPct = Math.min(desired, lastTop - LABEL_MIN_GAP_PCT)
|
|
placed.push({ ...h, topPct })
|
|
lastTop = topPct
|
|
}
|
|
return placed
|
|
}
|
|
|
|
function HazardMarker({ hazard, topPct }: { hazard: DiagramHazard; topPct: number }) {
|
|
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 centerAll = hazards.filter((h) => h.lane === "center")
|
|
const centerBehindGreen = centerAll.filter((h) => h.alongPercent > 100)
|
|
const centerNormal = centerAll.filter((h) => h.alongPercent <= 100)
|
|
const hasBehindGreen = centerBehindGreen.length > 0
|
|
const greenTopPct = hasBehindGreen ? GREEN_TOP_PCT_PUSHED : GREEN_TOP_PCT_DEFAULT
|
|
const centerTrackTop = hasBehindGreen ? CENTER_TRACK_TOP_PUSHED : CENTER_TRACK_TOP
|
|
const centerHazards = layoutLane(centerNormal, centerTrackTop, CENTER_TRACK_BOTTOM)
|
|
const behindGreenHazards = layoutBehindGreenLane(centerBehindGreen)
|
|
const rightHazards = layoutLane(hazards.filter((h) => h.lane === "right"))
|
|
const 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 flex -translate-x-1/2 flex-col items-center gap-0.5"
|
|
style={{ top: `${greenTopPct}%` }}
|
|
>
|
|
<img src="/hole-diagram/green-40.png" width={40} height={40} alt="" className="block" />
|
|
<span className="rounded bg-clubhouse-card px-1.5 py-0.5 text-xs font-bold text-tee-strong">
|
|
Green
|
|
</span>
|
|
</div>
|
|
|
|
{behindGreenHazards.map((h) => (
|
|
<HazardMarker key={h.key} hazard={h} topPct={h.topPct} />
|
|
))}
|
|
|
|
<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.map((h) => (
|
|
<HazardRow key={h.key} hazard={h} />
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function HazardRow({ hazard }: { hazard: DiagramHazard }) {
|
|
const meta = [
|
|
_LANE_LABEL[hazard.lane],
|
|
hazard.above === null && hazard.location ? _LOCATION_LABEL[hazard.location] : null,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" · ")
|
|
return (
|
|
<li className="flex items-center gap-3 px-3 py-2.5">
|
|
<HazardIcon kind={hazard.kind} size={36} />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-pretty text-base text-clubhouse-ink">{hazard.label}</div>
|
|
<div className="text-sm text-clubhouse-muted">{meta}</div>
|
|
</div>
|
|
{hazard.above !== null ? (
|
|
<div className="flex shrink-0 flex-col items-end text-clubhouse-ink">
|
|
<span className="text-sm font-bold tabular-nums">Forkant {formatMeters(hazard.below)}</span>
|
|
<span className="text-sm font-bold tabular-nums">Bakkant {formatMeters(hazard.above)}</span>
|
|
</div>
|
|
) : (
|
|
<span className="shrink-0 text-lg font-bold tabular-nums text-clubhouse-ink">
|
|
{formatMeters(hazard.below)}
|
|
</span>
|
|
)}
|
|
</li>
|
|
)
|
|
}
|
|
|
|
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) => {
|
|
const lane = _LANE_LABEL[h.lane].toLowerCase()
|
|
if (h.above !== null) {
|
|
return `${h.label}, ${lane}, forkant ${Math.round(h.below)} meter, bakkant ${Math.round(h.above)} meter`
|
|
}
|
|
const location = h.location ? `, ${_LOCATION_LABEL[h.location]}` : ""
|
|
return `${h.label}, ${lane}${location}, ${Math.round(h.below)} meter`
|
|
})
|
|
.join("; ")
|
|
return `${base} Hindringer: ${list}.`
|
|
}
|