"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, så * 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", }: { /** 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 på ballposisjon-steget: et fast, ikke-flyttbart * referansepunkt (utslagspunktet) vises på 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 på * start-steget, som fortsatt kun gjør ett engangs-oppslag. */ referencePoint?: LngLat referenceLabel?: string }) { const containerRef = useRef(null) const mapRef = useRef(null) const markerRef = useRef(null) const referenceMarkerRef = useRef(null) const liveMarkerRef = useRef(null) const watchIdRef = useRef(null) const [loaded, setLoaded] = useState(false) const [point, setPoint] = useState(null) const [error, setError] = useState(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(null) const [liveDistance, setLiveDistance] = useState(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]]) { 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, 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 if (typeof navigator !== "undefined" && navigator.geolocation) { navigator.geolocation.getCurrentPosition( (pos) => { const here: [number, number] = [pos.coords.longitude, pos.coords.latitude] if (referencePoint) { initMap(here, [here, [referencePoint.lng, referencePoint.lat]]) } else { initMap(here) } }, () => initMap(fallbackCenter), { enableHighAccuracy: true, timeout: 8000 }, ) } else { initMap(fallbackCenter) } // 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 (
{/* 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 på 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. */}
{!loaded && !error ? (

Laster kart …

) : null} {error ? (

{error}

) : null} {referencePoint ? (
) : null} {/* Trykk-instruksjon KUN på start-steget -- ball-steget har ingen trykk-handling lenger (se click-handler-kommentaren over). */} {loaded && !point && !error && !referencePoint ? (

) : null} {/* Sanntids-avstand så langt -- kun ball-steget. Brukerønske 2026-08-08: "jeg ønsker å se lengden så langt i sanntid mens jeg nærmer meg ballen". */} {referencePoint && liveDistance !== null ? (
Avstand så langt {Math.round(liveDistance)} m
) : null}
{referencePoint ? ( ) : ( )}
) } export default MapPointPicker