198 lines
6.8 KiB
TypeScript
198 lines
6.8 KiB
TypeScript
|
|
"use client"
|
||
|
|
|
||
|
|
// MIDLERTIDIG hånd-kodet hull-diagram (ADR-083, 2026-08-17) -- bygget for
|
||
|
|
// å bevise geometrikjeden (frontend/lib/hole-geometry.ts) fungerer riktig
|
||
|
|
// ende-til-ende FØR en V0-prompt skrives, samme rekkefølge som
|
||
|
|
// FlagPlantSheet/PlayerImportPanel tidligere denne økten. Erstattes med en
|
||
|
|
// V0-eksportert visning senere -- selve geometri-utregningen (i
|
||
|
|
// hole-target-distance.tsx) endres ikke ved bytte, kun denne rene
|
||
|
|
// 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 { cn } from "@/lib/utils"
|
||
|
|
|
||
|
|
type DiagramHazard = {
|
||
|
|
label: string
|
||
|
|
distanceMeters: number
|
||
|
|
alongPercent: number
|
||
|
|
crossMeters: number
|
||
|
|
}
|
||
|
|
|
||
|
|
export function HoleDiagram({
|
||
|
|
distances,
|
||
|
|
playerPosition,
|
||
|
|
hazards,
|
||
|
|
size = "full",
|
||
|
|
className,
|
||
|
|
}: {
|
||
|
|
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") {
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
"inline-flex flex-col gap-1 rounded-xl border border-clubhouse-border bg-clubhouse-card px-3 py-2 shadow-sm",
|
||
|
|
className,
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<div className="flex items-end gap-3">
|
||
|
|
<NumberColumn label="Front" value={distances.front} />
|
||
|
|
<NumberColumn label="Senter" value={distances.middle} emphasized />
|
||
|
|
<NumberColumn label="Bak" value={distances.back} />
|
||
|
|
</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 && (
|
||
|
|
<div className="border-t border-clubhouse-border pt-3">
|
||
|
|
<HazardTextList hazards={hazards} />
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</section>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
const LANE_HEIGHT_PX = 260
|
||
|
|
// Faktisk fairway-bredde er ikke kjent fra dataen -- klipper visuelt til
|
||
|
|
// ±30 m fra midtlinjen, en fornuftig typisk verdi (se ADR-083). Selve
|
||
|
|
// TALLENE (distanceMeters) er alltid nøyaktige uansett -- kun selve
|
||
|
|
// PLASSERINGEN i tegningen klippes.
|
||
|
|
const MAX_CROSS_METERS = 30
|
||
|
|
|
||
|
|
function clampAlong(percent: number): number {
|
||
|
|
return Math.min(100, Math.max(0, percent))
|
||
|
|
}
|
||
|
|
|
||
|
|
function crossToOffsetPercent(crossMeters: number): number {
|
||
|
|
const clamped = Math.min(MAX_CROSS_METERS, Math.max(-MAX_CROSS_METERS, 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 (
|
||
|
|
<div
|
||
|
|
className="relative w-full overflow-hidden rounded-xl border border-clubhouse-border bg-clubhouse-field"
|
||
|
|
style={{ height: LANE_HEIGHT_PX }}
|
||
|
|
>
|
||
|
|
{/* Midtlinje -- ren visuell referanse, ikke en ekte fairway-kant. */}
|
||
|
|
<div className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-clubhouse-border" aria-hidden="true" />
|
||
|
|
|
||
|
|
{/* 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>
|
||
|
|
|
||
|
|
{/* Tee -- fast nederst. */}
|
||
|
|
<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
|
||
|
|
key={`${h.label}-${i}`}
|
||
|
|
className="absolute flex -translate-x-1/2 translate-y-1/2 flex-col items-center gap-0.5"
|
||
|
|
style={{
|
||
|
|
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" />
|
||
|
|
<span className="whitespace-nowrap rounded-md bg-clubhouse-card px-1 text-[10px] font-bold tabular-nums text-cup-strong shadow-sm">
|
||
|
|
{h.distanceMeters}m
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
|
||
|
|
{/* Spilleren -- "du er her". */}
|
||
|
|
<div
|
||
|
|
className="absolute -translate-x-1/2 translate-y-1/2"
|
||
|
|
style={{
|
||
|
|
bottom: `${clampAlong(playerPosition.alongPercent)}%`,
|
||
|
|
left: `calc(50% + ${crossToOffsetPercent(playerPosition.crossMeters)}%)`,
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<MapPin aria-label="Din posisjon" className="size-6 fill-tee-strong text-clubhouse-card" />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
function NumberColumn({
|
||
|
|
label,
|
||
|
|
value,
|
||
|
|
emphasized,
|
||
|
|
}: {
|
||
|
|
label: string
|
||
|
|
value: number
|
||
|
|
emphasized?: boolean
|
||
|
|
}) {
|
||
|
|
return (
|
||
|
|
<div className="flex flex-col items-center justify-end gap-0.5">
|
||
|
|
<span className="text-xs font-semibold uppercase tracking-wide text-clubhouse-muted">{label}</span>
|
||
|
|
<span
|
||
|
|
className={cn(
|
||
|
|
"flex items-baseline gap-0.5 leading-none tabular-nums",
|
||
|
|
emphasized ? "text-6xl font-black text-tee-strong" : "text-4xl font-bold text-clubhouse-ink",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{value}
|
||
|
|
<span className="text-sm font-semibold text-clubhouse-muted">m</span>
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
function HazardTextList({ hazards, compact }: { hazards: DiagramHazard[]; compact?: boolean }) {
|
||
|
|
if (hazards.length === 0) return null
|
||
|
|
return (
|
||
|
|
<ul className={cn("flex flex-col", compact ? "gap-0.5" : "gap-1")}>
|
||
|
|
{hazards.map((h, i) => (
|
||
|
|
<li
|
||
|
|
key={`${h.label}-${i}`}
|
||
|
|
className={cn("font-medium text-cup-strong", compact ? "text-[11px]" : "text-sm")}
|
||
|
|
>
|
||
|
|
{h.label}: <span className="tabular-nums font-bold">{h.distanceMeters}</span> m
|
||
|
|
</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
)
|
||
|
|
}
|