teecup/frontend/components/target-distance.tsx
Erol Haagenrud 6f79df6eab Visuelt hull-diagram for rangefinderen (tee nederst, green øverst)
Fikser to ting rapportert fra ekte produksjonsskjermbilde: (1) kun
nærmeste hindring ble vist -- target-distance.tsx viser nå ALLE
hindringer, sortert nærmest først, uavhengig av om et diagram kan
tegnes. (2) Nytt hull-diagram (hole-diagram-view.tsx) plasserer
hindringer og spiller i 2D langs en fast tee->green-akse, tee nederst/
green øverst konsistent uansett gangretning.

Ny geometri-modul frontend/lib/hole-geometry.ts (projectOntoAxis,
bygget på eksisterende haversineMeters/bearingDegrees) med 10
enhetstester som beviser matematikken riktig før noe visuelt ble
bygget. Verifisert i scratch mot presist kjente syntetiske koordinater
-- alle tall og posisjoner stemte eksakt med håndregnede fasitverdier.

Midlertidig hånd-kodet visning, venter på V0-eksport for den polerte
versjonen (samme mønster som FlagPlantSheet/PlayerImportPanel). Ingen
migrasjon, ingen backend-endring.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 16:46:50 +02:00

192 lines
5.5 KiB
TypeScript

"use client"
import { TriangleAlert, Satellite } from "lucide-react"
import { cn } from "@/lib/utils"
/**
* TargetDistance — ren visningskomponent for gjenstående avstand til green.
*
* "Dum" med vilje: den mottar ferdigregnede tall som props. Ingen GPS,
* ingen fetch, ingen kart. Følger TeeCups clubhouse-palett (grønn/oransje
* aksenter, samme korthjørner/skygge-stil som resten av scorekortet).
*/
export type TargetDistanceProps = {
distances: {
front: number // meter
middle: number
back: number
} | null // null = ingen koordinatdata for dette hullet (normal, forventet tilstand)
/** ALLE hindringer på hullet (ikke bare nærmeste, se ADR-083), sortert
* nærmest spilleren først -- kalleren styrer rekkefølgen. */
hazards?: {
label: string // f.eks. "Bunker (front)"
distanceMeters: number
}[]
/** "compact" = badge ved siden av GIR-merket, "full" = eget panel. */
size?: "compact" | "full"
className?: string
}
/** Én hindring-linje. Oransje varsel, ikke rødt. */
function HazardLine({
hazard,
compact,
}: {
hazard: { label: string; distanceMeters: number }
compact?: boolean
}) {
return (
<p
className={cn(
"inline-flex items-center gap-1.5 font-medium text-cup-strong",
compact ? "text-[11px]" : "text-sm",
)}
>
<TriangleAlert aria-hidden="true" className={compact ? "size-3.5 shrink-0" : "size-4 shrink-0"} />
<span className="text-pretty">
{hazard.label}: <span className="tabular-nums font-bold">{hazard.distanceMeters}</span> m
</span>
</p>
)
}
/** Alle hindringer, stablet -- erstatter den gamle "kun nærmeste"-visningen. */
function HazardList({
hazards,
compact,
}: {
hazards: { label: string; distanceMeters: number }[]
compact?: boolean
}) {
if (hazards.length === 0) return null
return (
<div className={cn("flex flex-col", compact ? "gap-0.5" : "gap-1")}>
{hazards.map((h, i) => (
<HazardLine key={`${h.label}-${i}`} hazard={h} compact={compact} />
))}
</div>
)
}
/** En avstandskolonne (Front/Midt/Bak). Alltid tekst-label + tall + enhet. */
function DistanceColumn({
label,
value,
emphasized,
compact,
}: {
label: string
value: number
emphasized?: boolean
compact?: boolean
}) {
return (
<div className="flex flex-col items-center justify-end gap-0.5">
<span
className={cn(
"font-semibold uppercase tracking-wide text-clubhouse-muted",
compact ? "text-[10px]" : "text-xs",
)}
>
{label}
</span>
<span
className={cn(
"flex items-baseline gap-0.5 leading-none tabular-nums",
emphasized ? "text-tee-strong" : "text-clubhouse-ink",
// Stort nok til å leses på avstand, i sollys — som en klokke-visning.
compact
? emphasized
? "text-3xl font-black"
: "text-xl font-bold"
: emphasized
? "text-6xl font-black"
: "text-4xl font-bold",
)}
>
{value}
<span
className={cn(
"font-semibold text-clubhouse-muted",
compact ? "text-[10px]" : "text-sm",
)}
>
m
</span>
</span>
</div>
)
}
/** Nøytral, lavmælt placeholder når banen ikke har koordinatdata. Aldri en feilmelding. */
function NoData({ compact, className }: { compact?: boolean; className?: string }) {
return (
<div
role="note"
className={cn(
"inline-flex items-center gap-2 rounded-xl border border-clubhouse-border bg-clubhouse-field text-clubhouse-muted",
compact ? "px-2.5 py-1.5 text-[11px]" : "px-4 py-3 text-sm",
className,
)}
>
<Satellite aria-hidden="true" className={compact ? "size-3.5" : "size-4"} />
<span>Avstand ikke tilgjengelig</span>
</div>
)
}
export function TargetDistance({
distances,
hazards = [],
size = "full",
className,
}: TargetDistanceProps) {
const compact = size === "compact"
// Punkt 4: ingen data er en normal, forventet tilstand — vis en nøytral placeholder.
if (!distances) {
return <NoData compact={compact} className={className} />
}
if (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">
<DistanceColumn label="Front" value={distances.front} compact />
<DistanceColumn label="Senter" value={distances.middle} emphasized compact />
<DistanceColumn label="Bak" value={distances.back} compact />
</div>
<HazardList hazards={hazards} compact />
</div>
)
}
return (
<section
aria-label="Avstand til green"
className={cn(
"flex w-full flex-col gap-3 rounded-2xl border border-clubhouse-border bg-clubhouse-card p-5 shadow-sm",
className,
)}
>
<h3 className="text-sm font-semibold uppercase tracking-wide text-clubhouse-muted">Til green</h3>
<div className="flex items-end justify-between gap-2">
<DistanceColumn label="Front" value={distances.front} />
<DistanceColumn label="Senter" value={distances.middle} emphasized />
<DistanceColumn label="Bak" value={distances.back} />
</div>
{hazards.length > 0 && (
<div className="border-t border-clubhouse-border pt-3">
<HazardList hazards={hazards} />
</div>
)}
</section>
)
}