diff --git a/frontend/components/hole-target-distance.tsx b/frontend/components/hole-target-distance.tsx index d2f3d50..c1b955c 100644 --- a/frontend/components/hole-target-distance.tsx +++ b/frontend/components/hole-target-distance.tsx @@ -191,13 +191,15 @@ function groupDiagramHazards( location: p.location, }) } - // ADR-083-konvensjonen ("sortert nærmest spilleren først") manglet her -- - // uten denne falt rekkefølgen tilbake til rå API-/innsettingsrekkefølge, - // som ikke sier noe om avstand og heller ikke oppdaterer seg når - // spilleren beveger seg. `below` er alltid den nærmeste av forkant/ - // enkeltpunkt-avstanden, så stigende sortering på den gir riktig - // "nærmest spilleren først" for både parrede og enkeltstående hindringer. - return entities.sort((a, b) => a.below - b.below) + // Rettet 2026-08-21 (brukerfunn, skjermdump): listen skal lese som + // kartet den står under -- holdt loddrett med greenen øverst (lengst + // unna) og utslaget nederst (nærmest), skal hindringene imellom følge + // SAMME rekkefølge, ikke motsatt. Sorterer derfor synkende på + // BAKKANTEN når en finnes (`above` -- den ferneste kanten av en parret + // hindring, eksplisitt bedt om av bruker: "avstanden til bakkant [som] + // vi hensyntar når vi skal sortere"), ellers enkeltpunktets egen + // avstand (`below`, kun eksisterende verdi for et upar ret punkt). + return entities.sort((a, b) => (b.above ?? b.below) - (a.above ?? a.below)) } export function HoleTargetDistance({ @@ -314,9 +316,13 @@ export function HoleTargetDistance({ } // ADR-083: ALLE hindringer, ikke bare nærmeste (tidligere reduserte denne - // løkken til ett enkelt nearestHazard-objekt) -- sortert nærmest - // spilleren først. Avstandene er ALLTID live fra spilleren, uavhengig av - // om et geometrisk diagram kan tegnes under. + // løkken til ett enkelt nearestHazard-objekt). Avstandene er ALLTID live + // fra spilleren, uavhengig av om et geometrisk diagram kan tegnes under. + // Sortert lengst unna FØRST (2026-08-21, samme retting/begrunnelse som + // groupDiagramHazards under -- listen leses loddrett med greenen + // øverst) -- denne grenen (ingen tee-koordinater) har ingen forkant/ + // bakkant-parring i det hele tatt, kun ett enkelt avstandstall per + // punkt, så ingen "bakkant"-nyanse å ta hensyn til her. const hazardPoints = points.filter((p) => p.poi_type in _HAZARD_LABELS) // ADR-085 (brukerønske 2026-08-20): passerte hindringer skal ikke vises. // Denne (tee-referanse mangler) grenen har ingen akse å projisere langs, @@ -336,7 +342,7 @@ export function HoleTargetDistance({ : base return { label, distanceMeters: Math.round(haversineMeters(livePosition, { lat: h.latitude, lng: h.longitude })), lat: h.latitude, lng: h.longitude } }) - .sort((a, b) => a.distanceMeters - b.distanceMeters) + .sort((a, b) => b.distanceMeters - a.distanceMeters) // Fast tee-referanse (IKKE spillerens bevegelige livePosition) for selve // hull-diagrammet -- se ADR-083. Midtpunkt hvis begge tee_front/tee_back diff --git a/frontend/components/round-detail.tsx b/frontend/components/round-detail.tsx index 6aa68fc..7fa2dcc 100644 --- a/frontend/components/round-detail.tsx +++ b/frontend/components/round-detail.tsx @@ -60,6 +60,7 @@ import { ShotMeasurementSheet } from "@/components/shot/shot-measurement-sheet" import { HoleTargetDistance } from "@/components/hole-target-distance" import { HoleHistoryDetail } from "@/components/hole-history-detail" import { bearingDegrees } from "@/lib/geo" +import { midpoint } from "@/lib/hole-geometry" import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue" // --- Types ----------------------------------------------------------------- @@ -2431,25 +2432,69 @@ function ShotMeasurementEntry({ }, [urlBase]) const count = shots.length - // Kartretning ved slagmåling (brukerønske 2026-08-10): "opp på skjermen" - // følger spillerens EGEN gangretning på hullet, uten lagrede green- - // koordinater. Regnet ut fra det SISTE tidligere målte slaget på dette - // hullet (start→slutt-retningen) -- `shots` er allerede sortert - // stigende på shot_number av GET-endepunktet (se _list_shots_for_hole i - // rounds.py), så siste element ER forrige slag. `undefined` (ingen - // rotasjon regnet ut ennå) for det aller første slaget på hullet -- - // MapPointPicker faller da selv tilbake til enhetens GPS-heading/nord, - // se den komponentens dokumentasjon. Bevisst en STATISK retning, satt - // idet arket åpnes -- ikke løpende oppdatert (ville vært urolig ved lav + // Ekte banegeometri (2026-08-21, brukerønske) -- når hullet har BÅDE + // utslags- OG green-koordinater kjent (samme datakilde som + // HoleTargetDistance/HoleDiagram bruker), skal kartretningen ALLTID + // følge utslag->green, ikke gjettes fra spillerens forrige slag (som + // kan avvike, f.eks. en slice/hook). Egen, liten fetch her (i stedet + // for å prop-drille et tall gjennom RoundDetail/PlayerHoleCards/ + // ScoringWizard -- ShotMeasurementEntry brukes fra tre atskilte steder + // i denne filen, kun roundId+holeNumber er felles for alle) -- ett + // ekstra, lett GET-kall per spillerkort er en akseptert kostnad for at + // ALLE tre inngangene til kartet får riktig retning, ikke bare én. + const [courseBearing, setCourseBearing] = useState(undefined) + useEffect(() => { + let cancelled = false + setCourseBearing(undefined) + fetch(`/rounds/${roundId}/holes/${holeNumber}/target-points`, { credentials: "include" }) + .then((res) => (res.ok ? res.json() : [])) + .then((points: { poi_type: string; location: string | null; latitude: number; longitude: number }[]) => { + if (cancelled) return + const middle = points.find((p) => p.poi_type === "green" && p.location === "middle") + const teeFront = points.find((p) => p.poi_type === "tee_front") + const teeBack = points.find((p) => p.poi_type === "tee_back") + const teeRef = + teeFront && teeBack + ? midpoint({ lat: teeFront.latitude, lng: teeFront.longitude }, { lat: teeBack.latitude, lng: teeBack.longitude }) + : teeFront + ? { lat: teeFront.latitude, lng: teeFront.longitude } + : teeBack + ? { lat: teeBack.latitude, lng: teeBack.longitude } + : null + setCourseBearing( + teeRef && middle ? bearingDegrees(teeRef, { lat: middle.latitude, lng: middle.longitude }) : undefined, + ) + }) + .catch(() => { + if (!cancelled) setCourseBearing(undefined) + }) + return () => { + cancelled = true + } + }, [roundId, holeNumber]) + + // Kartretning ved slagmåling (brukerønske 2026-08-10, utvidet + // 2026-08-21): "opp på skjermen" følger banens EGEN spillretning + // (utslag->green, courseBearing over) når den er kjent -- ellers + // faller tilbake til spillerens EGEN gangretning, regnet ut fra det + // SISTE tidligere målte slaget på dette hullet (start→slutt-retningen) + // -- `shots` er allerede sortert stigende på shot_number av GET- + // endepunktet (se _list_shots_for_hole i rounds.py), så siste element + // ER forrige slag. `undefined` (ingen rotasjon regnet ut ennå) for det + // aller første slaget på et hull uten kjent banegeometri -- MapPoint- + // Picker faller da selv tilbake til enhetens GPS-heading/nord, se den + // komponentens dokumentasjon. Bevisst en STATISK retning, satt idet + // arket åpnes -- ikke løpende oppdatert (ville vært urolig ved lav // gangfart), se map-point-picker.tsx. const initialBearing = useMemo(() => { + if (typeof courseBearing === "number") return courseBearing const previous = shots[shots.length - 1] if (!previous) return undefined return bearingDegrees( { lat: previous.start_lat, lng: previous.start_lng }, { lat: previous.end_lat, lng: previous.end_lng }, ) - }, [shots]) + }, [shots, courseBearing]) async function deleteShot(shotId: string) { const res = await fetch(`/rounds/${roundId}/shots/${shotId}`, { method: "DELETE", credentials: "include" }) @@ -6812,6 +6857,35 @@ function PlayerHoleCards({ window.removeEventListener("resize", check) } }, [mapExpanded]) + // Den flytende knappen ble opprinnelig styrt av `mapExpanded` alene -- + // FEIL, funnet av bruker 2026-08-20: den IKKE-utvidede ("compact") + // visningen viser OGSÅ hele hindringslisten (ADR-083, alle hindringer, + // ikke bare nærmeste), som på hull med mange hindringer alene kan bli + // høy nok til å dytte den vanlige, side-flyt-plasserte knappen utenfor + // synlig område -- UTEN at banekartet noensinne ble utvidet. Riktig + // signal er derfor ikke "er kartet utvidet", men "er selve + // side-flyt-knappen faktisk synlig akkurat nå" -- målt direkte, samme + // mønster som `playerListReached` over (ikke IntersectionObserver, av + // samme grunn som der: unngår enhver antakelse om når komponenten + // regnes som "utvidet" eller ikke -- fungerer likt uansett ÅRSAK til at + // knappen er utenfor synlig område). + const inFlowButtonRef = useRef(null) + const [inFlowButtonVisible, setInFlowButtonVisible] = useState(true) + useEffect(() => { + function check() { + const el = inFlowButtonRef.current + if (!el) return + const rect = el.getBoundingClientRect() + setInFlowButtonVisible(rect.bottom > getHeaderHeight() && rect.top < window.innerHeight) + } + check() + window.addEventListener("scroll", check, { passive: true }) + window.addEventListener("resize", check) + return () => { + window.removeEventListener("scroll", check) + window.removeEventListener("resize", check) + } + }, [mapExpanded, distanceAvailable]) // Manuell scroll (ikke `scrollIntoView` + CSS `scroll-margin-top`) -- // samme begrunnelse som `getHeaderHeight()` over: en CSS-klasse som // `scroll-mt-28` kan ikke være dynamisk, og headerens høyde varierer @@ -6954,18 +7028,18 @@ function PlayerHoleCards({ )} - {/* Alltid synlig, tydelig snarvei rett til score-registreringen under - -- samme grunn som knappen over: en førstegangsbruker skal ikke - måtte oppdage ved et tilfeldig sveip at dette finnes. KUN i normal - side-flyt når banekartet er kollapset -- utvidet blir den samme - knappen flytende i stedet, se render-stedet nederst i denne - komponenten (brukerønske 2026-08-20: det utvidede diagrammet - kunne dytte knappen helt utenfor synlig skjermbilde). */} - {!mapExpanded && ( + {/* Alltid i normal side-flyt, uansett banekart-tilstand (rettet + 2026-08-20 -- se `inFlowButtonVisible`-kommentaren over: den + MÅTTE bli værende i flyten uansett, siden en lang hindringsliste + alene kan dytte den ut av syne selv når kartet ALDRI ble utvidet). + `ref` brukt til å måle om DENNE knappen faktisk er synlig -- den + flytende versjonen (render-stedet nederst i komponenten) dukker + kun opp når den IKKE er det. */} +
- )} +
{isTwoSided && sides.length === 2 && (
@@ -7206,24 +7280,26 @@ function PlayerHoleCards({ /> )} - {/* Flytende "Registrer score" (V0-eksport, 2026-08-20) -- mens - banekartet er utvidet OG spillerlisten fortsatt er utenfor synlig - område (se `playerListReached` over -- uten denne betingelsen hang - knappen igjen synlig, og dekket delvis over spillerkort 1, selv - etter at brukeren allerede hadde scrollet dit). `fixed` - posisjonerer mot selve viewporten (ingen ancestor her setter - transform/filter, så det er trygt), gradient-scrim + skygge - løfter den visuelt over innholdet under, `env(safe-area-inset- - bottom)` unngår at den klemmes helt inntil kanten på telefoner - med gest-navigasjon. `pointer-events-none` på selve scrim-sonen - når skjult/i overgang, slik at den aldri blokkerer klikk på - innhold bak. */} + {/* Flytende "Registrer score" (V0-eksport, 2026-08-20, utløser- + logikk rettet samme dag) -- vises når side-flyt-knappen over + IKKE er synlig (uansett ÅRSAK: utvidet banekart ELLER en lang + hindringsliste alene, se `inFlowButtonVisible`-kommentaren over) + OG spillerlisten fortsatt er utenfor synlig område (se + `playerListReached` -- uten denne dekket den delvis over + spillerkort 1 selv etter at brukeren allerede hadde scrollet + dit). `fixed` posisjonerer mot selve viewporten (ingen ancestor + her setter transform/filter, så det er trygt), gradient-scrim + + skygge løfter den visuelt over innholdet under, + `env(safe-area-inset-bottom)` unngår at den klemmes helt inntil + kanten på telefoner med gest-navigasjon. `pointer-events-none` + på selve scrim-sonen når skjult/i overgang, slik at den aldri + blokkerer klikk på innhold bak. */}
@@ -7243,10 +7319,8 @@ function PlayerHoleCards({
{/* Gir siste innhold på siden rom til å scrolle klar av den flytende knappen i stedet for å bli permanent skjult bak den -- samme - betingelse som selve knappen (2026-08-20-rettelsen), ellers ble - det stående et tomt, uforklart mellomrom selv etter at knappen - alt var skjult. */} - {mapExpanded && !playerListReached &&