Del A (ADR-066, migrasjon 069/070, allerede rullet ut mot teecup_db): "Plant flagget" via GPS, med en interaktiv "Hullet du ut?"-bekreftelse før posisjonen registreres, pluss full runde 2+-scoreføring for spillere som fullfører 18 hull med slag igjen. Ny, isolert overflow- tabell (rører ikke delt round_hole/tournament_round_hole). Bygget for både frittstående runder og org-individuelle turneringer. UI-sheeten er en Claude-skrevet V0-eksport (zip 11). Del B (ADR-067, migrasjon 071, klar for utrulling): av/på-bryter (standard AV) for en satellittkartoversikt over ALLE deltakeres plantede flagg, lap 1 skilt fra lap 2+ med farge+tekstbadge. Frittstående runder får full tilskuerstøtte (/watch/[id]); org-individuelle turneringer er bevisst avgrenset til medlemmer/deltakere, siden ingen offentlig spectator-side finnes for det turneringsformatet ennå. Se ARCHITECTURE_DECISIONS.md (ADR-066/067) og CHANGELOG.md (punkt 82/83) for full begrunnelse og verifiseringslogg. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
157 lines
5.3 KiB
TypeScript
157 lines
5.3 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useRef, useState } from "react"
|
|
import mapboxgl from "mapbox-gl"
|
|
import "mapbox-gl/dist/mapbox-gl.css"
|
|
|
|
export type FlagMapEntry = {
|
|
participantId: string
|
|
displayName: string
|
|
lap: number
|
|
holeNumber: number
|
|
lat: number
|
|
lng: number
|
|
onGreen: boolean
|
|
distanceToPinCm: number | null
|
|
}
|
|
|
|
export type FlagMapOverviewProps = {
|
|
flags: FlagMapEntry[]
|
|
visibleToAll: boolean
|
|
}
|
|
|
|
// ADR-067-flagg-tillegget ("Del B", 2026-08-14) -- MIDLERTIDIG, enkel
|
|
// kartkomponent inntil V0-eksporten (prompt sendt til bruker) er klar.
|
|
// Samme props-kontrakt -- når eksporten kommer, byttes kun komponentens
|
|
// INNMAT, kalleren skal ikke måtte endres. Re-initialiserer hele kartet
|
|
// når `flags` endrer referanse (enkel, men ikke det mest effisiente --
|
|
// akseptert forenkling for den midlertidige versjonen).
|
|
export function FlagMapOverview({ flags, visibleToAll }: FlagMapOverviewProps) {
|
|
const containerRef = useRef<HTMLDivElement | null>(null)
|
|
const [loaded, setLoaded] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (flags.length === 0) return
|
|
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
|
|
}
|
|
|
|
setLoaded(false)
|
|
setError(null)
|
|
mapboxgl.accessToken = token
|
|
const map = new mapboxgl.Map({
|
|
container,
|
|
style: "mapbox://styles/mapbox/satellite-v9",
|
|
center: [flags[0].lng, flags[0].lat],
|
|
zoom: 16,
|
|
attributionControl: false,
|
|
})
|
|
|
|
map.on("load", () => {
|
|
setLoaded(true)
|
|
if (flags.length > 1) {
|
|
const bounds = new mapboxgl.LngLatBounds([flags[0].lng, flags[0].lat], [flags[0].lng, flags[0].lat])
|
|
for (const f of flags) bounds.extend([f.lng, f.lat])
|
|
map.fitBounds(bounds, { padding: 80, maxZoom: 18, duration: 0 })
|
|
}
|
|
})
|
|
map.on("error", () => setError("Kunne ikke laste kartet."))
|
|
|
|
const markers: mapboxgl.Marker[] = []
|
|
for (const f of flags) {
|
|
const el = document.createElement("div")
|
|
el.className =
|
|
"flex size-7 items-center justify-center rounded-full border-2 border-white text-[10px] font-extrabold text-white shadow-md"
|
|
el.style.backgroundColor = f.lap > 1 ? "#7c3aed" : "#ff5a1f"
|
|
el.textContent = f.lap > 1 ? `R${f.lap}` : "⚑"
|
|
|
|
const popupHtml = `
|
|
<div style="font-size:13px; line-height:1.4;">
|
|
<strong>${f.displayName}</strong><br/>
|
|
Hull ${f.holeNumber}${f.lap > 1 ? ` · runde ${f.lap}` : ""}
|
|
${
|
|
f.onGreen && f.distanceToPinCm !== null
|
|
? `<br/>${Math.floor(f.distanceToPinCm / 100)} m ${f.distanceToPinCm % 100} cm fra hullet`
|
|
: ""
|
|
}
|
|
</div>
|
|
`
|
|
const marker = new mapboxgl.Marker({ element: el })
|
|
.setLngLat([f.lng, f.lat])
|
|
.setPopup(new mapboxgl.Popup({ offset: 16 }).setHTML(popupHtml))
|
|
.addTo(map)
|
|
markers.push(marker)
|
|
}
|
|
|
|
return () => {
|
|
markers.forEach((m) => m.remove())
|
|
map.remove()
|
|
}
|
|
}, [flags])
|
|
|
|
if (flags.length === 0) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center p-6 text-center">
|
|
<p className="text-base text-muted-foreground">
|
|
{visibleToAll ? "Ingen flagg plantet ennå" : "Du har ikke plantet flagget ennå"}
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="relative h-full w-full">
|
|
<div
|
|
ref={containerRef}
|
|
className="absolute inset-0 h-full w-full"
|
|
aria-label="Kart over plantede flagg"
|
|
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}
|
|
|
|
{!visibleToAll ? (
|
|
<div className="pointer-events-none absolute inset-x-0 top-4 flex justify-center px-4">
|
|
<p className="rounded-full bg-card/95 px-4 py-2 text-sm font-semibold text-foreground shadow-md">
|
|
Kun ditt eget flagg er synlig
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-4">
|
|
<div className="flex items-center gap-3 rounded-full bg-card/95 px-4 py-2 text-xs font-semibold text-foreground shadow-md">
|
|
<span className="flex items-center gap-1.5">
|
|
<span className="size-3 rounded-full" style={{ backgroundColor: "#ff5a1f" }} aria-hidden="true" />
|
|
Runde 1
|
|
</span>
|
|
<span className="flex items-center gap-1.5">
|
|
<span className="size-3 rounded-full" style={{ backgroundColor: "#7c3aed" }} aria-hidden="true" />
|
|
Runde 2+
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default FlagMapOverview
|