diff --git a/.gitignore b/.gitignore index 00b8bc1..5f5cd12 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,7 @@ TILE_AUTH_SERVER.md tile-keys/ *.tileauth.key secret.key + +# Legal drafts (Impressum, Datenschutz) — contain personal/operator data, +# kept out of the public repo until finalized. +legal/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 254595b..6a86a9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,81 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). --- +## [1.6.4.5] - 2026-10-03 + +### 🐛 Behoben / ✨ Verbessert +- **Remote Admin: Ablehnungen (NAK) werden jetzt gemeldet statt still in den Timeout zu laufen.** Lehnte der Ziel-Node oder die Route eine Admin-Anfrage ab (z. B. `ADMIN_PUBLIC_KEY_UNAUTHORIZED` = unser Key ist drüben nicht als Admin-Key eingetragen, `PKI_FAILED` = Schlüsselproblem am lokalen Node, `MAX_RETRANSMIT` = Paket kam über Funk nicht durch), wurde der Routing-NAK bisher nur ins Log geschrieben — das Fenster zeigte nach 30 s kommentarlos „Timeout". Jetzt bricht die Anfrage sofort ab und das Fenster nennt den konkreten Fehlergrund samt Hinweis, was zu prüfen ist. +- **Remote Admin: Pakete werden explizit als PKI markiert (Android-Parität).** Wenn der Public Key des Ziel-Nodes bekannt ist, wird `pki_encrypted` + Key gesetzt — wie in der Android-App. Vorteil: Kann die Firmware nicht per PKI verschlüsseln (Key fehlt in der NodeDB des lokalen Nodes oder stimmt nicht überein), gibt sie sofort einen sichtbaren NAK zurück, statt das Paket still kanalverschlüsselt zu senden, das der Ziel-Node dann kommentarlos verwirft. +- **Remote Admin: funktionierte nur zu direkt erreichbaren Nodes (hop_limit=0).** Die Remote-Admin-Pakete (Requests **und** Writes) wurden ohne `hop_limit` und ohne `want_ack` gesendet. Die Firmware füllt das Standard-Hop-Limit bei Paketen von der App aber **nur** auf, wenn `want_ack` gesetzt ist (`Router.cpp`, `sendLocal`) — die Pakete gingen also mit **0 Hops** auf die Luft und wurden nie weitergeleitet. Folge: Remote Admin klappte nur bei Nodes in direkter Funkreichweite, über Multi-Hop- oder MQTT-Strecken lief jede Anfrage in den Timeout (die Android-App setzt `hop_limit`/`want_ack` explizit und war deshalb nicht betroffen). Der Client sendet Admin-Pakete jetzt wie alle anderen Remote-Pakete mit `hop_limit=7`, `want_ack=true` und Priorität *Reliable* (inkl. Firmware-Retransmits auf verlustreichen Strecken). + +--- + +## [1.6.4.3] - 2026-09-18 + +### 🐛 Behoben / ✨ Verbessert +- **Mesh-Hessen-Button: reboot-sicher, gestaged, mit Rücklesung + automatischem Nachziehen.** Der Button schreibt das Profil jetzt in einer **einzigen Edit-Transaktion** (`BeginEditSettings`/`CommitEditSettings`), sodass das Gerät nur **einmal am Ende** neu startet — vorher rebootete es schon nach dem Modem-Preset/Region-Wechsel, wodurch der anschließende Kanal-Write verloren ging. Reihenfolge: **1.** Modem-Preset ShortSlow + Region EU868, **2.** Kanal „Mesh Hessen" (Uplink+Downlink), **3.** MQTT + Hop-Limit 7. Die LoRa-Config wird dabei **zweimal** geschrieben (vor und nach dem Kanal): Der erste Write löst die Firmware-Coercion aus (EU868 mit Duty-Cycle → `ignore_mqtt` wird zwangsweise `true`), der zweite Write lässt die Region unverändert → keine Coercion → `ok_to_mqtt=1` / `ignore_mqtt=0` bleiben stehen (Fund aus `ignore-mqtt-eu868-coercion.md`, jetzt auch im Full-Client). **Vorher wurden `ok_to_mqtt`/`ignore_mqtt` gar nicht gesetzt.** Nach dem Commit wartet der Client reboot-sicher auf die automatische Neuverbindung und **liest alle Werte zurück** (Region/Preset/Hop/MQTT/Kanal). Fehlt danach noch etwas, wird es in einem **zweiten Durchlauf automatisch nachgezogen**; das Endergebnis wird mit ✓/✗ angezeigt. Der Button ist außerdem nutzbar, solange das Profil noch nicht vollständig gesetzt ist (z. B. zum Nachbessern der MQTT-Werte). + +--- + +## [1.6.4.2] - 2026-09-17 + +### 🐛 Behoben +- **Virtueller Node: Verbindung mit der Meshtastic-Android-App stabilisiert.** Die App nutzt einen **zweistufigen** `want_config`-Handshake mit zwei festen Spezial-Nonces: `69420` = „nur Config" (my_info, Metadata, Config, ModuleConfig, Kanäle) und `69421` = „nur Nodes" (ausschließlich NodeInfos). Der vNode hat bisher **beide** Anfragen mit dem vollständigen Dump inkl. `my_info` beantwortet. Dadurch setzte Androids `handleMyInfo` in Stufe 2 den Handshake-Zustand zurück, das `config_complete` von Stufe 2 wurde verworfen, die App wurde nie „verbunden" und lief in eine Reconnect-Schleife (2 Replays pro Versuch, 4 Versuche, dann Abbruch). Der vNode **verzweigt jetzt nach Nonce**: auf `69420` nur die Config (ohne Nodes), auf `69421` nur die Nodes (ohne my_info/Config/Kanäle); jede andere Nonce erhält weiterhin den vollständigen Einstufen-Dump. Zusätzlich loggt der vNode jetzt Nonce und Stufe pro Replay. + +--- + +## [1.6.4] - 2026-09-10 + +### ✨ Hinzugefügt + +#### 💬 Nachrichten-Übersicht mit Seitenleiste (WhatsApp-Stil) +- Neue **Seitenleiste** im Nachrichten-Tab: links die Auswahl (Kanäle **und** Direktnachrichten), rechts der Chat. Das bewährte **Kontextmenü** und die farbigen Bubbles bleiben unverändert — die **Node-Farbe** wird weiterhin je Bubble berücksichtigt. +- **Ungelesen-Badge** (grüne Blase mit Zähler) an Kanälen und DMs, die nicht gerade geöffnet sind. + +#### ✅ Zustellstatus & erneutes Senden +- Gesendete Nachrichten zeigen jetzt **Statushaken**: … (wird gesendet) → ✓ (gesendet) → ✓✓ (zugestellt, grün) bzw. ⚠ (fehlgeschlagen, rot). Basiert auf `want_ack` und der Routing-Bestätigung der Firmware (Android-Parität). +- **Erneut-senden-Button** (↻) an fehlgeschlagenen Nachrichten. + +#### 🔔 Direktnachrichten wahlweise inline oder im Fenster +- In den Einstellungen umschaltbar: DMs **wie bei WhatsApp direkt im Client** (inline in der Übersicht) **oder** im separaten Fenster (Standard bleibt Fenster). + +#### 🔔 Toast-Benachrichtigungen +- Optionale **Windows-Toast-Benachrichtigungen** bei neuen Nachrichten (in den Einstellungen aktivierbar), nur wenn das Fenster nicht im Vordergrund ist. + +#### 🚀 Verbindung +- **BLE-Durchsatz optimiert**: bevorzugte Verbindungsparameter (`ThroughputOptimized`) werden angefordert — spürbar schnellerer Datenaustausch über Bluetooth. +- **BLE-Reconnect** stabilisiert: GATT-Session/-Service und Verbindungsparameter werden beim Trennen sauber freigegeben, sodass ein erneutes Verbinden zuverlässig funktioniert. + +> ℹ️ Diese Version hebt das Windows-SDK-Ziel auf `10.0.22621` an (für die BLE-Durchsatz-API). Mindest-Windows-Version bleibt unverändert. + +### 🐛 Behoben +- **Toast-Spam bei verschlüsselten Nachrichten**: Für unentschlüsselbare Nachrichten (Platzhalter „Verschlüsselte Nachricht – PSK erforderlich") wird **kein** Toast mehr angezeigt. +- **„DM senden" springt jetzt in den Chat**: Der „DM senden"-Button an der Node-Liste respektiert nun den Inline/Fenster-Schalter — im Inline-Modus wird direkt in den Nachrichten-Tab gesprungen statt zusätzlich das DM-Fenster zu öffnen; im Fenster-Modus wird der passende Chat direkt geöffnet und aktiviert. +- **„Info anfordern" im Chat**: Das Nachrichten-Kontextmenü (Rechtsklick auf eine Nachricht) hat jetzt das vollständige **„Info anfordern"**-Untermenü (Nutzer-Info, Position, Geräte-/Umwelt-/Luftqualitäts-/Power-Metriken, LocalStats, HostMetrics, PaxCounter) — zielt auf den Absender. Funktioniert auch bei **noch unbekannten Nodes** (nur die Node-ID ist nötig — genau so lernt man einen unbekannten Node kennen). Zusätzlich hat die **Chatliste selbst** (Direktnachrichten-Einträge) jetzt ein Rechtsklick-Menü mit „Node-Info", „Auf Karte zeigen" und „Info anfordern". +- **Kanalauswahl-Dropdown neben dem Eingabefeld entfernt**: Der Sende-Kanal folgt jetzt der Auswahl in der Seitenleiste — das separate Dropdown war doppelt und ist weg. Mehr Platz fürs Eingabefeld. +- **Chat startet unten (neueste Nachricht) statt oben**: Beim Start bzw. Kanal-/DM-Wechsel wird jetzt zuverlässig ans Ende gescrollt. Zuvor landete man im Back-Scroll ganz oben, weil das Nachladen älterer Nachrichten schon beim ersten Layout-Durchlauf (Scroll-Position 0) auslöste; das ist jetzt bis nach dem ersten Scroll-to-Bottom gesperrt. +- **Chatliste und angezeigte Nachrichten stimmen beim Start überein**: Beim Start war in der Seitenleiste zwar ein Kanal (z. B. „short slow") ausgewählt, angezeigt wurden aber die Nachrichten eines anderen Kanals — erst ein erneuter Klick schaltete um. Ursache: der versteckte alte Kanal-Filter (ComboBox) überschrieb beim Befüllen die Auswahl der Seitenleiste mit „Alle Kanäle". Die Seitenleiste ist jetzt alleinige Filterquelle; der versteckte ComboBox kann sie nicht mehr überschreiben. +- **Serielle Verbindung hängt nicht mehr beim Trennen**: Verband man sich versehentlich mit einem COM-Port, der kein Meshtastic-Node ist, und trennte sofort, konnte der Client einfrieren. Ursache: unendlicher Write-Timeout ließ Schreibvorgänge auf dem toten Port ewig blockieren, wodurch `SerialPort.Close()` beim Trennen deadlockte. Jetzt: finiter Write-Timeout (2 s), das Schließen läuft zeitlich begrenzt auf einem eigenen Thread, der Trenn-Vorgang ist reentranz-sicher, die Config-Wartephase bricht beim Trennen sofort ab, und die Oberfläche gibt nach spätestens 8 s garantiert wieder frei. + +--- + +## [1.6.3] - 2026-08-31 + +### ✨ Hinzugefügt + +#### 🌡️ Umweltdaten auf der Karte +- Neues, in den Einstellungen aktivierbares Feature: Nodes, die Umwelt-Telemetrie liefern, zeigen ihre Messwerte direkt auf der Karte. Funktioniert für **Raster- und Vektor-Karte**. +- **Messwert-Boxen** unter dem jeweiligen Node-Marker (leicht gelbe Box mit themenabhängigem Rand – weiß bei Nacht, schwarz bei Tag) mit allen gemeldeten Werten plus Datum/Uhrzeit der Messung. +- **Heatmap / interpolierte Wertefläche** (Vektor **und** Raster-Karte) für die gewählte Metrik — im **Wetterdienst-Stil**: die Farbe entspricht dem **tatsächlichen Messwert** auf einer kalibrierten, saturierten Skala (nicht der Punktdichte), mit **Legende** am Kartenrand. Zwischen den Sensoren wird per IDW (inverse Distanzgewichtung, ~30 km Reichweite) interpoliert; jenseits der Reichweite bleibt die Fläche transparent. Weiche, ineinander verlaufende Kanten (bilinear hochgerechnetes Bild) plus **Isobaren** an den Bandgrenzen und einer **Außenumrandung** des Gebiets. Ältere Messwerte zählen weniger (exponentieller Zeit-Abfall), veraltete Sensoren verlieren an Gewicht. Funktioniert für **alle Metriken** (Temperatur, IAQ, Feuchte, …). Die Karte bleibt darunter sichtbar. +- **Voller Sensorsatz**: Temperatur, Feuchte, Luftdruck, IAQ, Gas-Widerstand, Beleuchtung/Weißlicht/UV, Wind (Geschw./Richtung/Böe), Regen (1 h/24 h), Bodenfeuchte/-temperatur, Strahlung, Distanz, Gewicht. Die Telemetrie-DB wird dafür automatisch migriert (bisher wurden nur Temperatur/Feuchte/Luftdruck/IAQ gespeichert). +- **Einzelne Nodes abwählbar** (falls einer Unsinn misst): über den 🌡️-Button an der Karte, wirkt auf Boxen **und** Heatmap. +- Steuerung komplett über den neuen 🌡️-Button (Box-Modus, Heatmap an/aus, Metrik-Auswahl, Node-Liste); Einstellungen bleiben erhalten. +- **Box-Modus konfigurierbar**: Aus / Immer sichtbar / Beim Überfahren (Hover). Nodes, die Umweltdaten liefern, sind am **grünen Marker-Ring** erkennbar. +- **Abgestufte Legende** (z. B. 5-°C-Schritte bei Temperatur) mit diskreten Farbbändern statt weichem Verlauf — die Band-Grenzen sind als sichtbare Kontur in der Fläche erkennbar. +- **Z-Reihenfolge** korrigiert: eigener Standort liegt über den Node-Markern, Messwert-Boxen über den Markern. + +--- + ## [1.6.2.5] - 2026-08-30 ### 🐛 Behoben diff --git a/MeshhessenClient.Tests/EnvironmentTests.cs b/MeshhessenClient.Tests/EnvironmentTests.cs new file mode 100644 index 0000000..f5169c8 --- /dev/null +++ b/MeshhessenClient.Tests/EnvironmentTests.cs @@ -0,0 +1,241 @@ +using System.IO; +using Meshtastic.Protobufs; +using MeshhessenClient.Services; + +namespace MeshhessenClient.Tests; + +public class EnvironmentMetricInfoTests +{ + [Fact] + public void Registry_HasCoreAndExtendedMetrics() + { + Assert.NotNull(EnvironmentMetricInfo.ByKey("temperature")); + Assert.NotNull(EnvironmentMetricInfo.ByKey("iaq")); + Assert.NotNull(EnvironmentMetricInfo.ByKey("gas")); // extended sensor set + Assert.NotNull(EnvironmentMetricInfo.ByKey("wind_speed")); + Assert.Null(EnvironmentMetricInfo.ByKey("does_not_exist")); + } + + [Fact] + public void Columns_MatchRegistryOneToOne() + { + Assert.Equal(EnvironmentMetricInfo.All.Count, EnvironmentMetricInfo.Columns.Count); + Assert.Contains("gas_resistance", EnvironmentMetricInfo.Columns); + Assert.True(EnvironmentMetricInfo.IsKnownColumn("relative_humidity")); + Assert.False(EnvironmentMetricInfo.IsKnownColumn("node_id")); + } + + [Fact] + public void Format_AppendsUnit() + { + Assert.StartsWith("21", EnvironmentMetricInfo.Format("temperature", 21.4)); + Assert.EndsWith("°C", EnvironmentMetricInfo.Format("temperature", 21.4)); + Assert.DoesNotContain("°", EnvironmentMetricInfo.Format("iaq", 42)); // iaq is unitless + } + + [Fact] + public void Bands_CoverRangeAtStep() + { + var temp = EnvironmentMetricInfo.ByKey("temperature")!; // -10..40 step 5 + var bands = EnvironmentMetricInfo.Bands(temp); + Assert.Equal(10, bands.Count); // (40 - -10)/5 + } + + [Fact] + public void RgbAt_EndsMatchGradient() + { + Assert.Equal(EnvironmentMetricInfo.Gradient[0].rgb, EnvironmentMetricInfo.RgbAt(0.0)); + Assert.Equal(EnvironmentMetricInfo.Gradient[^1].rgb, EnvironmentMetricInfo.RgbAt(1.0)); + } + + [Fact] + public void Extract_TakesPresentFields_SkipsZeros() + { + var em = new EnvironmentMetrics { Temperature = 21.5f, RelativeHumidity = 60f, Iaq = 42, GasResistance = 12.3f }; + var d = EnvironmentMetricInfo.Extract(em); + + Assert.Equal(21.5, d["temperature"], 3); + Assert.Equal(60.0, d["humidity"], 3); + Assert.Equal(42.0, d["iaq"], 3); + Assert.Equal(12.3, d["gas"], 3); + Assert.False(d.ContainsKey("pressure")); // 0 = not reported + } +} + +public class EnvironmentHeatmapBuilderTests +{ + [Fact] + public void NoSamples_ReturnsNull() + { + Assert.Null(EnvironmentHeatmapBuilder.Representative(new List<(double, double)>())); + } + + [Fact] + public void FreshReading_HasFullRecency_AndRealValue() + { + var r = EnvironmentHeatmapBuilder.Representative(new[] { (0.0, 25.0) }); + Assert.NotNull(r); + Assert.Equal(25.0, r!.Value.Value, 3); // value is NOT normalised + Assert.True(r.Value.Recency > 0.99, $"recency was {r.Value.Recency}"); + } + + [Fact] + public void RecentReadingsDominateValue() + { + // recent 30 (age 0), old 10 (age 24h): weighted value pulls toward the recent one, + // i.e. above the plain average of 20. + var r = EnvironmentHeatmapBuilder.Representative(new[] { (0.0, 30.0), (24.0, 10.0) }); + Assert.NotNull(r); + Assert.True(r!.Value.Value > 20.0, $"value was {r.Value.Value}"); + } + + [Fact] + public void StaleNode_LosesRecency() + { + var fresh = EnvironmentHeatmapBuilder.Representative(new[] { (0.0, 25.0) })!.Value.Recency; + var stale = EnvironmentHeatmapBuilder.Representative(new[] { (48.0, 25.0) })!.Value.Recency; + Assert.True(stale < fresh); + Assert.True(stale < 0.5, $"stale recency was {stale}"); + } +} + +public class EnvironmentZonesTests +{ + [Fact] + public void SingleBand_AllCellsGetZoneAverage() + { + var g = new EnvironmentField.Grid + { + Nx = 4, Ny = 2, Lon0 = 8, Lat0 = 50, DLon = 0.1, DLat = 0.1, + Values = Enumerable.Repeat(12.0, 8).ToArray(), // all in band [10,15) + Alpha = Enumerable.Repeat(1.0, 8).ToArray(), + }; + var avg = EnvironmentZones.ZoneAverages(g, -10, 5, 10); + Assert.All(avg, a => Assert.Equal(12.0, a, 3)); + } + + [Fact] + public void TwoBands_EachCellGetsItsZoneAverage() + { + var vals = new double[8]; + for (int j = 0; j < 2; j++) + for (int i = 0; i < 4; i++) + vals[j * 4 + i] = i < 2 ? 12.0 : 22.0; // left band 12, right band 22 + var g = new EnvironmentField.Grid + { + Nx = 4, Ny = 2, Lon0 = 8, Lat0 = 50, DLon = 0.1, DLat = 0.1, + Values = vals, Alpha = Enumerable.Repeat(1.0, 8).ToArray(), + }; + var avg = EnvironmentZones.ZoneAverages(g, -10, 5, 10); + Assert.Equal(12.0, avg[0], 3); // left cell + Assert.Equal(22.0, avg[3], 3); // right cell + } +} + +public class EnvironmentFieldTests +{ + private static EnvironmentField.Grid.Cell Nearest(EnvironmentField.Grid g, double lon, double lat) + { + double best = double.MaxValue; int bi = 0, bj = 0; + for (int j = 0; j < g.Ny; j++) + for (int i = 0; i < g.Nx; i++) + { + double cl = g.Lon0 + (i + 0.5) * g.DLon, ca = g.Lat0 + (j + 0.5) * g.DLat; + double d = (cl - lon) * (cl - lon) + (ca - lat) * (ca - lat); + if (d < best) { best = d; bi = i; bj = j; } + } + int idx = bj * g.Nx + bi; + return new EnvironmentField.Grid.Cell(g.Values[idx], g.Alpha[idx]); + } + + [Fact] + public void NoSensors_ReturnsNull() + { + Assert.Null(EnvironmentField.Build(new List(), 30)); + } + + [Fact] + public void InterpolatedValues_StayWithinSensorRange() + { + var sensors = new[] + { + new EnvironmentField.Sensor(8.0, 50.0, 30.0, 1.0), // hot + new EnvironmentField.Sensor(8.3, 50.0, 10.0, 1.0), // cold, ~21 km east + }; + var g = EnvironmentField.Build(sensors, 30); + Assert.NotNull(g); + + foreach (var v in g!.Values) + if (!double.IsNaN(v)) + Assert.True(v >= 10.0 - 1e-6 && v <= 30.0 + 1e-6, $"value out of range: {v}"); // IDW weighted average + } + + [Fact] + public void Contours_EmitSegmentWhereLevelCrossed() + { + // 2x2 lattice, only the NE corner above the level → exactly one contour segment. + var g = new EnvironmentField.Grid + { + Nx = 2, Ny = 2, Lon0 = 8.0, Lat0 = 50.0, DLon = 0.1, DLat = 0.1, + Values = new double[] { 0, 0, 0, 20 }, // row-major j*Nx+i; index 3 = NE + Alpha = new double[] { 1, 1, 1, 1 }, + }; + var segs = EnvironmentContours.Build(g, new[] { 10.0 }); + Assert.Single(segs); + Assert.Equal(4, segs[0].Length); // [lon1,lat1,lon2,lat2] + } + + [Fact] + public void CellAtSensor_TakesThatSensorsValue() + { + var sensors = new[] + { + new EnvironmentField.Sensor(8.0, 50.0, 30.0, 1.0), + new EnvironmentField.Sensor(8.3, 50.0, 10.0, 1.0), + }; + var g = EnvironmentField.Build(sensors, 30)!; + var hot = Nearest(g, 8.0, 50.0); + Assert.True(hot.Value > 29.0, $"value at hot sensor was {hot.Value}"); + Assert.True(hot.Alpha > 0.0); + } +} + +public class EnvironmentDbRoundTripTests +{ + [Fact] + public void InsertAndReadBack_FullSensorSet() + { + var dbPath = Path.Combine(Path.GetTempPath(), $"mh_envtest_{Guid.NewGuid():N}.db"); + try + { + using (var db = new TelemetryDatabaseService(dbPath)) + { + db.InsertEnvironmentTelemetry(123u, DateTime.UtcNow, new Dictionary + { + ["temperature"] = 21.5, + ["humidity"] = 55, + ["gas"] = 12.3, // extended column added by migration + ["wind_speed"] = 3.2, + }); + + var latest = db.GetLatestEnvironmentPerNode(); + var e = Assert.Single(latest); + Assert.Equal(123u, e.NodeId); + Assert.Equal(21.5, e.Values["temperature"], 3); + Assert.Equal(12.3, e.Values["gas"], 3); + Assert.True(e.Values.ContainsKey("wind_speed")); + Assert.False(e.Values.ContainsKey("pressure")); // never inserted + + var series = db.GetTimeSeries(new[] { 123u }, "gas", 1); + Assert.Single(series); + Assert.Equal(12.3, series[0].Value, 3); + } + } + finally + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + foreach (var p in new[] { dbPath, dbPath + "-wal", dbPath + "-shm" }) + try { if (File.Exists(p)) File.Delete(p); } catch { /* best-effort cleanup */ } + } + } +} diff --git a/MeshhessenClient.Tests/MeshhessenClient.Tests.csproj b/MeshhessenClient.Tests/MeshhessenClient.Tests.csproj index 5ab9003..fdbed60 100644 --- a/MeshhessenClient.Tests/MeshhessenClient.Tests.csproj +++ b/MeshhessenClient.Tests/MeshhessenClient.Tests.csproj @@ -3,7 +3,7 @@ - net8.0-windows10.0.19041.0 + net8.0-windows10.0.22621.0 enable enable false diff --git a/MeshhessenClient/MainWindow.Environment.cs b/MeshhessenClient/MainWindow.Environment.cs new file mode 100644 index 0000000..e2b2101 --- /dev/null +++ b/MeshhessenClient/MainWindow.Environment.cs @@ -0,0 +1,610 @@ +// Umweltdaten auf der Karte: Messwert-Boxen (Raster + Vektor), Heatmap (Vektor), +// Metrik-Auswahl und Node-Ausschluss. Partial class von MainWindow. + +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Windows; +using System.Windows.Controls; +using MeshhessenClient.Models; +using MeshhessenClient.Services; +using Mapsui; +using Mapsui.Layers; +using Mapsui.Styles; +using Mapsui.Projections; +using Mapsui.Extensions; +using Mapsui.Nts; +using NetTopologySuite.Geometries; + +namespace MeshhessenClient; + +public partial class MainWindow +{ + // Raster info-box layer (parallel to _nodeLayer; created in InitializeMap). + private MemoryLayer? _envLayer; + private readonly List _envFeatures = new(); + + // Raster heatmap: the interpolated field as a georeferenced image + the contour/border lines. + private MemoryLayer? _envFillLayer; + private List _envFillFeatures = new(); + private MemoryLayer? _envLineLayer; + private readonly List _envLineFeatures = new(); + + private readonly HashSet _envDisabledNodes = new(); + private readonly HashSet _envNodeIds = new(); // nodes that report env data (→ green marker) + + // Raster hover-mode boxes: data per node + which one is currently shown. + private readonly Dictionary _envRasterBoxData = new(); + private string _envRasterMode = "always"; + private uint _envHoverNodeId; + private bool _envControlsBuilt; + private bool _envLoading; // guards control handlers during programmatic updates + private string _envNodeSig = ""; // signature of the current exclusion-list node set + private System.Windows.Threading.DispatcherTimer? _envRefreshTimer; + + private const int EnvHeatmapDays = 2; // history window feeding the time-weighted field + private const double EnvInfluenceKm = 30.0; // per-sensor reach for the interpolated field + + // ── UI wiring ───────────────────────────────────────────────────────────── + + /// Applies the feature's on/off state to the map toolbar: shows/hides the + /// 🌡️ button, the raster heatmap hint, and (re)renders boxes + heatmap. Called at + /// map init and after the settings dialog is saved. + public void ApplyEnvironmentUi() + { + bool on = _currentSettings.ShowEnvironmentData; + if (EnvBtn != null) EnvBtn.Visibility = on ? Visibility.Visible : Visibility.Collapsed; + + if (!on) + { + if (EnvPopup != null) EnvPopup.IsOpen = false; + ClearEnvRasterLayer(); + ClearEnvRasterSurface(); + ClearEnvVector(); + return; + } + + EnsureEnvControlsBuilt(); + if (EnvHeatmapRasterHint != null) + EnvHeatmapRasterHint.Visibility = Visibility.Collapsed; // heatmap now works on the raster map too + RefreshEnvironmentData(); + } + + private void EnsureEnvControlsBuilt() + { + if (!_envControlsBuilt) + { + _envLoading = true; + EnvMetricCombo.Items.Clear(); + foreach (var m in EnvironmentMetricInfo.All) + { + var item = new ComboBoxItem { Tag = m.Key }; + item.SetResourceReference(ContentControl.ContentProperty, m.LabelKey); + EnvMetricCombo.Items.Add(item); + } + _envControlsBuilt = true; + _envLoading = false; + } + SyncEnvControlStates(); + } + + private void SyncEnvControlStates() + { + _envLoading = true; + ComboBoxItem? boxSel = null; + foreach (ComboBoxItem it in EnvBoxModeCombo.Items) + if ((it.Tag as string) == _currentSettings.EnvBoxMode) { boxSel = it; break; } + EnvBoxModeCombo.SelectedItem = boxSel ?? (EnvBoxModeCombo.Items.Count > 1 ? EnvBoxModeCombo.Items[1] : null); + EnvHeatmapCheck.IsChecked = _currentSettings.EnvShowHeatmap; + + var key = _currentSettings.EnvMetric; + ComboBoxItem? sel = null; + foreach (ComboBoxItem it in EnvMetricCombo.Items) + if ((it.Tag as string) == key) { sel = it; break; } + EnvMetricCombo.SelectedItem = sel ?? (EnvMetricCombo.Items.Count > 0 ? EnvMetricCombo.Items[0] : null); + + _envDisabledNodes.Clear(); + foreach (var s in _currentSettings.EnvDisabledNodes.Split(',', StringSplitOptions.RemoveEmptyEntries)) + if (uint.TryParse(s.Trim(), out var id)) _envDisabledNodes.Add(id); + _envLoading = false; + } + + private void PersistEnv(Func mutate) + { + _currentSettings = mutate(_currentSettings); + SettingsService.Save(_currentSettings); + } + + private void EnvBtn_Click(object sender, RoutedEventArgs e) + { + EnvPopup.IsOpen = !EnvPopup.IsOpen; + if (EnvPopup.IsOpen) RefreshEnvironmentData(); + } + + private void EnvBoxModeCombo_Changed(object sender, SelectionChangedEventArgs e) + { + if (_envLoading) return; + if (EnvBoxModeCombo.SelectedItem is ComboBoxItem it && it.Tag is string mode) + PersistEnv(s => s with { EnvBoxMode = mode }); + RefreshEnvironmentData(); + } + + private void EnvHeatmapCheck_Changed(object sender, RoutedEventArgs e) + { + if (_envLoading) return; + PersistEnv(s => s with { EnvShowHeatmap = EnvHeatmapCheck.IsChecked == true }); + RefreshEnvironmentData(); + } + + private void EnvMetricCombo_Changed(object sender, SelectionChangedEventArgs e) + { + if (_envLoading) return; + if (EnvMetricCombo.SelectedItem is ComboBoxItem it && it.Tag is string key) + PersistEnv(s => s with { EnvMetric = key }); + RefreshEnvironmentData(); + } + + private void EnvNodeCheck_Changed(object sender, RoutedEventArgs e) + { + if (_envLoading) return; + if (sender is CheckBox cb && cb.Tag is uint id) + { + if (cb.IsChecked == true) _envDisabledNodes.Remove(id); + else _envDisabledNodes.Add(id); + var csv = string.Join(",", _envDisabledNodes); + PersistEnv(s => s with { EnvDisabledNodes = csv }); + RefreshEnvironmentData(); + } + } + + /// Debounced refresh; called whenever node/telemetry data changes. + private void ScheduleEnvRefresh() + { + if (!_currentSettings.ShowEnvironmentData) return; + if (_envRefreshTimer == null) + { + _envRefreshTimer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromSeconds(1.5) }; + _envRefreshTimer.Tick += (_, _) => { _envRefreshTimer!.Stop(); RefreshEnvironmentData(); }; + } + _envRefreshTimer.Stop(); + _envRefreshTimer.Start(); + } + + // ── Core refresh ────────────────────────────────────────────────────────── + + private void RefreshEnvironmentData() + { + if (!_currentSettings.ShowEnvironmentData || _db == null) + { + ClearEnvRasterLayer(); + ClearEnvVector(); + return; + } + + List readings; + try { readings = _db.GetLatestEnvironmentPerNode(); } + catch (Exception ex) { Services.Logger.WriteLine($"[Env] query error: {ex.Message}"); return; } + + // Positioned nodes only (a value box / heat point needs a location). + var nodeById = new Dictionary(); + foreach (var n in _allNodes) + if (n.Latitude.HasValue && n.Longitude.HasValue) nodeById[n.NodeId] = n; + + var contributing = new List<(TelemetryDatabaseService.EnvReading r, NodeInfo n)>(); + foreach (var r in readings) + if (nodeById.TryGetValue(r.NodeId, out var n)) contributing.Add((r, n)); + + // Node exclusion list — rebuild only when the contributing node set changed. + var sig = string.Join(",", contributing.Select(c => c.r.NodeId).OrderBy(x => x)); + if (sig != _envNodeSig) + { + _envNodeSig = sig; + BuildEnvNodeList(contributing.Select(c => c.n).ToList()); + } + + // Track which nodes report env data (drives the green marker); re-render pins on change. + var envIds = new HashSet(contributing.Select(c => c.r.NodeId)); + if (!envIds.SetEquals(_envNodeIds)) + { + _envNodeIds.Clear(); + foreach (var id in envIds) _envNodeIds.Add(id); + ScheduleNodePinPushToVectorMap(); + if (!UseVectorMap) foreach (var (_, n) in contributing) UpdateNodePin(n); + } + + var active = contributing.Where(c => !_envDisabledNodes.Contains(c.r.NodeId)).ToList(); + + var mode = _currentSettings.EnvBoxMode; + if (mode == "off") + { + _envRasterMode = "off"; _envRasterBoxData.Clear(); _envHoverNodeId = 0; + ClearEnvRasterLayer(); + PushEnvBoxesToVector("[]", "off"); + } + else PushEnvBoxes(active, mode); + + if (_currentSettings.EnvShowHeatmap) + PushEnvSurface(active.Select(c => c.n).ToList()); + else + ClearEnvSurfaceBoth(); + } + + // ── Info boxes ──────────────────────────────────────────────────────────── + + private static string EnvNodeTitle(NodeInfo n) => + string.IsNullOrEmpty(n.ShortName) ? n.Id : n.ShortName; + + private List BuildEnvLines(TelemetryDatabaseService.EnvReading r) + { + var lines = new List(); + foreach (var m in EnvironmentMetricInfo.All) + if (r.Values.TryGetValue(m.Key, out var v)) + lines.Add($"{Loc(m.LabelKey)}: {EnvironmentMetricInfo.Format(m.Key, v)}"); + return lines; + } + + private void PushEnvBoxes(List<(TelemetryDatabaseService.EnvReading r, NodeInfo n)> items, string mode) + { + bool dark = ModernWpf.ThemeManager.Current.ActualApplicationTheme == ModernWpf.ApplicationTheme.Dark; + + if (UseVectorMap) + { + ClearEnvRasterLayer(); + var arr = items.Select(it => new + { + id = it.n.NodeId, + lon = it.n.Longitude!.Value, + lat = it.n.Latitude!.Value, + title = EnvNodeTitle(it.n), + lines = BuildEnvLines(it.r), + time = it.r.Timestamp.ToString("dd.MM. HH:mm"), + dark + }); + PushEnvBoxesToVector(JsonSerializer.Serialize(arr), mode); + } + else + { + PushEnvBoxesToVector("[]", "off"); // clear vector side if it was active before + _envRasterMode = mode; + _envRasterBoxData.Clear(); + foreach (var it in items) _envRasterBoxData[it.n.NodeId] = it; + + if (mode == "hover") + { + // Show only the box of the node currently hovered (if still present). + if (_envHoverNodeId != 0 && _envRasterBoxData.TryGetValue(_envHoverNodeId, out var hov)) + BuildEnvRasterBoxes(new List<(TelemetryDatabaseService.EnvReading, NodeInfo)> { hov }, dark); + else + ClearEnvRasterLayer(); + } + else + { + BuildEnvRasterBoxes(items, dark); + } + } + } + + /// Raster-map hover: shows only the hovered node's value box (mode "hover"). + private void MapControl_EnvHover(object sender, System.Windows.Input.MouseEventArgs e) + { + if (UseVectorMap || !_currentSettings.ShowEnvironmentData || _envRasterMode != "hover") return; + if (MapControl.Map == null || _envRasterBoxData.Count == 0) return; + + var screenPos = e.GetPosition(MapControl); + uint hit = 0; double minDist = 22; + foreach (var kv in _envRasterBoxData) + { + if (!_nodePinPositions.TryGetValue(kv.Key, out var pinWorld)) continue; + var s = MapControl.Map.Navigator.Viewport.WorldToScreen(pinWorld); + var dist = Math.Sqrt(Math.Pow(screenPos.X - s.X, 2) + Math.Pow(screenPos.Y - s.Y, 2)); + if (dist < minDist) { minDist = dist; hit = kv.Key; } + } + + if (hit == _envHoverNodeId) return; // unchanged + _envHoverNodeId = hit; + if (hit == 0) { ClearEnvRasterLayer(); return; } + + bool dark = ModernWpf.ThemeManager.Current.ActualApplicationTheme == ModernWpf.ApplicationTheme.Dark; + BuildEnvRasterBoxes(new List<(TelemetryDatabaseService.EnvReading, NodeInfo)> { _envRasterBoxData[hit] }, dark); + } + + private void BuildEnvRasterBoxes(List<(TelemetryDatabaseService.EnvReading r, NodeInfo n)> items, bool dark) + { + _envFeatures.Clear(); + var boxFill = new Mapsui.Styles.Brush(new Mapsui.Styles.Color(255, 250, 205, 235)); // light yellow + foreach (var (r, n) in items) + { + var pos = SphericalMercator.FromLonLat(n.Longitude!.Value, n.Latitude!.Value); + var feature = new PointFeature(new MPoint(pos.x, pos.y)); + + var sb = new StringBuilder(EnvNodeTitle(n)); + foreach (var line in BuildEnvLines(r)) sb.Append('\n').Append(line); + sb.Append('\n').Append(r.Timestamp.ToString("dd.MM. HH:mm")); + + feature.Styles.Add(new LabelStyle + { + Text = sb.ToString(), + Font = new Mapsui.Styles.Font { FontFamily = "Segoe UI", Size = 11 }, + ForeColor = Mapsui.Styles.Color.Black, // dark text on the light-yellow box = high contrast + BackColor = boxFill, + HorizontalAlignment = LabelStyle.HorizontalAlignmentEnum.Center, + VerticalAlignment = LabelStyle.VerticalAlignmentEnum.Bottom, + Offset = new Offset(0, 22) + }); + _envFeatures.Add(feature); + } + if (_envLayer != null) { _envLayer.Features = _envFeatures; _envLayer.DataHasChanged(); } + MapControl?.Refresh(); + } + + // ── Interpolated value field / heatmap (vector map + raster map) ────────── + + private void PushEnvSurface(List nodes) + { + var metric = EnvironmentMetricInfo.ByKey(_currentSettings.EnvMetric) ?? EnvironmentMetricInfo.All[0]; + var now = DateTime.Now; + + // Each contributing node → one sensor point with its time-weighted representative value. + var sensors = new List(); + foreach (var n in nodes) + { + var series = _db!.GetTimeSeries(new[] { n.NodeId }, metric.Key, EnvHeatmapDays); + if (series.Count == 0) continue; + + var samples = series.Select(p => ((now - p.Timestamp).TotalHours, p.Value)).ToList(); + var rep = EnvironmentHeatmapBuilder.Representative(samples); + if (rep is not { } r) continue; + + sensors.Add(new EnvironmentField.Sensor( + n.Longitude!.Value, n.Latitude!.Value, r.Value, Math.Max(0.05, r.Recency))); + } + + var grid = sensors.Count > 0 ? EnvironmentField.Build(sensors, EnvInfluenceKm) : null; + Services.Logger.WriteLine($"[Env] surface push: metric={metric.Key} sensors={sensors.Count} " + + $"disabled=[{string.Join(",", _envDisabledNodes)}] " + + $"grid={(grid == null ? "none" : $"{grid.Nx}x{grid.Ny}")} vector={UseVectorMap}"); + if (grid == null) { ClearEnvSurfaceBoth(); return; } + + // Discrete colour bands so the fill colour changes exactly at the isotherm lines. + int nBands = Math.Max(1, (int)Math.Round((metric.HeatMax - metric.HeatMin) / metric.Step)); + var img = EnvironmentRaster.Render(grid, RgbBytesAt, metric.HeatMin, metric.HeatMax, maxOpacity: 0.6, bandCount: nBands); + if (img == null) { ClearEnvSurfaceBoth(); return; } + + // Contour lines at the interior band boundaries (5° etc.). + var levels = new List(); + for (int k = 1; k < nBands; k++) levels.Add(metric.HeatMin + k * metric.Step); + var segs = RoundSegs(EnvironmentContours.Build(grid, levels)); + + // Labels ON the isotherm lines: anchor at the actual contour crossing, value = the + // warmer adjacent zone's average. Thinned to ~one per 0.12° cell along the lines. + var zoneAvg = EnvironmentZones.ZoneAverages(grid, metric.HeatMin, metric.Step, nBands); + var inv = System.Globalization.CultureInfo.InvariantCulture; + var seenLbl = new HashSet<(int, int)>(); + var labels = new List<(double lon, double lat, string t)>(); + foreach (var (lon, lat, warmerCell) in EnvironmentContours.LineLabelAnchors(grid, levels)) + { + if (warmerCell < 0 || warmerCell >= zoneAvg.Length || double.IsNaN(zoneAvg[warmerCell])) continue; + int gx = (int)Math.Floor(lon / 0.12), gy = (int)Math.Floor(lat / 0.12); + if (seenLbl.Add((gx, gy))) + labels.Add((lon, lat, zoneAvg[warmerCell].ToString("0.00", inv))); + } + Services.Logger.WriteLine($"[Env] line labels: {labels.Count} (contours segs={segs.Count})"); + + // Outer border of the whole covered area: iso-line of the coverage (alpha) field. + var alphaGrid = new EnvironmentField.Grid + { + Nx = grid.Nx, Ny = grid.Ny, Lon0 = grid.Lon0, Lat0 = grid.Lat0, + DLon = grid.DLon, DLat = grid.DLat, Values = grid.Alpha, Alpha = grid.Alpha + }; + var border = RoundSegs(EnvironmentContours.Build(alphaGrid, new[] { 0.15 })); + + if (UseVectorMap) + { + ClearEnvRasterSurface(); + var payload = new + { + image = img.DataUri, + coords = new[] + { + new[] { img.MinLon, img.MaxLat }, // top-left (NW) + new[] { img.MaxLon, img.MaxLat }, // top-right (NE) + new[] { img.MaxLon, img.MinLat }, // bottom-right (SE) + new[] { img.MinLon, img.MinLat }, // bottom-left (SW) + }, + contours = segs, + border = border, + labels = labels.Select(p => new { lon = Math.Round(p.lon, 5), lat = Math.Round(p.lat, 5), t = p.t }).ToList(), + bands = EnvironmentMetricInfo.Bands(metric), + legend = new { label = Loc(metric.LabelKey), unit = metric.Unit, min = metric.HeatMin, max = metric.HeatMax, step = metric.Step } + }; + PushEnvSurfaceToVector(JsonSerializer.Serialize(payload)); + } + else + { + PushEnvSurfaceToVector("null"); // clear vector side if it was active before + DrawEnvSurfaceRaster(img, segs, border, labels); + UpdateEnvLegendWpf(metric); + } + } + + private void ClearEnvSurfaceBoth() + { + PushEnvSurfaceToVector("null"); + ClearEnvRasterSurface(); + } + + // ── Raster heatmap rendering (Mapsui) ───────────────────────────────────── + + private void DrawEnvSurfaceRaster(EnvironmentRaster.Result img, List contours, List border, + List<(double lon, double lat, string t)> labels) + { + var min = SphericalMercator.FromLonLat(img.MinLon, img.MinLat); + var max = SphericalMercator.FromLonLat(img.MaxLon, img.MaxLat); + var rect = new MRect(min.x, min.y, max.x, max.y); + var pngBytes = Convert.FromBase64String(img.DataUri.Substring(img.DataUri.IndexOf(',') + 1)); + + var rf = new RasterFeature(new MRaster(pngBytes, rect)); + rf.Styles.Add(new RasterStyle()); + _envFillFeatures = new List { rf }; + if (_envFillLayer != null) { _envFillLayer.Features = _envFillFeatures; _envFillLayer.DataHasChanged(); } + + _envLineFeatures.Clear(); + AddEnvLineSegs(contours, new Mapsui.Styles.Color(40, 40, 40, 128), 1.0); + AddEnvLineSegs(border, new Mapsui.Styles.Color(30, 30, 30, 217), 1.8); + + // Isotherm value labels (weather-report style): white number on a solid red box. + var lblFill = new Mapsui.Styles.Brush(new Mapsui.Styles.Color(198, 40, 40, 235)); + foreach (var lp in labels) + { + var pos = SphericalMercator.FromLonLat(lp.lon, lp.lat); + var f = new PointFeature(new MPoint(pos.x, pos.y)); + f.Styles.Add(new LabelStyle + { + Text = lp.t, + Font = new Mapsui.Styles.Font { FontFamily = "Segoe UI", Size = 11 }, + ForeColor = Mapsui.Styles.Color.White, + BackColor = lblFill, + HorizontalAlignment = LabelStyle.HorizontalAlignmentEnum.Center, + VerticalAlignment = LabelStyle.VerticalAlignmentEnum.Center + }); + _envLineFeatures.Add(f); + } + if (_envLineLayer != null) { _envLineLayer.Features = _envLineFeatures; _envLineLayer.DataHasChanged(); } + + MapControl?.Refresh(); + } + + private void AddEnvLineSegs(List segs, Mapsui.Styles.Color color, double width) + { + if (segs.Count == 0) return; + // One MultiLineString instead of thousands of features — else each pan frame renders + // them all individually and the raster map effectively freezes. + var lines = new LineString[segs.Count]; + for (int i = 0; i < segs.Count; i++) + { + var s = segs[i]; + var p1 = SphericalMercator.FromLonLat(s[0], s[1]); + var p2 = SphericalMercator.FromLonLat(s[2], s[3]); + lines[i] = new LineString(new[] { new Coordinate(p1.x, p1.y), new Coordinate(p2.x, p2.y) }); + } + var gf = new GeometryFeature { Geometry = new MultiLineString(lines) }; + gf.Styles.Add(new VectorStyle { Line = new Mapsui.Styles.Pen(color, width) }); + _envLineFeatures.Add(gf); + } + + private void ClearEnvRasterSurface() + { + bool changed = _envFillFeatures.Count > 0 || _envLineFeatures.Count > 0; + _envFillFeatures = new List(); + if (_envFillLayer != null) { _envFillLayer.Features = _envFillFeatures; _envFillLayer.DataHasChanged(); } + _envLineFeatures.Clear(); + if (_envLineLayer != null) { _envLineLayer.Features = _envLineFeatures; _envLineLayer.DataHasChanged(); } + UpdateEnvLegendWpf(null); + if (changed) MapControl?.Refresh(); + } + + /// WPF legend overlay for the raster map (the vector map has its own HTML legend). + private void UpdateEnvLegendWpf(EnvMetric? metric) + { + if (EnvLegendPanel == null) return; + if (metric == null || UseVectorMap) { EnvLegendPanel.Visibility = Visibility.Collapsed; return; } + + EnvLegendTitle.Text = Loc(metric.LabelKey) + (string.IsNullOrEmpty(metric.Unit) ? "" : $" ({metric.Unit})"); + EnvLegendRows.Children.Clear(); + var bands = EnvironmentMetricInfo.BandList(metric); + for (int i = bands.Count - 1; i >= 0; i--) // hottest band on top + { + var b = bands[i]; + var p = b.Rgb.Split(','); + var row = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 1, 0, 1) }; + row.Children.Add(new System.Windows.Shapes.Rectangle + { + Width = 15, Height = 11, + Stroke = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromArgb(80, 0, 0, 0)), + StrokeThickness = 1, + Fill = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb( + byte.Parse(p[0]), byte.Parse(p[1]), byte.Parse(p[2]))) + }); + row.Children.Add(new TextBlock + { + Text = $" {b.Lo:0.#}–{b.Hi:0.#}", + FontSize = 11, Margin = new Thickness(4, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Center + }); + EnvLegendRows.Children.Add(row); + } + EnvLegendPanel.Visibility = Visibility.Visible; + } + + private static List RoundSegs(List segs) => + segs.Select(s => new[] { Math.Round(s[0], 5), Math.Round(s[1], 5), Math.Round(s[2], 5), Math.Round(s[3], 5) }).ToList(); + + /// Gradient colour at normalised t (0..1) as RGB bytes (for the raster renderer). + private static (byte r, byte g, byte b) RgbBytesAt(double t) + { + var parts = EnvironmentMetricInfo.RgbAt(t).Split(','); + return ((byte)int.Parse(parts[0]), (byte)int.Parse(parts[1]), (byte)int.Parse(parts[2])); + } + + // ── Node exclusion list ─────────────────────────────────────────────────── + + private void BuildEnvNodeList(List nodes) + { + if (EnvNodesPanel == null) return; + _envLoading = true; + EnvNodesPanel.Children.Clear(); + + if (nodes.Count == 0) + { + var tb = new TextBlock + { + FontStyle = FontStyles.Italic, + Foreground = System.Windows.Media.Brushes.Gray, + FontSize = 11, + TextWrapping = TextWrapping.Wrap + }; + tb.SetResourceReference(TextBlock.TextProperty, "StrEnvNoNodes"); + EnvNodesPanel.Children.Add(tb); + _envLoading = false; + return; + } + + foreach (var n in nodes.OrderBy(x => string.IsNullOrEmpty(x.ShortName) ? x.Id : x.ShortName)) + { + var cb = new CheckBox + { + Content = string.IsNullOrEmpty(n.ShortName) ? n.Id : $"{n.ShortName} ({n.Id})", + Tag = n.NodeId, + IsChecked = !_envDisabledNodes.Contains(n.NodeId), + Margin = new Thickness(0, 1, 0, 1), + FontSize = 12 + }; + cb.Checked += EnvNodeCheck_Changed; + cb.Unchecked += EnvNodeCheck_Changed; + EnvNodesPanel.Children.Add(cb); + } + _envLoading = false; + } + + // ── Push helpers ────────────────────────────────────────────────────────── + + private void ClearEnvRasterLayer() + { + if (_envFeatures.Count == 0) return; + _envFeatures.Clear(); + if (_envLayer != null) { _envLayer.Features = _envFeatures; _envLayer.DataHasChanged(); } + MapControl?.Refresh(); + } + + private void ClearEnvVector() + { + PushEnvBoxesToVector("[]", "off"); + PushEnvSurfaceToVector("null"); + } + + private void PushEnvBoxesToVector(string json, string mode) + { + if (UseVectorMap) ExecVectorScript($"setEnvBoxes({json}, {JsonSerializer.Serialize(mode)})"); + } + private void PushEnvSurfaceToVector(string json) { if (UseVectorMap) ExecVectorScript($"setEnvSurface({json})"); } +} diff --git a/MeshhessenClient/MainWindow.Kiosk.cs b/MeshhessenClient/MainWindow.Kiosk.cs index 886b0f7..df66c1f 100644 --- a/MeshhessenClient/MainWindow.Kiosk.cs +++ b/MeshhessenClient/MainWindow.Kiosk.cs @@ -511,8 +511,7 @@ private void LoadMessagesFromDbAsync() // Resolve stored "Kanal N" names against the current channel list, then rebuild. RefreshChannelNames(); RebuildVisibleMessages(); - if (_messages.Count > 0) - MessageListView.ScrollIntoView(_messages[^1]); + ScrollMessagesToBottom(); }); } catch (Exception ex) @@ -618,11 +617,27 @@ private void RebuildVisibleMessages() private void MessageListView_ScrollChanged(object sender, ScrollChangedEventArgs e) { + // Don't lazy-load until the initial scroll-to-bottom has happened — otherwise the + // startup layout pass (VerticalOffset 0) would immediately drag us back to the top. + if (!_initialScrollDone) return; // When scrolled to (near) the top, lazy-load older messages from DB if (e.VerticalOffset < 10 && _messageDbManager != null && _dbOldestTimestamp != long.MaxValue) LazyLoadOlderMessages(); } + /// Scroll the chat to the newest message. Deferred to Background priority so the + /// (virtualized) list has finished its layout pass before we scroll — otherwise the scroll + /// is a no-op and we stay at the top. Also arms lazy-load-on-scroll-up. + private void ScrollMessagesToBottom() + { + Dispatcher.BeginInvoke(new Action(() => + { + if (_messages.Count > 0) + MessageListView.ScrollIntoView(_messages[^1]); + _initialScrollDone = true; + }), System.Windows.Threading.DispatcherPriority.Background); + } + private void ClearChannelMessages_Click(object sender, RoutedEventArgs e) { if (_messageDbManager == null) return; @@ -724,25 +739,35 @@ private void ClearSelectedChannelDb_Click(object sender, RoutedEventArgs e) private void UpdateMessageFilterComboBox() { - // Speichere aktuelle Auswahl - var selectedFilter = MessageChannelFilterComboBox.SelectedItem as ChannelInfo; + // Populate the (hidden) ComboBox without letting its SelectionChanged clobber the + // sidebar-driven _messageChannelFilter — see MessageChannelFilter_Changed. + _suppressChannelFilterCombo = true; + try + { + // Speichere aktuelle Auswahl + var selectedFilter = MessageChannelFilterComboBox.SelectedItem as ChannelInfo; - // Erstelle Liste mit "Alle" Option - var filterItems = new List(); - filterItems.Add(new ChannelInfo { Index = 999, Name = Loc("StrAllChannels"), Role = "" }); - filterItems.AddRange(_channels); + // Erstelle Liste mit "Alle" Option + var filterItems = new List(); + filterItems.Add(new ChannelInfo { Index = 999, Name = Loc("StrAllChannels"), Role = "" }); + filterItems.AddRange(_channels); - MessageChannelFilterComboBox.ItemsSource = filterItems; + MessageChannelFilterComboBox.ItemsSource = filterItems; - // Stelle Auswahl wieder her oder wähle "Alle" - if (selectedFilter != null) - { - var restored = filterItems.FirstOrDefault(c => c.Index == selectedFilter.Index); - MessageChannelFilterComboBox.SelectedItem = restored ?? filterItems[0]; + // Stelle Auswahl wieder her oder wähle "Alle" + if (selectedFilter != null) + { + var restored = filterItems.FirstOrDefault(c => c.Index == selectedFilter.Index); + MessageChannelFilterComboBox.SelectedItem = restored ?? filterItems[0]; + } + else + { + MessageChannelFilterComboBox.SelectedIndex = 0; + } } - else + finally { - MessageChannelFilterComboBox.SelectedIndex = 0; + _suppressChannelFilterCombo = false; } } @@ -796,6 +821,9 @@ private void OpenDmWindow_Click(object sender, RoutedEventArgs e) private void MessageChannelFilter_Changed(object sender, SelectionChangedEventArgs e) { + // The ComboBox is hidden and only ever changes programmatically (via + // UpdateMessageFilterComboBox). Ignore those so it can't overwrite the sidebar filter. + if (_suppressChannelFilterCombo) return; try { _messageChannelFilter = MessageChannelFilterComboBox.SelectedItem as ChannelInfo; @@ -824,14 +852,9 @@ private void MessageChannelFilter_Changed(object sender, SelectionChangedEventAr } } - // Sync send-channel dropdown to the selected filter channel - // (skip "Alle Kanäle" sentinel with Index 999) + // Send-channel now follows the sidebar selection (ActiveChannelComboBox removed). if (_messageChannelFilter != null && _messageChannelFilter.Index != 999) - { - var match = _channels.FirstOrDefault(c => c.Index == _messageChannelFilter.Index); - if (match != null && !Equals(ActiveChannelComboBox.SelectedItem, match)) - ActiveChannelComboBox.SelectedItem = match; - } + _activeChannelIndex = (int)_messageChannelFilter.Index; Services.Logger.WriteLine($"Message filter changed to: {_messageChannelFilter?.Name ?? "Alle"} ({_messages.Count}/{_allMessages.Count} messages)"); } @@ -860,15 +883,10 @@ private void SendDmToNode_Click(object sender, RoutedEventArgs e) return; } - // Öffne/Erstelle DM-Fenster - if (_dmWindow == null) - { - _dmWindow = new DirectMessagesWindow(_protocolService, _myNodeId); - _dmWindow.SetMessageDbManager(_messageDbManager); - } - - // Öffne Chat mit diesem Knoten - _dmWindow.OpenChatWithNode(selectedNode.NodeId, selectedNode.Name); + // Über OpenDmToNode leiten, damit der Inline/Fenster-Schalter aus den + // Einstellungen respektiert wird (Inline: springt in den Nachrichten-Tab, + // Fenster: öffnet das DM-Fenster und den passenden Chat). + OpenDmToNode(selectedNode); Services.Logger.WriteLine($"Opening DM chat with node: {selectedNode.Name} ({selectedNode.Id})"); } diff --git a/MeshhessenClient/MainWindow.Map.cs b/MeshhessenClient/MainWindow.Map.cs index 53a5d35..fdc51f6 100644 --- a/MeshhessenClient/MainWindow.Map.cs +++ b/MeshhessenClient/MainWindow.Map.cs @@ -106,6 +106,12 @@ private void InitializeMap() var tileSource = new TileSource(tileProvider, schema); _map.Layers.Add(new TileLayer(tileSource) { Name = "OSM" }); + // Environment heatmap: interpolated field image + contour/border lines (below data overlays) + _envFillLayer = new MemoryLayer("EnvFill") { Features = _envFillFeatures, Style = null }; + _map.Layers.Add(_envFillLayer); + _envLineLayer = new MemoryLayer("EnvLines") { Features = _envLineFeatures, Style = null }; + _map.Layers.Add(_envLineLayer); + // Neighbour-lines layer (rendered below node pins) _neighborLinesLayer = new MemoryLayer("NeighborLines") { Features = _neighborLineFeatures, Style = null }; _map.Layers.Add(_neighborLinesLayer); @@ -118,6 +124,10 @@ private void InitializeMap() _myPosLayer = new MemoryLayer("MyPosition") { Features = _myPosFeatures, Style = null }; _map.Layers.Add(_myPosLayer); + // Umweltdaten-Boxen (oberhalb der Node-Pins) + _envLayer = new MemoryLayer("EnvBoxes") { Features = _envFeatures, Style = null }; + _map.Layers.Add(_envLayer); + MapControl.Map = _map; MapControl.MouseRightButtonUp += MapControl_RightClick; // PreviewMouseLeftButtonDown fires before Mapsui starts pan tracking. @@ -125,6 +135,8 @@ private void InitializeMap() MapControl.PreviewMouseLeftButtonDown += MapControl_LeftClick_Preview; // MouseMove: cursor feedback when hovering near a segment MapControl.MouseMove += MapControl_MouseMoveSegmentHover; + // MouseMove: show env value box for the hovered node (box mode "hover") + MapControl.MouseMove += MapControl_EnvHover; // Karte auf eigenen Standort zentrieren var center = SphericalMercator.FromLonLat(_currentSettings.MyLongitude, _currentSettings.MyLatitude); @@ -146,6 +158,9 @@ private void InitializeMap() // Copyright-Hinweis basierend auf Kartenquelle setzen UpdateMapCopyright(); + + // Umweltdaten-Overlay (Button-Sichtbarkeit + Boxen/Heatmap) + ApplyEnvironmentUi(); } catch (Exception ex) { @@ -252,7 +267,15 @@ await VectorMapView.CoreWebView2.ExecuteScriptAsync( core.WebMessageReceived += VectorMap_WebMessageReceived; core.NavigationCompleted += VectorMap_NavigationCompleted; - core.Navigate("https://meshmap.local/map.html"); + // Cache-buster: map.html is served by WebView2's default virtual-host + // handler (it bypasses our interceptor), so WebView2 can cache it across + // runs. Key the URL to the extracted file's mtime → a rebuilt map.html + // (new JS) forces a fresh load instead of running a stale cached document. + var mapHtmlPath = Path.Combine(assetsDir, "map.html"); + var bust = File.Exists(mapHtmlPath) + ? new FileInfo(mapHtmlPath).LastWriteTimeUtc.Ticks.ToString() + : "0"; + core.Navigate($"https://meshmap.local/map.html?v={bust}"); // NavigationCompleted starts the map with the current settings } @@ -311,6 +334,18 @@ await VectorMapView.CoreWebView2.ExecuteScriptAsync( foreach (var (key, json) in _vectorLineJson.ToList()) ExecVectorScript($"setLines({JsonSerializer.Serialize(key)}, {json})"); + // Environment overlay (value boxes + heatmap) for the vector map + ApplyEnvironmentUi(); + // Diagnostic: confirm the running document actually has the env JS (catches a + // stale cached map.html) – logs "function/function" when current. + try + { + var probe = await VectorMapView.CoreWebView2.ExecuteScriptAsync( + "(typeof setEnvSurface)+'/'+(typeof setEnvBoxes)"); + Services.Logger.WriteLine($"[Env] js functions present: {probe}"); + } + catch { } + // A "Show on map" click that opened the map applies its center now that JS is callable. if (_pendingVectorCenter is { } pc) { @@ -531,6 +566,19 @@ private void VectorMap_WebMessageReceived(object? sender, Services.Logger.WriteLine($"[VectorMap] Map error: {msg}"); break; + case "envheat": + { + int pts = root.TryGetProperty("points", out var pEl) && pEl.TryGetInt32(out var pv) ? pv : -1; + bool added = root.TryGetProperty("added", out var aEl) && aEl.ValueKind == JsonValueKind.True; + Services.Logger.WriteLine($"[Env] heatmap layer applied: points={pts} added={added}"); + break; + } + + case "enverror": + Services.Logger.WriteLine($"[Env] JS error ({(root.TryGetProperty("where", out var wEl) ? wEl.GetString() : "?")}): " + + $"{(root.TryGetProperty("msg", out var meEl) ? meEl.GetString() : "?")}"); + break; + case "nodeclick": { var node = FindNodeFromMessage(root); @@ -693,7 +741,8 @@ private void PushNodePinsToVectorMap() lat = n.Latitude!.Value, color = CssColorFromHex(n.ColorHex, "#e53935"), label = (string.IsNullOrEmpty(n.ShortName) ? n.Id : n.ShortName) - + (string.IsNullOrEmpty(n.Note) ? "" : $" ({n.Note})") + + (string.IsNullOrEmpty(n.Note) ? "" : $" ({n.Note})"), + hasEnv = _currentSettings.ShowEnvironmentData && _envNodeIds.Contains(n.NodeId) }) .ToList(); ExecVectorScript($"setNodes({JsonSerializer.Serialize(nodes)})"); @@ -857,11 +906,14 @@ private void UpdateNodePin(NodeInfo node) } } + // Green outline signals the node reports environment data (when the feature is on). + bool hasEnv = _currentSettings.ShowEnvironmentData && _envNodeIds.Contains(node.NodeId); + var outlineColor = hasEnv ? new Mapsui.Styles.Color(46, 204, 64) : Mapsui.Styles.Color.White; feature.Styles.Add(new SymbolStyle { SymbolType = SymbolType.Ellipse, Fill = new Mapsui.Styles.Brush(pinColor), - Outline = new Mapsui.Styles.Pen(Mapsui.Styles.Color.White, 2), + Outline = new Mapsui.Styles.Pen(outlineColor, hasEnv ? 3.5 : 2), SymbolScale = 0.5 }); @@ -1309,6 +1361,12 @@ private void SetMyPosition(double lat, double lon) private void OpenDmToNode(NodeInfo node) { + // Inline (WhatsApp-style) mode: open the DM in the main overview instead of a window. + if (DmInline) + { + OpenInlineDm(node.NodeId, node.Name, node.ColorHex); + return; + } if (_dmWindow == null || !_dmWindow.IsVisible) { _dmWindow = new DirectMessagesWindow(_protocolService, _myNodeId); @@ -1319,6 +1377,17 @@ private void OpenDmToNode(NodeInfo node) _dmWindow.OpenChatWithNode(node.NodeId, node.Name, node.ColorHex); } + /// Open (or create) an inline DM conversation and show it in the Messages overview tab. + private void OpenInlineDm(uint nodeId, string name, string color) + { + if (nodeId == 0 || nodeId == _myNodeId) return; + var dm = GetOrCreateDmItem(nodeId, name, color); + MainTabs.SelectedIndex = 0; // Messages tab + bool alreadyOpen = _currentDmPartner == nodeId; + DmList.SelectedItem = dm; // fires DmList_SelectionChanged → shows the chat + if (alreadyOpen) DmList_SelectionChanged(DmList, null!); // force reload if it was already selected + } + private void ShowNodeInfoDialog(NodeInfo node) { var win = new NodeInfoWindow(node) { Owner = this }; diff --git a/MeshhessenClient/MainWindow.xaml b/MeshhessenClient/MainWindow.xaml index deaabfc..5206b74 100644 --- a/MeshhessenClient/MainWindow.xaml +++ b/MeshhessenClient/MainWindow.xaml @@ -589,21 +589,100 @@ + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + DisplayMemberPath="Name" + Visibility="Collapsed"/> @@ -611,7 +690,7 @@ + + +