teecup/frontend/components/shot/map-point-picker.tsx

347 lines
16 KiB
TypeScript
Raw Normal View History

"use client"
import { useEffect, useRef, useState } from "react"
import mapboxgl from "mapbox-gl"
import "mapbox-gl/dist/mapbox-gl.css"
import { Crosshair, MapPin } from "lucide-react"
import { haversineMeters } from "@/lib/geo"
type LngLat = { lng: number; lat: number }
/**
* MapPointPicker
*
* To ulike modi, styrt av `referencePoint`:
* - Start-steget (referencePoint IKKE satt): rendret kun når brukeren
* velger "Velg punkt på kart". Trykk-for-å-plassere en markør, ett
* engangs-GPS-oppslag kun for sentrering.
* - Ball-steget (referencePoint satt, ADR-048/049-tillegg): rendres
* ALLTID. Rent informativt kart (referansepunkt + løpende sporet
* posisjon + sanntidsavstand) -- IKKE trykkbart for å plassere ballen.
* Ballens posisjon bekreftes alltid med sporet GPS-posisjon
* ("Jeg er ved ballen nå"), aldri ved kart-trykk (brukerønske
* 2026-08-09: "bekreft ballens posisjon er unødvendig" -- fjernet
* trykk-alternativet som fantes en kort periode før dette).
*
* Alltid via next/dynamic(..., { ssr: false }) fra foreldre-komponenten.
* Mountes én gang per steg og forblir mountet til det steget forlates,
* selve Mapbox-KARTET initialiseres nøyaktig én gang (empty-deps
* useEffect) pans/zoom re-initialiserer det aldri. Dette holder
* Mapbox-kartlastninger (som faktureres) til én per steg-åpning.
*/
export function MapPointPicker({
onConfirm,
referencePoint,
referenceLabel = "Utslag",
initialBearing,
}: {
/** method er "map_tap" ved trykk-bekreftelse (kun start-steget), "gps"
* ved "Jeg er ved ballen nå"-bekreftelse (kun ball-steget). */
onConfirm: (lngLat: LngLat, method: "gps" | "map_tap") => void
/**
* Satt KUN ballposisjon-steget: et fast, ikke-flyttbart
* referansepunkt (utslagspunktet) vises kartet, og kartet forsøker å
* vise BÅDE dette og brukerens nåværende posisjon i samme bilde --
* brukerønske: "slagpunktet må være en del av det jeg ser". Trigger
* samtidig løpende posisjonssporing (se lenger ned) -- ALDRI satt
* start-steget, som fortsatt kun gjør ett engangs-oppslag.
*/
referencePoint?: LngLat
referenceLabel?: string
/**
* Kompassgrader (0-360) kartet roteres til ved oppstart, "opp
* skjermen" følger spillerens egen gangretning hullet i stedet for
* alltid å peke nord -- brukerønske 2026-08-10, løst UTEN lagrede
* green-koordinater (se bearingForShotMap() i shot-measurement-sheet.tsx,
* som regner denne ut fra spillerens EGET forrige slag hullet).
* Satt ÉN gang ved kart-initialisering, aldri oppdatert løpende -- en
* kontinuerlig rotasjon etter live GPS-heading ville vært urolig/svimlende
* i lav fart, dette er bevisst en statisk, ikke en sanntids-rotasjon.
* Udefinert/`undefined` betyr "ingen kjent retning ennå" -- kartet
* forsøker da ett engangs-fall-tilbake til enhetens `coords.heading` fra
* det første posisjons-oppslaget (se initMap under), og lander til slutt
* nord (0) hvis heller ikke det finnes.
*/
initialBearing?: number
}) {
const containerRef = useRef<HTMLDivElement | null>(null)
const mapRef = useRef<mapboxgl.Map | null>(null)
const markerRef = useRef<mapboxgl.Marker | null>(null)
const referenceMarkerRef = useRef<mapboxgl.Marker | null>(null)
const liveMarkerRef = useRef<mapboxgl.Marker | null>(null)
const watchIdRef = useRef<number | null>(null)
const [loaded, setLoaded] = useState(false)
const [point, setPoint] = useState<LngLat | null>(null)
const [error, setError] = useState<string | null>(null)
// Løpende posisjon + avstand-fra-referansepunkt-så-langt (ADR-048-tillegg
// 2026-08-08, brukerønske: "jeg ønsker å se lengden så langt i sanntid
// mens jeg nærmer meg ballen"). Kun i bruk når referencePoint er satt.
const [livePoint, setLivePoint] = useState<LngLat | null>(null)
const [liveDistance, setLiveDistance] = useState<number | null>(null)
useEffect(() => {
const token = process.env.NEXT_PUBLIC_MAPBOX_TOKEN
const container = containerRef.current
if (!container) return
if (!token) {
setError("Kartet er ikke tilgjengelig akkurat nå.")
return
}
let cancelled = false
// Reell bug funnet 2026-08-08: kartet sentrerte ALLTID på en hardkodet
// Oslo-koordinat -- en kommentar her hevdet "real app centers on
// last-known position", men den logikken fantes aldri, kun påstanden.
// Henter nå brukerens faktiske GPS-posisjon FØR kartet initialiseres
// (ett engangs-oppslag for selve sentreringen), med Oslo som fallback
// KUN hvis posisjon ikke kan hentes (avslått tillatelse, tidsavbrudd,
// ingen støtte).
// fitTo: satt når vi HAR en brukerposisjon OG et referansepunkt --
// kartet zoomer/panorerer da til å vise begge (padding rundt), i stedet
// for bare å sentrere på ett av dem, slik at referansepunktet garantert
// er synlig med det samme.
function initMap(center: [number, number], fitTo?: [[number, number], [number, number]], bearing = 0) {
if (cancelled || !container) return
mapboxgl.accessToken = token as string
const map = new mapboxgl.Map({
container,
style: "mapbox://styles/mapbox/satellite-v9",
center,
zoom: referencePoint ? 17 : 16,
bearing,
attributionControl: false,
})
mapRef.current = map
map.on("load", () => {
setLoaded(true)
if (fitTo) {
const bounds = new mapboxgl.LngLatBounds(fitTo[0], fitTo[0])
bounds.extend(fitTo[1])
map.fitBounds(bounds, { padding: 80, maxZoom: 18, duration: 0 })
}
})
map.on("error", () => setError("Kunne ikke laste kartet."))
// Fast referansemarkør (utslagspunktet) -- kan ikke flyttes, kun til
// orientering. Egen farge (samme "pin-a"-oransje som brukes i
// delings-bildet) for å skille tydelig fra punktet som plasseres.
if (referencePoint) {
referenceMarkerRef.current = new mapboxgl.Marker({ color: "#ff5a1f" })
.setLngLat([referencePoint.lng, referencePoint.lat])
.addTo(map)
}
// Tap to place/move a single draggable marker — never re-inits the map.
// KUN på start-steget (ingen referencePoint): brukerønske 2026-08-09,
// "bekreft ballens posisjon er unødvendig" -- ballens posisjon
// bekreftes nå ALLTID med sporet GPS-posisjon ("Jeg er ved ballen
// nå"), aldri ved å trykke på kartet. Kartet på ball-steget er dermed
// rent informativt (referansepunkt + sanntidsposisjon/-avstand), ikke
// interaktivt for plassering.
if (!referencePoint) {
map.on("click", (e) => {
const lngLat = { lng: e.lngLat.lng, lat: e.lngLat.lat }
if (!markerRef.current) {
const marker = new mapboxgl.Marker({ draggable: true, color: "#d2551a" })
.setLngLat(e.lngLat)
.addTo(map)
marker.on("dragend", () => {
const p = marker.getLngLat()
setPoint({ lng: p.lng, lat: p.lat })
})
markerRef.current = marker
} else {
markerRef.current.setLngLat(e.lngLat)
}
setPoint(lngLat)
})
}
}
const OSLO_FALLBACK: [number, number] = [10.7522, 59.9139]
const fallbackCenter: [number, number] = referencePoint
? [referencePoint.lng, referencePoint.lat]
: OSLO_FALLBACK
// Retning kartet roteres til: kjent spillretning (initialBearing, regnet
// ut av kalleren fra spillerens eget forrige slag på hullet) vinner
// alltid. Uten den: ett engangs-forsøk på enhetens `coords.heading` fra
// AKKURAT dette posisjonsoppslaget (kun populert når enheten beveger
// seg -- ofte null/NaN for et enkeltstående oppslag, og det er en
// akseptert, bevisst fallback til nord (0) i så fall, ikke en feil).
function resolveBearing(headingFromFix: number | null | undefined): number {
if (typeof initialBearing === "number" && Number.isFinite(initialBearing)) return initialBearing
if (typeof headingFromFix === "number" && Number.isFinite(headingFromFix)) return headingFromFix
return 0
}
if (typeof navigator !== "undefined" && navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) => {
const here: [number, number] = [pos.coords.longitude, pos.coords.latitude]
const bearing = resolveBearing(pos.coords.heading)
if (referencePoint) {
initMap(here, [here, [referencePoint.lng, referencePoint.lat]], bearing)
} else {
initMap(here, undefined, bearing)
}
},
() => initMap(fallbackCenter, undefined, resolveBearing(null)),
{ enableHighAccuracy: true, timeout: 8000 },
)
} else {
initMap(fallbackCenter, undefined, resolveBearing(null))
}
// Løpende posisjonssporing -- KUN på ballposisjon-steget (referencePoint
// satt). Egen, bevisst unntak fra ADR-048s opprinnelige "ingen løpende
// watchPosition"-prinsipp (som gjaldt kostnadskontroll for KART-
// lastninger): watchPosition i seg selv koster ingenting hos Mapbox --
// det er ren nettleser-GPS, ikke et kartlastnings-kall -- og brukeren
// ba eksplisitt om sanntids-avstand mens de går mot ballen. Kartet
// forblir uansett kun ÉN instans (samme map-objekt gjenbrukes, aldri
// re-initialisert), kun MARKØRPOSISJONEN oppdateres løpende.
if (referencePoint && typeof navigator !== "undefined" && navigator.geolocation) {
watchIdRef.current = navigator.geolocation.watchPosition(
(pos) => {
if (cancelled) return
const p = { lng: pos.coords.longitude, lat: pos.coords.latitude }
setLivePoint(p)
setLiveDistance(haversineMeters(referencePoint, p))
if (mapRef.current) {
if (!liveMarkerRef.current) {
const el = document.createElement("div")
el.className = "size-4 rounded-full border-2 border-white bg-[#2b7fff] shadow-md"
liveMarkerRef.current = new mapboxgl.Marker({ element: el }).setLngLat([p.lng, p.lat]).addTo(mapRef.current)
} else {
liveMarkerRef.current.setLngLat([p.lng, p.lat])
}
}
},
() => {
// Feil/avslått tillatelse underveis -- degraderer grasiøst, bruker
// kan fortsatt trykke selv ut ballens posisjon på kartet.
},
{ enableHighAccuracy: true, maximumAge: 1000 },
)
}
return () => {
cancelled = true
if (watchIdRef.current !== null && typeof navigator !== "undefined" && navigator.geolocation) {
navigator.geolocation.clearWatch(watchIdRef.current)
}
liveMarkerRef.current?.remove()
liveMarkerRef.current = null
referenceMarkerRef.current?.remove()
referenceMarkerRef.current = null
markerRef.current?.remove()
markerRef.current = null
mapRef.current?.remove()
mapRef.current = null
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- montert én gang per steg, se komponent-docstring
}, [])
return (
<div className="flex h-full flex-col">
<div className="relative flex-1">
{/* Map canvas host kept mounted for the map's whole lifetime.
`h-full w-full`, IKKE `absolute inset-0`: mapbox-gl.css sin egen
`.mapboxgl-map { position: relative }`-regel overstyrer en
`absolute`-Tailwind-klasse her (samme klasse-navn, senere i
cascade siden mapbox-gl.css importeres i denne komponentfilen)
-- når posisjonen dermed blir `relative` i stedet for
`absolute`, mister `inset-0` all effekt størrelsen (den
styrer kun posisjon/størrelse for absolutt/fixed-plasserte
elementer), og beholderen kollapser til 0 høyde uansett hvilken
posisjonsverdi som vinner kappløpet. `h-full`/`w-full` løser
størrelsen via prosent-arv i stedet, som fungerer likt for
både `relative` og `absolute`. Reell bug funnet 2026-08-08,
se CHANGELOG.md. */}
<div ref={containerRef} className="absolute inset-0 h-full w-full" aria-label="Satellittkart" role="application" />
{!loaded && !error ? (
<div className="absolute inset-0 flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<div className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
<p className="text-base text-muted-foreground">Laster kart </p>
</div>
</div>
) : null}
{error ? (
<div className="absolute inset-0 flex items-center justify-center p-6">
<p role="alert" className="max-w-sm text-center text-base text-destructive">
{error}
</p>
</div>
) : null}
{referencePoint ? (
<div className="pointer-events-none absolute left-4 top-4 flex items-center gap-2 rounded-full bg-card/95 px-3 py-1.5 text-xs font-semibold text-foreground shadow-md">
<span className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: "#ff5a1f" }} aria-hidden="true" />
{referenceLabel}
</div>
) : null}
{/* Trykk-instruksjon KUN på start-steget -- ball-steget har ingen
trykk-handling lenger (se click-handler-kommentaren over). */}
{loaded && !point && !error && !referencePoint ? (
<div className="pointer-events-none absolute inset-x-0 top-4 flex justify-center px-4">
<p className="flex items-center gap-2 rounded-full bg-card/95 px-4 py-2 text-sm font-semibold text-foreground shadow-md">
<MapPin className="size-4 shrink-0" aria-hidden="true" />
Trykk kartet for å plassere punktet
</p>
</div>
) : null}
{/* Sanntids-avstand så langt -- kun ball-steget. Brukerønske
2026-08-08: "jeg ønsker å se lengden langt i sanntid mens
jeg nærmer meg ballen". */}
{referencePoint && liveDistance !== null ? (
<div className="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-4">
<div className="flex flex-col items-center gap-0.5 rounded-2xl bg-card/95 px-5 py-2.5 shadow-md">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Avstand langt
</span>
<span className="text-3xl font-extrabold tabular-nums leading-none text-foreground">
{Math.round(liveDistance)}
<span className="ml-1 text-lg font-bold text-muted-foreground">m</span>
</span>
</div>
</div>
) : null}
</div>
<div className="shrink-0 border-t border-border bg-background p-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
{referencePoint ? (
<button
type="button"
disabled={!livePoint}
onClick={() => livePoint && onConfirm(livePoint, "gps")}
className="inline-flex min-h-14 w-full items-center justify-center gap-2 rounded-xl bg-primary px-6 text-lg font-bold text-primary-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
<Crosshair className="size-5 shrink-0" aria-hidden="true" />
Jeg er ved ballen
</button>
) : (
<button
type="button"
disabled={!point}
onClick={() => point && onConfirm(point, "map_tap")}
className="inline-flex min-h-14 w-full items-center justify-center rounded-xl bg-primary px-6 text-lg font-bold text-primary-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
Bekreft punkt
</button>
)}
</div>
</div>
)
}
export default MapPointPicker