/** * Standort — Live-GPS-Position des Fahrzeugs. Vorschau-Kachel auf der * Übersicht (`StandortVorschau`, eingebettet dort) plus Vollbild-Karte mit * eigenem Marker, Abstand, Adresse, Fahrzustand und Route/Teilen-Aktionen. * Vorlage: `vStandort()`/`standortKachel()` im alten Panel — bis 2026-08-29 * fehlte diese ganze Funktion hier komplett (Parität-Audit). * * Leaflet kommt wie bei `Karte.tsx` aus dem Bündel, nicht vom CDN. */ import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react" import { useDaten } from "../daten/DatenKontext" import { koordinatenAufloesen } from "../daten/geokodierung" import { kartendienstUrlZuPunkt } from "../daten/tankstelle" import { de } from "../format" import type { Fahrzeug } from "../daten/profilAdapter" import { SymbolTanken } from "../symbole" import "leaflet/dist/leaflet.css" const KACHEL_URL = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" const KACHEL_ZUSATZ = "© OpenStreetMap contributors" const SAT_KACHEL_URL = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" const SAT_ZUSATZ = "© Esri" // Audi-CI-Icon-Satz (vom Nutzer als SVG geliefert, verbatim übernommen wie die // übrigen CI-Icons in diesem Projekt — siehe symbole.tsx SymbolOelwechsel/- // Inspektion) — hier lokal, weil sie nur für die Kartensteuerung/den // Fahrzeugmarker gebraucht werden, nicht als allgemeine UI-Symbole. const CI = { /* Beginnt mit kleinem m. Hier stand ein grosses M - beim ersten Befehl ist das gleichbedeutend, aendert aber alles danach: nach M gelten die folgenden Zahlenpaare als absolute Linien, nach m als relative. Mit M sprang der zweite Punkt auf (-8.97,-5.66), also weit aus dem 24er-Raster heraus - das Symbol war dadurch verzerrt, nicht bloss zu gross. */ mapLayer: 'm11.7 19.4-8.97-5.66.54-.85 8.96 5.67c.17.1.37.1.54 0l8.96-5.67.54.85-8.97 5.66a1.5 1.5 0 0 1-1.6 0m.53-3.84c.17.1.37.1.54 0l8.3-5.25-8.3-5.23a.5.5 0 0 0-.54 0l-8.3 5.23zm-.53.84-9.64-6.09 9.64-6.08a1.5 1.5 0 0 1 1.6 0l9.64 6.08-9.64 6.1c-.49.3-1.11.3-1.6 0Z', carSearch: 'M1 7.8a6.16 6.16 0 0 0 6.1 6.1h.4A5.7 5.7 0 0 0 13 7.91V7.9a6.1 6.1 0 0 0-5.91-6A5.93 5.93 0 0 0 1 7.8m-1 0C0 3.92 3.23.8 7.11.9a7.1 7.1 0 0 1 6.89 7c.06 2.33-1 4.44-2.73 5.71L14.8 19H20v1.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5v-7.8l-1.9-1.9-.04-.06a55 55 0 0 0-2.84-4.71c-1.42-.1-2.49-.17-3.2-.2l.04-1c.77.04 1.93.1 3.48.22l.24.02.14.2a55 55 0 0 1 2.99 4.93L24 12.3v8.2c0 .83-.67 1.5-1.5 1.5h-2a1.5 1.5 0 0 1-1.5-1.5V20h-3.55l1.67 2.54-.84.54L14.26 20 5 19.96v.54c0 .83-.67 1.5-1.5 1.5h-2A1.5 1.5 0 0 1 0 20.5v-7.08h1v7.08c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5v-1.54l9.6.02-3.17-4.83c-.87.45-1.85.72-2.92.75H7.1c-3.88 0-7-3.13-7.1-7.09zm5.47-2.75c1.86-.12 3.37-.2 4.52-.24l.03 1c-1.08.03-2.5.1-4.24.22L4.51 8c-.27.43-.6 1-1 1.7l-.87-.48c.4-.72.74-1.3 1.03-1.76q.6-.96 1.41-2.19l.14-.2z', gps: 'M12.5 3.51V6.5h-1V3.51a8.46 8.46 0 0 0-7.99 7.99H6.5v1H3.51a8.46 8.46 0 0 0 7.99 7.99V17.5h1v2.99a8.46 8.46 0 0 0 7.99-7.99H17.5v-1h2.99a8.46 8.46 0 0 0-7.99-7.99m0-1a9.46 9.46 0 0 1 8.99 8.99h2.01v1h-2.01a9.46 9.46 0 0 1-8.99 8.99v2.01h-1v-2.01a9.46 9.46 0 0 1-8.99-8.99H.5v-1h2.01a9.46 9.46 0 0 1 8.99-8.99V.5h1z', selectAll: 'M2.8 2H5v1H3v2H2V2.8a.76.76 0 0 1 .43-.71.9.9 0 0 1 .35-.09zv.5zM9 3H7V2h2zm4 0h-2V2h2zm4 0h-2V2h2zm4 0h-2V2h2.2v.5V2h.02a1 1 0 0 1 .1.01c.07.01.16.03.25.08a.76.76 0 0 1 .43.71V5h-1zM6 6h12v12H6zm1 1v10h10V7zM3 7v2H2V7zm19 0v2h-1V7zM3 11v2H2v-2zm19 0v2h-1v-2zM3 15v2H2v-2zm19 0v2h-1v-2zM3 21v-2H2v2.2a.76.76 0 0 0 .43.71.9.9 0 0 0 .35.09h.01l.01-.5v.5H5v-1zm19-2v2.2a.76.76 0 0 1-.43.71.9.9 0 0 1-.35.09h-.01l-.01-.49V22H19v-1h2v-2zM9 22H7v-1h2zm4 0h-2v-1h2zm4 0h-2v-1h2z', planRoute: 'm18.3 7-3.65-3.65.7-.7 4.86 4.85-4.86 4.85-.7-.7L18.29 8H9.5C8.67 8 8 8.67 8 9.5V21H7V9.5A2.5 2.5 0 0 1 9.5 7z', // share-s aus dem Audi-CI-Satz (vom Nutzer geliefert, verbatim). Loest // beim Teilen das upload-Icon ab, das dort nur ersatzweise stand. shareS: 'm11.5 1.8 3.85 3.85-.7.7L12 3.71V15h-1V3.7L8.35 6.36l-.7-.7zM5 8h4v1H6v11h11V9h-3.02V8H18v13H5z', parking: 'M0 2.5A2.5 2.5 0 0 1 2.5 0H23v20.5a2.5 2.5 0 0 1-2.5 2.5H0zM2.5 1C1.67 1 1 1.67 1 2.5V22h19.5c.83 0 1.5-.67 1.5-1.5V1zm11.59 6.35C13.6 7 13.02 7 12.5 7H8v6h4.5c.52 0 1.11-.01 1.59-.35.44-.3.91-1 .91-2.65 0-1.66-.47-2.34-.91-2.65M8 14v3H7V6h5.53c.5 0 1.38 0 2.13.53C15.47 7.09 16 8.16 16 10s-.53 2.9-1.34 3.47a3.6 3.6 0 0 1-2.13.53z', // poi-car-l: Umriss (Ring) + Vollton-Silhouette dahinter, siehe // fahrzeugMarkerSVG() im Panel — reine Konturformen sind auf Kartenkacheln // sonst praktisch unsichtbar. poiCarL: 'M9 17.5C9 9.52 15.52 3 23.5 3S38 9.52 38 17.5c0 2.04-.92 4.78-2.23 7.67a90 90 0 0 1-4.84 8.98 170 170 0 0 1-6.98 10.59l-.03.04-.01.01-.41-.29-.4.3-.02-.02-.03-.04a66 66 0 0 1-.56-.8 166 166 0 0 1-6.42-9.8 90 90 0 0 1-4.84-8.97C9.92 22.27 9 19.54 9 17.5m14.5 27-.4.3.4.55.4-.56zm0-.86a132 132 0 0 0 1.77-2.56 170 170 0 0 0 4.8-7.45 89 89 0 0 0 4.79-8.87c1.3-2.89 2.14-5.45 2.14-7.26C37 10.08 30.92 4 23.5 4S10 10.08 10 17.5c0 1.8.83 4.37 2.14 7.26a89 89 0 0 0 4.79 8.87 170 170 0 0 0 6.57 10.01M17.96 7.98A63.3 63.3 0 0 1 29.05 8l.2.02.13.16c.6.7 1.1 1.36 1.46 1.94l1.31 2.08L34 14.31v7.19c0 .83-.67 1.5-1.5 1.5h-2a1.5 1.5 0 0 1-1.5-1.5V21H18v.5c0 .83-.67 1.5-1.5 1.5h-2a1.5 1.5 0 0 1-1.5-1.5v-7.19l1.85-2.11 1.3-2.08c.37-.58.86-1.24 1.47-1.96l.13-.16.2-.02ZM15.48 13 14 14.69V15h3v1h-3v5.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5V20h13v1.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5V16h-3v-1h3v-.31L31.52 13zm15.36-1L30 10.65a16 16 0 0 0-1.25-1.67 62.4 62.4 0 0 0-10.5-.02 17 17 0 0 0-1.25 1.7L16.16 12zM19.3 15h8.42l1.4 1.4-.16.31c-.34.74-.65 1.32-.92 1.72-.14.2-.28.38-.42.5a.92.92 0 0 1-.61.27h-7a.92.92 0 0 1-.6-.27 2.4 2.4 0 0 1-.43-.5c-.27-.4-.58-.98-.92-1.72l-.15-.32zm.42 1-.6.6c.26.57.5.99.69 1.27.11.17.2.27.26.32l.01.01h6.86v-.01c.07-.05.16-.15.27-.32.2-.28.43-.7.7-1.27l-.6-.6h-7.6Z', // poi-car-s: dieselbe Nadel im 24er-Raster. Das Panel schaltet in // fahrzeugMarkerSVG() ab 34px auf die grosse Zeichnung um - unter 34px ist // die kleine gemeint, sie ist fuer diese Groesse gezeichnet. poiCar: 'M3.8 8.6C3.8 4.33 7.32.8 11.5.8s7.7 3.53 7.7 7.8c0 1.11-.5 2.58-1.17 4.1a48 48 0 0 1-2.5 4.7 90 90 0 0 1-3.54 5.47l-.06.09-.02.02V23l-.41-.29-.4.29h-.01l-.02-.03-.06-.09a70 70 0 0 1-1.05-1.53 90 90 0 0 1-2.49-3.93 48 48 0 0 1-2.5-4.72C4.29 11.18 3.8 9.71 3.8 8.6m7.7 14.1-.4.29.4.57.4-.57zm0-.87a88 88 0 0 0 3.17-4.94 47 47 0 0 0 2.45-4.6A10.3 10.3 0 0 0 18.2 8.6c0-3.73-3.08-6.8-6.7-6.8a6.82 6.82 0 0 0-6.7 6.8c0 .89.4 2.18 1.08 3.69.67 1.48 1.55 3.1 2.45 4.6a89 89 0 0 0 3.17 4.94M8.77 4h5.47L17 7.35V11a1 1 0 0 1-1 1h-1.5a1 1 0 0 1-1-1h-4a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7.32zm.46 1L7 7.68V8h2v1H7v2h1.5v-1h6v1H16V9h-2V8h2v-.29L13.76 5z', poiCarSil: 'M3.8 8.6C3.8 4.33 7.32.8 11.5.8s7.7 3.53 7.7 7.8c0 1.11-.5 2.58-1.17 4.1a48 48 0 0 1-2.5 4.7 90 90 0 0 1-3.54 5.47l-.06.09-.02.02V23l-.41-.29-.4.29h-.01l-.02-.03-.06-.09a70 70 0 0 1-1.05-1.53 90 90 0 0 1-2.49-3.93 48 48 0 0 1-2.5-4.72C4.29 11.18 3.8 9.71 3.8 8.6', poiCarLSil: 'M9 17.5C9 9.52 15.52 3 23.5 3S38 9.52 38 17.5c0 2.04-.92 4.78-2.23 7.67a90 90 0 0 1-4.84 8.98 170 170 0 0 1-6.98 10.59l-.03.04-.01.01-.41-.29-.4.3-.02-.02-.03-.04a66 66 0 0 1-.56-.8 166 166 0 0 1-6.42-9.8 90 90 0 0 1-4.84-8.97C9.92 22.27 9 19.54 9 17.5', } as const function ciIcon(d: string, px: number): string { return `` } function fahrzeugMarkerHtml(px = 38): string { // Groessenwechsel wie fahrzeugMarkerSVG() im Panel: ab 34px die grosse // Zeichnung (48er-Raster), darunter die kleine (24er). Die Vorschaukarte der // Uebersicht zeichnet mit 32px und bekam bisher faelschlich die grosse. const gross = px >= 34 const box = gross ? 48 : 24 const sil = gross ? CI.poiCarLSil : CI.poiCarSil const icon = gross ? CI.poiCarL : CI.poiCar return `
` } /* Haversine-Näherung, wie im Panel (distanzMeter()/distanzText()). */ function distanzMeter(a: { lat: number; lon: number }, b: { lat: number; lon: number }): number { const R = 6371000 const rad = Math.PI / 180 const dLat = (b.lat - a.lat) * rad const dLon = (b.lon - a.lon) * rad const s = Math.sin(dLat / 2) ** 2 + Math.cos(a.lat * rad) * Math.cos(b.lat * rad) * Math.sin(dLon / 2) ** 2 return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s)) } function distanzText(m: number | null): string { return m == null ? "" : m < 1000 ? `${Math.round(m)} m` : `${de(m / 1000, 1)} km` } function dauerSeitText(ts: number | null): string { if (ts == null) return "unbekannt" const sekunden = Math.max(0, Math.floor((Date.now() - ts) / 1000)) const tage = Math.floor(sekunden / 86400) const stunden = Math.floor((sekunden % 86400) / 3600) const minuten = Math.floor((sekunden % 3600) / 60) if (tage > 0) return `${tage} Tg. ${stunden} Std. ${minuten} Min.` if (stunden > 0) return `${stunden} Std. ${minuten} Min.` return `${minuten} Min.` } /* Fahrzustand aus der Zündung + letzter beendeter Fahrt, deckungsgleich mit standortZustand() im Panel. */ /* Ab wann aus „steht gerade" ein „Parkplatz" wird. Hing bis 2026-08-31 an der Einstellung fahrten_pausenzeit_min. Die gibt es seit dem 03.09.2026 wieder (Kachel „Fahrt beenden“), aber der Schwellwert hier hat nach wie vor nichts damit zu tun und bleibt als eigene Zahl stehen. */ const PARKPLATZ_AB_MIN = 15 /* Seit wann das Fahrzeug steht: aus dem Backend, hergeleitet aus dem Zeitpunkt, zu dem die Zuendung zuletzt ausging (in der Zeit des Geraets). Bis 2026-09-01 stand hier das Ende der juengsten Fahrt aus der Liste. Das war keine Aussage ueber das Parken, sondern ueber die Fahrterkennung: eine verpasste Fahrt laesst das Fahrzeug seit Tagen stehen, obwohl es gestern unterwegs war. Real vorgekommen - am 31.08.2026 zeigte die Anzeige "geparkt seit 2 Tg. 16 Std.", waehrend am Vorabend 7 km gefahren wurden. Kein Rueckfall auf die Fahrtenliste: null heisst unbekannt, und das wird auch so angezeigt. Eine falsche Zahl ist schlechter als keine. */ function standortZustand( fahrzeug: Fahrzeug, ): { faehrt: boolean; unbekannt: boolean; seitTs: number | null; geparkt: boolean } { const roh = fahrzeug.geparktSeit ? new Date(fahrzeug.geparktSeit).getTime() : NaN const neuesteTs: number | null = Number.isNaN(roh) ? null : roh if (fahrzeug.zuendung === true) return { faehrt: true, unbekannt: false, seitTs: null, geparkt: false } const minuten = neuesteTs != null ? (Date.now() - neuesteTs) / 60000 : null return { faehrt: false, unbekannt: fahrzeug.zuendung == null, seitTs: neuesteTs, geparkt: minuten != null && minuten >= PARKPLATZ_AB_MIN, } } /* Die Auflösung liegt jetzt in daten/geokodierung.ts, gemeinsam mit der Vorwärtsrichtung und mit dauerhaftem Cache. Der frühere Cache hier hielt nur einen einzigen Punkt und nur bis zum Neuladen — mit der Begründung, die Fahrzeugposition ändere sich ohnehin laufend. Das stimmt fürs Fahren, nicht fürs Parken: das Auto steht meist an denselben Stellen, und wenn Nominatim gerade drosselt (Antwort ohne CORS-Kopf, im Browser nicht von einem Netzfehler zu unterscheiden), stand an einer längst bekannten Stelle wieder nur ein Koordinatenpaar. */ const standortAdresseAufloesen = koordinatenAufloesen interface EigenePosition { lat: number lon: number } /** * Fortlaufende Ortung dieses Geraets, solange die Standortseite offen ist. * * Bis 2026-08-31 stand hier getCurrentPosition - eine Momentaufnahme beim * Oeffnen der Seite. Der eigene Punkt blieb danach stehen, auch wenn man * weiterging, und der angezeigte Abstand zum Fahrzeug veraltete still. * * Die Rueckgabe ist die Abmeldung: der Aufrufer haengt sie an einen * useEffect, damit die Ortung endet, sobald die Seite verlassen wird. Genau * das meint Apples "Beim Verwenden der App" - erfassen, solange jemand die * Karte ansieht, und keine Sekunde laenger. * * iOS zeigt die noetige Rueckfrage nur, wenn die native Huelle * NSLocationWhenInUseUsageDescription im Info.plist fuehrt; eingetragen wird * der Schluessel in scripts/ios-signieren.sh. Ohne ihn schlaegt schon der * erste Aufruf fehl, unabhaengig von diesem Code. */ function standortVerfolgen( setzePosition: (p: EigenePosition | null) => void, setzeFehler: (f: string | null) => void, ): () => void { if (!navigator.geolocation) { setzeFehler("GPS offline") return () => {} } const kennung = navigator.geolocation.watchPosition( (pos) => { setzeFehler(null) setzePosition({ lat: pos.coords.latitude, lon: pos.coords.longitude }) }, (err) => { // Diese Meldung betrifft den Standort DIESES Geräts, nicht des Fahrzeugs // — "GPS-Freigabe fehlt" nur beim genau dafür zuständigen Fehlercode, // sonst "GPS offline" (deckungsgleich mit dem Panel). setzeFehler(err.code === err.PERMISSION_DENIED ? "GPS-Freigabe fehlt" : "GPS offline") }, // maximumAge klein: eine zwischengespeicherte Position aus der letzten // halben Minute waere hier das Gegenteil von "live". { enableHighAccuracy: true, timeout: 15000, maximumAge: 2000 }, ) return () => navigator.geolocation.clearWatch(kennung) } /** Kleine, nicht interaktive Kartenvorschau auf der Übersicht — Vorlage: standortKachel()/initVorschauMap() im Panel. */ export function StandortVorschau({ geheZu }: { geheZu: () => void }) { const { fahrzeug } = useDaten() const behaelter = useRef(null) const bekannt = fahrzeug?.standortLat != null && fahrzeug?.standortLon != null useEffect(() => { const element = behaelter.current if (!element || !bekannt || !fahrzeug) return let karte: import("leaflet").Map | null = null let abgebrochen = false void import("leaflet").then((L) => { if (abgebrochen || !element) return karte = L.map(element, { attributionControl: false, zoomControl: false, dragging: false, scrollWheelZoom: false, doubleClickZoom: false, boxZoom: false, keyboard: false, touchZoom: false, }) L.tileLayer(KACHEL_URL, { attribution: KACHEL_ZUSATZ, maxZoom: 19 }).addTo(karte) karte.setView([fahrzeug.standortLat!, fahrzeug.standortLon!], 14) const icon = L.divIcon({ className: "dm-fahrzeug-pin-wrap", html: fahrzeugMarkerHtml(32), iconSize: [32, 32], iconAnchor: [15, 31], }) L.marker([fahrzeug.standortLat!, fahrzeug.standortLon!], { icon }).addTo(karte) setTimeout(() => karte?.invalidateSize(), 60) }) return () => { abgebrochen = true karte?.remove() } // eslint-disable-next-line react-hooks/exhaustive-deps }, [bekannt, fahrzeug?.standortLat, fahrzeug?.standortLon]) return ( ) } /** Vollbild-Standortansicht — Vorlage: vStandort() im Panel. */ export function Standort() { const { fahrzeug, einstellungen } = useDaten() const behaelter = useRef(null) const kartenRef = useRef(null) const fahrzeugMarkerRef = useRef(null) const userMarkerRef = useRef(null) const kachelRef = useRef(null) const leafletRef = useRef(null) const [satellit, setzeSatellit] = useState(false) const [eigenePosition, setzeEigenePosition] = useState(null) const [positionsFehler, setzePositionsFehler] = useState(null) // Zaehler statt Schalter: jeder Druck auf den GPS-Knopf ohne bekannte // Position soll die Ortung neu anstossen, nicht nur der erste. Ein einmal // gescheiterter watchPosition-Auftrag meldet sich nicht von selbst wieder - // etwa nachdem die Freigabe in den iOS-Einstellungen nachgereicht wurde. const [ortungsLauf, setzeOrtungsLauf] = useState(0) const [adresse, setzeAdresse] = useState(null) useEffect(() => standortVerfolgen(setzeEigenePosition, setzePositionsFehler), [ortungsLauf]) const [blattOffen, setzeBlattOffen] = useState(false) const blattRef = useRef(null) // Zug am Griff, deckungsgleich mit standortMenuVerdrahten() im Panel: das // Blatt folgt der Fingerbewegung direkt (Ref statt State - kein Re-Render // pro Pixel), erst pointerup entscheidet über den endgültigen Zustand. const zugRef = useRef<{ y0: number; offenBeimStart: boolean } | null>(null) const blattHub = () => { const el = blattRef.current if (!el) return 0 const peek = parseFloat(getComputedStyle(el).getPropertyValue("--dm-standort-peek")) || 96 return Math.max(0, el.offsetHeight - peek) } const griffPointerDown = (e: ReactPointerEvent) => { zugRef.current = { y0: e.clientY, offenBeimStart: blattOffen } const el = blattRef.current if (el) el.style.transition = "none" } const griffPointerMove = (e: ReactPointerEvent) => { const zug = zugRef.current const el = blattRef.current if (!zug || !el) return const dy = e.clientY - zug.y0 const hub = blattHub() const basis = zug.offenBeimStart ? 0 : hub const versatz = Math.max(0, Math.min(hub, basis + dy)) el.style.transform = `translateY(${versatz}px)` } const griffGesteBeenden = () => { const el = blattRef.current if (el) { el.style.transition = "" el.style.transform = "" } zugRef.current = null } const griffPointerUp = (e: ReactPointerEvent) => { const zug = zugRef.current if (!zug) return const dy = e.clientY - zug.y0 griffGesteBeenden() if (Math.abs(dy) > 40) setzeBlattOffen(dy < 0) else setzeBlattOffen((o) => !o) } /* Die Effekte weiter unten lesen leafletRef/kartenRef, die aber erst im asynchron geladenen import()-Callback gesetzt werden. Beim ersten Lauf waren sie leer, die Effekte stiegen aus - und weil ihre Abhaengigkeiten (die Koordinaten) sich danach nicht mehr aenderten, liefen sie nie wieder. Genau daran fehlte der Fahrzeug-Pin. Dieser Schalter bringt sie erneut zum Laufen, sobald die Karte wirklich steht. */ const [karteBereit, setzeKarteBereit] = useState(false) const bekannt = fahrzeug?.standortLat != null && fahrzeug?.standortLon != null const fahrzeugPos = bekannt ? { lat: fahrzeug!.standortLat!, lon: fahrzeug!.standortLon! } : null // Karte einmalig aufbauen. useEffect(() => { const element = behaelter.current if (!element) return let abgebrochen = false void import("leaflet").then((L) => { if (abgebrochen || !element) return leafletRef.current = L const karte = L.map(element, { attributionControl: false, zoomControl: false }) kartenRef.current = karte kachelRef.current = L.tileLayer(KACHEL_URL, { attribution: KACHEL_ZUSATZ, maxZoom: 19 }).addTo(karte) setzeKarteBereit(true) if (fahrzeugPos) karte.setView([fahrzeugPos.lat, fahrzeugPos.lon], 15) else karte.setView([51.16, 10.45], 5) setTimeout(() => karte.invalidateSize(), 60) }) return () => { abgebrochen = true kartenRef.current?.remove() kartenRef.current = null } // Nur beim ersten Aufbau — Positions-/Kachel-Updates laufen über die // Effekte unten, damit die Karte nicht bei jedem Datenpush neu entsteht. // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // Kachel-Ebene wechseln (Straße/Satellit). useEffect(() => { const L = leafletRef.current const karte = kartenRef.current if (!L || !karte) return if (kachelRef.current) karte.removeLayer(kachelRef.current) kachelRef.current = satellit ? L.tileLayer(SAT_KACHEL_URL, { attribution: SAT_ZUSATZ, maxZoom: 19 }).addTo(karte) : L.tileLayer(KACHEL_URL, { attribution: KACHEL_ZUSATZ, maxZoom: 19 }).addTo(karte) }, [satellit, karteBereit]) // Fahrzeugmarker zeichnen/aktualisieren. useEffect(() => { const L = leafletRef.current const karte = kartenRef.current if (!L || !karte) return if (fahrzeugMarkerRef.current) { karte.removeLayer(fahrzeugMarkerRef.current) fahrzeugMarkerRef.current = null } if (fahrzeugPos) { const icon = L.divIcon({ className: "dm-fahrzeug-pin-wrap", html: fahrzeugMarkerHtml(38), iconSize: [38, 38], iconAnchor: [19, 36], }) const marker = L.marker([fahrzeugPos.lat, fahrzeugPos.lon], { icon }).addTo(karte) marker.on("click", () => setzeBlattOffen(true)) fahrzeugMarkerRef.current = marker } }, [fahrzeugPos?.lat, fahrzeugPos?.lon, karteBereit]) // Eigene Position zeichnen/aktualisieren. useEffect(() => { const L = leafletRef.current const karte = kartenRef.current if (!L || !karte) return if (userMarkerRef.current) { karte.removeLayer(userMarkerRef.current) userMarkerRef.current = null } if (eigenePosition) { const icon = L.divIcon({ className: "dm-user-pin-wrap", html: `
`, iconSize: [16, 16], iconAnchor: [8, 8], }) userMarkerRef.current = L.marker([eigenePosition.lat, eigenePosition.lon], { icon, zIndexOffset: -100, }).addTo(karte) } }, [eigenePosition?.lat, eigenePosition?.lon, karteBereit]) // Anfangsausschnitt: beide Punkte zeigen, sobald beide bekannt sind. useEffect(() => { const karte = kartenRef.current if (!karte) return if (fahrzeugPos && eigenePosition) { karte.fitBounds( [ [fahrzeugPos.lat, fahrzeugPos.lon], [eigenePosition.lat, eigenePosition.lon], ], { padding: [64, 64], maxZoom: 16 }, ) } // Nur beim ersten Mal, an dem beide Punkte vorliegen — ein späterer Push // (Fahrzeug bewegt sich) soll den Nutzer nicht aus einer manuellen // Kartenposition herausreißen. // eslint-disable-next-line react-hooks/exhaustive-deps }, [!!fahrzeugPos, !!eigenePosition, karteBereit]) // Adresse nachladen, sobald/wann immer sich die Fahrzeugposition ändert. useEffect(() => { if (!fahrzeugPos) { setzeAdresse(null) return } let abgebrochen = false void standortAdresseAufloesen(fahrzeugPos.lat, fahrzeugPos.lon).then((a) => { if (!abgebrochen && a) setzeAdresse(a) }) return () => { abgebrochen = true } }, [fahrzeugPos?.lat, fahrzeugPos?.lon, karteBereit]) if (!fahrzeug || !einstellungen) return null const distanz = eigenePosition && fahrzeugPos ? distanzMeter(eigenePosition, fahrzeugPos) : null const distanzAnzeige = !bekannt ? "" : eigenePosition ? `${distanzText(distanz)} entfernt` : (positionsFehler ?? "Abstand wird ermittelt …") const koordinaten = fahrzeugPos ? `${de(fahrzeugPos.lat, 4)}, ${de(fahrzeugPos.lon, 4)}` : "" const adresseAnzeige = !bekannt ? "Kein GPS-Signal vom Fahrzeug" : (adresse ?? koordinaten) const zustand = standortZustand(fahrzeug) // Kartendienst des Geraets statt fest Google Maps: auf dem iPhone ist das // Karten. Dieselbe Wahl wie beim Tankstellen-Verweis (daten/tankstelle.ts). const kartenlink = kartendienstUrlZuPunkt(fahrzeugPos) const teilen = () => { if (!kartenlink) return const titel = `${einstellungen.fahrzeugtitel} · Standort` if (navigator.share) { void navigator.share({ title: titel, url: kartenlink }).catch(() => { /* vom Nutzer abgebrochen */ }) return } void navigator.clipboard.writeText(kartenlink).catch(() => { /* Zwischenablage nicht verfügbar — der Link steht in der Adressleiste des neuen Tabs */ }) } return (
)}
{einstellungen.fahrzeugtitel}
{distanzAnzeige}
{adresseAnzeige}
{zustand.faehrt ? ( <> {/* Der Text sagt "Zuendung an", nicht "Fahrzeug faehrt". Es gibt keinen Bewegungssensor am Fahrzeug - der einzige Anhaltspunkt ist ZUENDUNG_SENSOR, und der meldet on auch bei blosser Zuendung oder Zubehoerstellung. Auch die erkannte Fahrt hilft nicht weiter: fahrt_start_ts in fahrterkennung.py wird aus genau demselben Signal abgeleitet, waere also nur eine zweite Huelle um dieselbe Aussage. Der umgekehrte Fall ist unbedenklich: Zuendung aus heisst verlaesslich, dass gerade nicht gefahren wird - "steht"/"Geparkt seit" bleiben deshalb stehen. */} Zündung an ) : ( <> {zustand.unbekannt ? "Fahrzustand unbekannt" : zustand.seitTs != null ? `Geparkt seit ${dauerSeitText(zustand.seitTs)}` : "Fahrzeug steht, Parkdauer unbekannt"} )}
{de(fahrzeug.tankPct)} % {fahrzeug.reichweite != null ? `${de(fahrzeug.reichweite)} km` : "Reichweite unbekannt"}
Route
) }