Velger du kart, vises utslagspunktet som en fast oransje referansemarkør, og kartet zoomer/panorerer automatisk slik at både din posisjon og utslagspunktet er synlige med det samme. Du trykker der ballen ligger — markøren blir grønn for å skille den fra referansen.
217 lines
8.9 KiB
TypeScript
217 lines
8.9 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useRef, useState } from "react"
|
|
import mapboxgl from "mapbox-gl"
|
|
import "mapbox-gl/dist/mapbox-gl.css"
|
|
import { MapPin } from "lucide-react"
|
|
|
|
type LngLat = { lng: number; lat: number }
|
|
|
|
/**
|
|
* MapPointPicker
|
|
*
|
|
* Rendered ONLY when the user chooses "Velg punkt på kart", and always via
|
|
* next/dynamic(..., { ssr: false }) from the parent. It mounts once per
|
|
* sheet-open (once per step, since start and end use separate mounted
|
|
* instances) and stays mounted until that step is left, so the Mapbox map is
|
|
* created exactly once (empty-deps useEffect) — taps, pans and marker drags
|
|
* never re-initialise it. This keeps Mapbox map loads (which are billed) to a
|
|
* single load per step-open.
|
|
*/
|
|
export function MapPointPicker({
|
|
onConfirm,
|
|
referencePoint,
|
|
referenceLabel = "Utslag",
|
|
}: {
|
|
onConfirm: (lngLat: LngLat) => void
|
|
/**
|
|
* Satt KUN på ballposisjon-steget (ADR-048-tillegg 2026-08-08): 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".
|
|
*/
|
|
referencePoint?: LngLat
|
|
referenceLabel?: string
|
|
}) {
|
|
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 [loaded, setLoaded] = useState(false)
|
|
const [point, setPoint] = useState<LngLat | null>(null)
|
|
const [error, setError] = useState<string | 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å. Bruk «Min posisjon nå» i stedet.")
|
|
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
|
|
// (kun ETT kall, samme som "Min posisjon nå"-veien -- ingen løpende
|
|
// watchPosition), 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.
|
|
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: referencePoint ? "#2f7a3f" : "#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)
|
|
}
|
|
|
|
return () => {
|
|
cancelled = true
|
|
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 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. */}
|
|
<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}
|
|
|
|
{loaded && !point && !error ? (
|
|
<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" />
|
|
{referencePoint ? "Trykk der ballen ligger" : "Trykk på kartet for å plassere punktet"}
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="shrink-0 border-t border-border bg-background p-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
|
|
<button
|
|
type="button"
|
|
disabled={!point}
|
|
onClick={() => point && onConfirm(point)}
|
|
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"
|
|
>
|
|
{referencePoint ? "Bekreft ballens posisjon" : "Bekreft punkt"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default MapPointPicker
|