Score-fanen: flytende knapp ved mange hindringer, hindringsrekkefølge, kartretning
- Flytende "Registrer score" trigges nå av faktisk synlighet til side-flyt-knappen, ikke av om banekartet er utvidet -- en lang hindringsliste alene (uten utvidet kart) kunne dytte knappen ut av syne uten at den flytende versjonen noensinne dukket opp. - Hindringslisten sortert fra fjernest (øverst) til nærmest (nederst), matcher loddrett telefon-holdning med green øverst/utslag nederst. Bakkant brukt som sorteringsnøkkel der en finnes. - Kartretning ved slagmåling følger nå ekte utslag->green-geometri (green opp/utslag ned) når begge er kjent, vinner over den eldre forrige-slag-gjetningen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
a0163eaa16
commit
e940e932c1
2 changed files with 130 additions and 50 deletions
|
|
@ -191,13 +191,15 @@ function groupDiagramHazards(
|
||||||
location: p.location,
|
location: p.location,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// ADR-083-konvensjonen ("sortert nærmest spilleren først") manglet her --
|
// Rettet 2026-08-21 (brukerfunn, skjermdump): listen skal lese som
|
||||||
// uten denne falt rekkefølgen tilbake til rå API-/innsettingsrekkefølge,
|
// kartet den står under -- holdt loddrett med greenen øverst (lengst
|
||||||
// som ikke sier noe om avstand og heller ikke oppdaterer seg når
|
// unna) og utslaget nederst (nærmest), skal hindringene imellom følge
|
||||||
// spilleren beveger seg. `below` er alltid den nærmeste av forkant/
|
// SAMME rekkefølge, ikke motsatt. Sorterer derfor synkende på
|
||||||
// enkeltpunkt-avstanden, så stigende sortering på den gir riktig
|
// BAKKANTEN når en finnes (`above` -- den ferneste kanten av en parret
|
||||||
// "nærmest spilleren først" for både parrede og enkeltstående hindringer.
|
// hindring, eksplisitt bedt om av bruker: "avstanden til bakkant [som]
|
||||||
return entities.sort((a, b) => a.below - b.below)
|
// 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({
|
export function HoleTargetDistance({
|
||||||
|
|
@ -314,9 +316,13 @@ export function HoleTargetDistance({
|
||||||
}
|
}
|
||||||
|
|
||||||
// ADR-083: ALLE hindringer, ikke bare nærmeste (tidligere reduserte denne
|
// ADR-083: ALLE hindringer, ikke bare nærmeste (tidligere reduserte denne
|
||||||
// løkken til ett enkelt nearestHazard-objekt) -- sortert nærmest
|
// løkken til ett enkelt nearestHazard-objekt). Avstandene er ALLTID live
|
||||||
// spilleren først. Avstandene er ALLTID live fra spilleren, uavhengig av
|
// fra spilleren, uavhengig av om et geometrisk diagram kan tegnes under.
|
||||||
// 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)
|
const hazardPoints = points.filter((p) => p.poi_type in _HAZARD_LABELS)
|
||||||
// ADR-085 (brukerønske 2026-08-20): passerte hindringer skal ikke vises.
|
// ADR-085 (brukerønske 2026-08-20): passerte hindringer skal ikke vises.
|
||||||
// Denne (tee-referanse mangler) grenen har ingen akse å projisere langs,
|
// Denne (tee-referanse mangler) grenen har ingen akse å projisere langs,
|
||||||
|
|
@ -336,7 +342,7 @@ export function HoleTargetDistance({
|
||||||
: base
|
: base
|
||||||
return { label, distanceMeters: Math.round(haversineMeters(livePosition, { lat: h.latitude, lng: h.longitude })), lat: h.latitude, lng: h.longitude }
|
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
|
// Fast tee-referanse (IKKE spillerens bevegelige livePosition) for selve
|
||||||
// hull-diagrammet -- se ADR-083. Midtpunkt hvis begge tee_front/tee_back
|
// hull-diagrammet -- se ADR-083. Midtpunkt hvis begge tee_front/tee_back
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ import { ShotMeasurementSheet } from "@/components/shot/shot-measurement-sheet"
|
||||||
import { HoleTargetDistance } from "@/components/hole-target-distance"
|
import { HoleTargetDistance } from "@/components/hole-target-distance"
|
||||||
import { HoleHistoryDetail } from "@/components/hole-history-detail"
|
import { HoleHistoryDetail } from "@/components/hole-history-detail"
|
||||||
import { bearingDegrees } from "@/lib/geo"
|
import { bearingDegrees } from "@/lib/geo"
|
||||||
|
import { midpoint } from "@/lib/hole-geometry"
|
||||||
import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
|
import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
|
||||||
|
|
||||||
// --- Types -----------------------------------------------------------------
|
// --- Types -----------------------------------------------------------------
|
||||||
|
|
@ -2431,25 +2432,69 @@ function ShotMeasurementEntry({
|
||||||
}, [urlBase])
|
}, [urlBase])
|
||||||
const count = shots.length
|
const count = shots.length
|
||||||
|
|
||||||
// Kartretning ved slagmåling (brukerønske 2026-08-10): "opp på skjermen"
|
// Ekte banegeometri (2026-08-21, brukerønske) -- når hullet har BÅDE
|
||||||
// følger spillerens EGEN gangretning på hullet, uten lagrede green-
|
// utslags- OG green-koordinater kjent (samme datakilde som
|
||||||
// koordinater. Regnet ut fra det SISTE tidligere målte slaget på dette
|
// HoleTargetDistance/HoleDiagram bruker), skal kartretningen ALLTID
|
||||||
// hullet (start→slutt-retningen) -- `shots` er allerede sortert
|
// følge utslag->green, ikke gjettes fra spillerens forrige slag (som
|
||||||
// stigende på shot_number av GET-endepunktet (se _list_shots_for_hole i
|
// kan avvike, f.eks. en slice/hook). Egen, liten fetch her (i stedet
|
||||||
// rounds.py), så siste element ER forrige slag. `undefined` (ingen
|
// for å prop-drille et tall gjennom RoundDetail/PlayerHoleCards/
|
||||||
// rotasjon regnet ut ennå) for det aller første slaget på hullet --
|
// ScoringWizard -- ShotMeasurementEntry brukes fra tre atskilte steder
|
||||||
// MapPointPicker faller da selv tilbake til enhetens GPS-heading/nord,
|
// i denne filen, kun roundId+holeNumber er felles for alle) -- ett
|
||||||
// se den komponentens dokumentasjon. Bevisst en STATISK retning, satt
|
// ekstra, lett GET-kall per spillerkort er en akseptert kostnad for at
|
||||||
// idet arket åpnes -- ikke løpende oppdatert (ville vært urolig ved lav
|
// ALLE tre inngangene til kartet får riktig retning, ikke bare én.
|
||||||
|
const [courseBearing, setCourseBearing] = useState<number | undefined>(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.
|
// gangfart), se map-point-picker.tsx.
|
||||||
const initialBearing = useMemo(() => {
|
const initialBearing = useMemo(() => {
|
||||||
|
if (typeof courseBearing === "number") return courseBearing
|
||||||
const previous = shots[shots.length - 1]
|
const previous = shots[shots.length - 1]
|
||||||
if (!previous) return undefined
|
if (!previous) return undefined
|
||||||
return bearingDegrees(
|
return bearingDegrees(
|
||||||
{ lat: previous.start_lat, lng: previous.start_lng },
|
{ lat: previous.start_lat, lng: previous.start_lng },
|
||||||
{ lat: previous.end_lat, lng: previous.end_lng },
|
{ lat: previous.end_lat, lng: previous.end_lng },
|
||||||
)
|
)
|
||||||
}, [shots])
|
}, [shots, courseBearing])
|
||||||
|
|
||||||
async function deleteShot(shotId: string) {
|
async function deleteShot(shotId: string) {
|
||||||
const res = await fetch(`/rounds/${roundId}/shots/${shotId}`, { method: "DELETE", credentials: "include" })
|
const res = await fetch(`/rounds/${roundId}/shots/${shotId}`, { method: "DELETE", credentials: "include" })
|
||||||
|
|
@ -6812,6 +6857,35 @@ function PlayerHoleCards({
|
||||||
window.removeEventListener("resize", check)
|
window.removeEventListener("resize", check)
|
||||||
}
|
}
|
||||||
}, [mapExpanded])
|
}, [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<HTMLDivElement>(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`) --
|
// Manuell scroll (ikke `scrollIntoView` + CSS `scroll-margin-top`) --
|
||||||
// samme begrunnelse som `getHeaderHeight()` over: en CSS-klasse som
|
// samme begrunnelse som `getHeaderHeight()` over: en CSS-klasse som
|
||||||
// `scroll-mt-28` kan ikke være dynamisk, og headerens høyde varierer
|
// `scroll-mt-28` kan ikke være dynamisk, og headerens høyde varierer
|
||||||
|
|
@ -6954,18 +7028,18 @@ function PlayerHoleCards({
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Alltid synlig, tydelig snarvei rett til score-registreringen under
|
{/* Alltid i normal side-flyt, uansett banekart-tilstand (rettet
|
||||||
-- samme grunn som knappen over: en førstegangsbruker skal ikke
|
2026-08-20 -- se `inFlowButtonVisible`-kommentaren over: den
|
||||||
måtte oppdage ved et tilfeldig sveip at dette finnes. KUN i normal
|
MÅTTE bli værende i flyten uansett, siden en lang hindringsliste
|
||||||
side-flyt når banekartet er kollapset -- utvidet blir den samme
|
alene kan dytte den ut av syne selv når kartet ALDRI ble utvidet).
|
||||||
knappen flytende i stedet, se render-stedet nederst i denne
|
`ref` brukt til å måle om DENNE knappen faktisk er synlig -- den
|
||||||
komponenten (brukerønske 2026-08-20: det utvidede diagrammet
|
flytende versjonen (render-stedet nederst i komponenten) dukker
|
||||||
kunne dytte knappen helt utenfor synlig skjermbilde). */}
|
kun opp når den IKKE er det. */}
|
||||||
{!mapExpanded && (
|
<div ref={inFlowButtonRef}>
|
||||||
<RegisterScoreButton
|
<RegisterScoreButton
|
||||||
onClick={scrollToPlayerList}
|
onClick={scrollToPlayerList}
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{isTwoSided && sides.length === 2 && (
|
{isTwoSided && sides.length === 2 && (
|
||||||
<div className="flex items-center justify-center gap-2 rounded-2xl border border-border bg-card px-4 py-2.5 text-sm">
|
<div className="flex items-center justify-center gap-2 rounded-2xl border border-border bg-card px-4 py-2.5 text-sm">
|
||||||
|
|
@ -7206,24 +7280,26 @@ function PlayerHoleCards({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Flytende "Registrer score" (V0-eksport, 2026-08-20) -- mens
|
{/* Flytende "Registrer score" (V0-eksport, 2026-08-20, utløser-
|
||||||
banekartet er utvidet OG spillerlisten fortsatt er utenfor synlig
|
logikk rettet samme dag) -- vises når side-flyt-knappen over
|
||||||
område (se `playerListReached` over -- uten denne betingelsen hang
|
IKKE er synlig (uansett ÅRSAK: utvidet banekart ELLER en lang
|
||||||
knappen igjen synlig, og dekket delvis over spillerkort 1, selv
|
hindringsliste alene, se `inFlowButtonVisible`-kommentaren over)
|
||||||
etter at brukeren allerede hadde scrollet dit). `fixed`
|
OG spillerlisten fortsatt er utenfor synlig område (se
|
||||||
posisjonerer mot selve viewporten (ingen ancestor her setter
|
`playerListReached` -- uten denne dekket den delvis over
|
||||||
transform/filter, så det er trygt), gradient-scrim + skygge
|
spillerkort 1 selv etter at brukeren allerede hadde scrollet
|
||||||
løfter den visuelt over innholdet under, `env(safe-area-inset-
|
dit). `fixed` posisjonerer mot selve viewporten (ingen ancestor
|
||||||
bottom)` unngår at den klemmes helt inntil kanten på telefoner
|
her setter transform/filter, så det er trygt), gradient-scrim +
|
||||||
med gest-navigasjon. `pointer-events-none` på selve scrim-sonen
|
skygge løfter den visuelt over innholdet under,
|
||||||
når skjult/i overgang, slik at den aldri blokkerer klikk på
|
`env(safe-area-inset-bottom)` unngår at den klemmes helt inntil
|
||||||
innhold bak. */}
|
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. */}
|
||||||
<div
|
<div
|
||||||
aria-hidden={!mapExpanded || playerListReached}
|
aria-hidden={inFlowButtonVisible || playerListReached}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-x-0 bottom-0 z-30 px-5 pb-[calc(0.875rem+env(safe-area-inset-bottom))] pt-8 transition-all duration-300 ease-out",
|
"fixed inset-x-0 bottom-0 z-30 px-5 pb-[calc(0.875rem+env(safe-area-inset-bottom))] pt-8 transition-all duration-300 ease-out",
|
||||||
"bg-gradient-to-t from-background via-background/90 to-transparent",
|
"bg-gradient-to-t from-background via-background/90 to-transparent",
|
||||||
mapExpanded && !playerListReached
|
!inFlowButtonVisible && !playerListReached
|
||||||
? "translate-y-0 opacity-100"
|
? "translate-y-0 opacity-100"
|
||||||
: "pointer-events-none translate-y-full opacity-0",
|
: "pointer-events-none translate-y-full opacity-0",
|
||||||
)}
|
)}
|
||||||
|
|
@ -7231,7 +7307,7 @@ function PlayerHoleCards({
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-auto w-full max-w-3xl",
|
"mx-auto w-full max-w-3xl",
|
||||||
mapExpanded && !playerListReached ? "pointer-events-auto" : "pointer-events-none",
|
!inFlowButtonVisible && !playerListReached ? "pointer-events-auto" : "pointer-events-none",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="rounded-2xl shadow-xl shadow-black/25">
|
<div className="rounded-2xl shadow-xl shadow-black/25">
|
||||||
|
|
@ -7243,10 +7319,8 @@ function PlayerHoleCards({
|
||||||
</div>
|
</div>
|
||||||
{/* Gir siste innhold på siden rom til å scrolle klar av den flytende
|
{/* Gir siste innhold på siden rom til å scrolle klar av den flytende
|
||||||
knappen i stedet for å bli permanent skjult bak den -- samme
|
knappen i stedet for å bli permanent skjult bak den -- samme
|
||||||
betingelse som selve knappen (2026-08-20-rettelsen), ellers ble
|
betingelse som selve knappen. */}
|
||||||
det stående et tomt, uforklart mellomrom selv etter at knappen
|
{!inFlowButtonVisible && !playerListReached && <div aria-hidden="true" className="h-20" />}
|
||||||
alt var skjult. */}
|
|
||||||
{mapExpanded && !playerListReached && <div aria-hidden="true" className="h-20" />}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue