/** * 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