"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" | "fairway" | "stone_fence" | "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 = { 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> = { 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", fairway: "/hole-diagram/hazard-fairway.png", stone_fence: "/hole-diagram/hazard-stonefence.png", } function HazardIcon({ kind, size = 32 }: { kind: HazardKind; size?: number }) { const src = _ICON_SRC[kind] if (src) { return } return } /** 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 ( ) } export function HoleDiagram({ distances, playerPosition, hazards, size = "full", className }: Props) { if (size === "compact") { return (
{hazards.length > 0 && (
    {hazards.map((h) => (
  • ))}
)}
) } 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 (
{/* Venstre bane */}
{leftHazards.map((h) => ( ))}
{/* Midtbane -- eneste bane med green/tee/spiller, alltid faste ankre her */}
{behindGreenHazards.map((h) => ( ))} {centerHazards.map((h) => ( ))}
{/* Høyre bane */}
{rightHazards.map((h) => ( ))}
{hazards.length > 0 && (

Hindringer

    {hazards.map((h) => ( ))}
)}
) } 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 (
  • {hazard.label}
    {meta}
    {hazard.above !== null ? (
    Forkant {formatMeters(hazard.below)} Bakkant {formatMeters(hazard.above)}
    ) : ( {formatMeters(hazard.below)} )}
  • ) } function BigNumber({ label, value, emphasized }: { label: string; value: number; emphasized?: boolean }) { return (
    {Math.round(value)} {label}
    ) } function CompactNumber({ label, value, emphasized }: { label: string; value: number; emphasized?: boolean }) { return (
    {Math.round(value)} {label}
    ) } 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}.` }