43f25e2e0d
Sieben Meldungen des Eigentuemers, beide Codebasen. Karte im Einzelbeleg: "Position laedt nicht" und "der Button funktioniert nicht" waren dieselbe Stelle - ohne aufloesbaren Ort malte das Panel einen beliebigen Ausschnitt und liess den Zentrieren-Knopf fuer immer disabled. Beide zeigen jetzt die Auskunft statt einer Karte und unterscheiden dabei "steht nicht im Beleg" von "findet niemand". Dazu ein echter Paritaetsmangel: die App schlug nur station_address nach, das Panel faellt seit jeher auf den Namen zurueck - von 16 Testbelegen tragen vier eine Anschrift. Der Suchbegriff darf Name und Anschrift NICHT zusammensetzen: gemessen findet Nominatim "Pascalstr. 8, 85057 Ingolstadt", mit dem Betreibernamen davor nichts. Jetzt zwei Kandidaten nacheinander (Anschrift, dann Name); fuer den Kartendienst dagegen beides zusammen, weil Apple und Google Betriebsnamen kennen. Tankstelle steht ueber die volle Kachelbreite, zweizeilig (Tankstelle und Strasse / Ort) und fuehrt beim Tippen in den Standard-Kartendienst - Apple Karten auf Apple-Geraeten, sonst Google Maps. Dieselbe Wahl jetzt auch fuer Fahrzeugstandort und Autohaus. SmartDeal-Ersparnis brach um, weil die dt-Spalte 150,00px breit war und ihr Inhalt 150,20px - zwei Zehntel Pixel, Zeile dadurch 44,8 statt 26,4px hoch. Der Regler "Fahrt beenden" schreibt nicht mehr beim Loslassen: derselbe Wert stellt den Schlaf-Timeout des Dongles, das soll bewusst ausgeloest werden. Speichern/Verwerfen erscheinen nur bei echter Aenderung. Share-Erweiterung: an der ausgelieferten .ipa gemessen trug sie 1.0, die App 2026.9.3.3 - iOS registriert eine Erweiterung mit abweichender Versionsnummer nicht, sie fehlt dann im Teilen-Blatt. ios-signieren.sh schreibt die Version jetzt in beide Ziele und liest die fertige .ipa gegen. Zwischenablage: dritter Weg ueber UIPasteboard.itemProviders, und ein fehlender nativer Teil wird als solcher gemeldet statt als "Kein Zugriff". Am Geraet gesetzt: 1003 Network Ping Timeout 0 -> 60 s, 1004 ACK Type TCP/IP -> AVL. Beide liegen bei flespi als pending bereit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
626 lines
31 KiB
TypeScript
626 lines
31 KiB
TypeScript
/**
|
|
* 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 `<svg viewBox="0 0 24 24" width="${px}" height="${px}" fill="none" aria-hidden="true"><path fill="currentColor" d="${d}"/></svg>`
|
|
}
|
|
|
|
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 `<div class="dm-fahrzeug-pin"><svg viewBox="0 0 ${box} ${box}" width="${px}" height="${px}" fill="none" aria-hidden="true"><path fill="#182634" d="${sil}"/><path fill="currentColor" d="${icon}"/></svg></div>`
|
|
}
|
|
|
|
/* 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<HTMLDivElement | null>(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 (
|
|
<button type="button" className="dm-standort-kachelbtn" onClick={geheZu} aria-label="Standort in Vollbild öffnen">
|
|
<div className="dm-standort-vorschau" ref={behaelter} />
|
|
{!bekannt && <div className="dm-standort-leer">Kein GPS-Signal vom Fahrzeug</div>}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
/** Vollbild-Standortansicht — Vorlage: vStandort() im Panel. */
|
|
export function Standort() {
|
|
const { fahrzeug, einstellungen } = useDaten()
|
|
const behaelter = useRef<HTMLDivElement | null>(null)
|
|
const kartenRef = useRef<import("leaflet").Map | null>(null)
|
|
const fahrzeugMarkerRef = useRef<import("leaflet").Marker | null>(null)
|
|
const userMarkerRef = useRef<import("leaflet").Marker | null>(null)
|
|
const kachelRef = useRef<import("leaflet").TileLayer | null>(null)
|
|
const leafletRef = useRef<typeof import("leaflet") | null>(null)
|
|
|
|
const [satellit, setzeSatellit] = useState(false)
|
|
const [eigenePosition, setzeEigenePosition] = useState<EigenePosition | null>(null)
|
|
const [positionsFehler, setzePositionsFehler] = useState<string | null>(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<string | null>(null)
|
|
|
|
useEffect(() => standortVerfolgen(setzeEigenePosition, setzePositionsFehler), [ortungsLauf])
|
|
const [blattOffen, setzeBlattOffen] = useState(false)
|
|
const blattRef = useRef<HTMLDivElement | null>(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: `<div class="dm-user-pin"></div>`,
|
|
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 (
|
|
<div className="dm-standort-vollbild">
|
|
<div className="dm-standort-karte" ref={behaelter} />
|
|
<div className="dm-kartensteuerung">
|
|
<button
|
|
type="button"
|
|
onClick={() => setzeSatellit((s) => !s)}
|
|
aria-pressed={satellit}
|
|
aria-label={satellit ? "Satellitenansicht, zur Straßenkarte wechseln" : "Straßenkarte, zur Satellitenansicht wechseln"}
|
|
/* Das Layer-Symbol ist flach: seine Zeichnung fuellt im 24er-Raster nur
|
|
20,9 x 15,6, waehrend die drei Nachbarn 20x20 bis 24x22 einnehmen (alle
|
|
per getBBox gemessen). Bei gleicher Kastengroesse wirkt es dadurch
|
|
deutlich kleiner. 26px statt 22px gleichen das aus - dieselbe Loesung
|
|
wie beim Oelwechsel-Icon der Servicebloecke, das aus demselben Grund
|
|
26px statt 20px bekommt. */
|
|
dangerouslySetInnerHTML={{ __html: ciIcon(CI.mapLayer, 26) }}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => fahrzeugPos && kartenRef.current?.flyTo([fahrzeugPos.lat, fahrzeugPos.lon], Math.max(kartenRef.current.getZoom(), 15))}
|
|
aria-label="Fahrzeug auf der Karte suchen"
|
|
dangerouslySetInnerHTML={{ __html: ciIcon(CI.carSearch, 22) }}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (!eigenePosition) {
|
|
setzeOrtungsLauf((l) => l + 1)
|
|
return
|
|
}
|
|
kartenRef.current?.flyTo([eigenePosition.lat, eigenePosition.lon], Math.max(kartenRef.current.getZoom(), 15))
|
|
}}
|
|
aria-label="Auf eigenen Standort zentrieren"
|
|
dangerouslySetInnerHTML={{ __html: ciIcon(CI.gps, 22) }}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
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,
|
|
})
|
|
} else if (fahrzeugPos) karte.flyTo([fahrzeugPos.lat, fahrzeugPos.lon], 15)
|
|
else if (eigenePosition) karte.flyTo([eigenePosition.lat, eigenePosition.lon], 15)
|
|
}}
|
|
aria-label="Fahrzeug und eigenen Standort gemeinsam zeigen"
|
|
dangerouslySetInnerHTML={{ __html: ciIcon(CI.selectAll, 22) }}
|
|
/>
|
|
</div>
|
|
<div
|
|
ref={blattRef}
|
|
className={blattOffen ? "dm-standortmenu dm-standortmenu--offen" : "dm-standortmenu"}
|
|
>
|
|
<div
|
|
className="dm-standortmenu__griffzone"
|
|
onPointerDown={griffPointerDown}
|
|
onPointerMove={griffPointerMove}
|
|
onPointerUp={griffPointerUp}
|
|
onPointerCancel={griffGesteBeenden}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="dm-standortmenu__griff"
|
|
// Tippen/Klicken wird schon von den Pointer-Handlern der
|
|
// Griffzone erledigt (dy < 40px zählt dort als Tipp) - ein
|
|
// zusätzliches onClick würde bei Maus/Touch doppelt umschalten.
|
|
// Nur für Tastaturbedienung (Enter/Leertaste), die keinen
|
|
// Pointer-Event auslöst.
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault()
|
|
setzeBlattOffen((o) => !o)
|
|
}
|
|
}}
|
|
aria-expanded={blattOffen}
|
|
aria-label={blattOffen ? "Fahrzeugdetails einklappen" : "Fahrzeugdetails aufklappen"}
|
|
/>
|
|
{/* Nur im aufgezogenen Zustand: im halb verdeckten Ruhezustand hat
|
|
Schließen nichts zu schließen - der Knopf sah bedienbar aus und
|
|
tat nichts. */}
|
|
{blattOffen && (
|
|
<button
|
|
type="button"
|
|
className="dm-standortmenu__schliessen"
|
|
onClick={() => setzeBlattOffen(false)}
|
|
aria-label="Schließen"
|
|
>
|
|
<svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
|
|
<path d="M5 5l10 10M15 5 5 15" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
<div className="dm-standortmenu__kopf">
|
|
<div className="dm-standortmenu__name">{einstellungen.fahrzeugtitel}</div>
|
|
<div className="dm-standortmenu__distanz">{distanzAnzeige}</div>
|
|
</div>
|
|
</div>
|
|
<div className="dm-standortmenu__adresse">{adresseAnzeige}</div>
|
|
<div className="dm-standortmenu__status">
|
|
{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. */}
|
|
<span className="dm-standortmenu__punkt" />
|
|
<span>Zündung an</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<span dangerouslySetInnerHTML={{ __html: ciIcon(CI.parking, 16) }} />
|
|
<span>
|
|
{zustand.unbekannt
|
|
? "Fahrzustand unbekannt"
|
|
: zustand.seitTs != null
|
|
? `Geparkt seit ${dauerSeitText(zustand.seitTs)}`
|
|
: "Fahrzeug steht, Parkdauer unbekannt"}
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="dm-standortmenu__werte">
|
|
<SymbolTanken groesse={16} />
|
|
<span>{de(fahrzeug.tankPct)} %</span>
|
|
<span>{fahrzeug.reichweite != null ? `${de(fahrzeug.reichweite)} km` : "Reichweite unbekannt"}</span>
|
|
</div>
|
|
<div className="dm-standortmenu__aktionen">
|
|
<a
|
|
className="dm-standort-pille dm-standort-pille--primaer"
|
|
href={kartenlink ?? undefined}
|
|
target="_blank"
|
|
rel="noopener"
|
|
aria-disabled={!kartenlink}
|
|
style={kartenlink ? undefined : { pointerEvents: "none", opacity: 0.4 }}
|
|
>
|
|
<span dangerouslySetInnerHTML={{ __html: ciIcon(CI.planRoute, 15) }} />
|
|
Route
|
|
</a>
|
|
<button type="button" className="dm-standort-pille" onClick={teilen} disabled={!bekannt}>
|
|
<span dangerouslySetInnerHTML={{ __html: ciIcon(CI.shareS, 15) }} />
|
|
Teilen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|