From 5cf6d8b7ad87bdb281b9392f681f2fec801478ec Mon Sep 17 00:00:00 2001 From: SMLunchen Date: Sun, 30 Aug 2026 21:53:36 +0200 Subject: [PATCH 1/7] feat: Environment-Map --- CHANGELOG.md | 10 + MeshhessenClient.Tests/EnvironmentTests.cs | 178 +++++++++ MeshhessenClient/MainWindow.Environment.cs | 363 ++++++++++++++++++ MeshhessenClient/MainWindow.Map.cs | 42 +- MeshhessenClient/MainWindow.xaml | 42 ++ MeshhessenClient/MainWindow.xaml.cs | 6 + MeshhessenClient/Resources/Strings.de.xaml | 33 ++ MeshhessenClient/Resources/Strings.en.xaml | 33 ++ MeshhessenClient/Resources/VectorMap/map.html | 159 +++++++- MeshhessenClient/Services/EnvironmentField.cs | 91 +++++ .../Services/EnvironmentHeatmapBuilder.cs | 40 ++ .../Services/EnvironmentMetricInfo.cs | 118 ++++++ .../Services/MeshtasticProtocolService.cs | 14 +- MeshhessenClient/Services/SettingsService.cs | 21 +- .../Services/TelemetryDatabaseService.cs | 107 +++++- 15 files changed, 1231 insertions(+), 26 deletions(-) create mode 100644 MeshhessenClient.Tests/EnvironmentTests.cs create mode 100644 MeshhessenClient/MainWindow.Environment.cs create mode 100644 MeshhessenClient/Services/EnvironmentField.cs create mode 100644 MeshhessenClient/Services/EnvironmentHeatmapBuilder.cs create mode 100644 MeshhessenClient/Services/EnvironmentMetricInfo.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 254595b..c09d13e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ## [Unreleased] +### ✨ 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** (nur Vektor-Karte) für die gewählte Metrik — im **Wetterdienst-Stil**: die Farbe entspricht dem **tatsächlichen Messwert** auf einer kalibrierten 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. Ä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. Im Raster-Modus ein Hinweis, dass die Fläche nur in der Vektor-Karte verfügbar ist. +- **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 (Boxen an/aus, Heatmap an/aus, Metrik-Auswahl, Node-Liste); Einstellungen bleiben erhalten. + --- ## [1.6.2.5] - 2026-08-30 diff --git a/MeshhessenClient.Tests/EnvironmentTests.cs b/MeshhessenClient.Tests/EnvironmentTests.cs new file mode 100644 index 0000000..8f3d905 --- /dev/null +++ b/MeshhessenClient.Tests/EnvironmentTests.cs @@ -0,0 +1,178 @@ +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 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 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 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/MainWindow.Environment.cs b/MeshhessenClient/MainWindow.Environment.cs new file mode 100644 index 0000000..fc8a7dc --- /dev/null +++ b/MeshhessenClient/MainWindow.Environment.cs @@ -0,0 +1,363 @@ +// 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; + +namespace MeshhessenClient; + +public partial class MainWindow +{ + // Raster info-box layer (parallel to _nodeLayer; created in InitializeMap). + private MemoryLayer? _envLayer; + private readonly List _envFeatures = new(); + + private readonly HashSet _envDisabledNodes = new(); + 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(); + ClearEnvVector(); + return; + } + + EnsureEnvControlsBuilt(); + if (EnvHeatmapRasterHint != null) + EnvHeatmapRasterHint.Visibility = UseVectorMap ? Visibility.Collapsed : Visibility.Visible; + 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; + EnvBoxesCheck.IsChecked = _currentSettings.EnvShowBoxes; + 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 EnvBoxesCheck_Changed(object sender, RoutedEventArgs e) + { + if (_envLoading) return; + PersistEnv(s => s with { EnvShowBoxes = EnvBoxesCheck.IsChecked == true }); + 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()); + } + + var active = contributing.Where(c => !_envDisabledNodes.Contains(c.r.NodeId)).ToList(); + + if (_currentSettings.EnvShowBoxes) PushEnvBoxes(active); + else { ClearEnvRasterLayer(); PushEnvBoxesToVector("[]"); } + + if (_currentSettings.EnvShowHeatmap && UseVectorMap) + PushEnvSurface(active.Select(c => c.n).ToList()); + else + PushEnvSurfaceToVector("null"); + } + + // ── 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) + { + bool dark = ModernWpf.ThemeManager.Current.ActualApplicationTheme == ModernWpf.ApplicationTheme.Dark; + + if (UseVectorMap) + { + ClearEnvRasterLayer(); + var arr = items.Select(it => new + { + 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)); + } + else + { + PushEnvBoxesToVector("[]"); // clear vector side if it was active before + BuildEnvRasterBoxes(items, 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 Emoji", Size = 11 }, + ForeColor = Mapsui.Styles.Color.Black, + BackColor = boxFill, + // Themed contrast outline around the text (white at night, black by day). + Halo = new Mapsui.Styles.Pen(dark ? Mapsui.Styles.Color.White : Mapsui.Styles.Color.Black, 1), + 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 only) ────────────────── + + 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} " + + $"grid={(grid == null ? "none" : $"{grid.Nx}x{grid.Ny}")} vector={UseVectorMap}"); + if (grid == null) { PushEnvSurfaceToVector("null"); return; } + + var payload = new + { + lon0 = grid.Lon0, lat0 = grid.Lat0, dlon = grid.DLon, dlat = grid.DLat, + nx = grid.Nx, ny = grid.Ny, + v = grid.Values.Select(x => double.IsNaN(x) ? (double?)null : Math.Round(x, 2)).ToArray(), + a = grid.Alpha.Select(x => Math.Round(x, 3)).ToArray(), + stops = EnvironmentMetricInfo.ColorStops(metric), + legend = new { label = Loc(metric.LabelKey), unit = metric.Unit, min = metric.HeatMin, max = metric.HeatMax } + }; + PushEnvSurfaceToVector(JsonSerializer.Serialize(payload)); + } + + // ── 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("[]"); + PushEnvSurfaceToVector("null"); + } + + private void PushEnvBoxesToVector(string json) { if (UseVectorMap) ExecVectorScript($"setEnvBoxes({json})"); } + private void PushEnvSurfaceToVector(string json) { if (UseVectorMap) ExecVectorScript($"setEnvSurface({json})"); } +} diff --git a/MeshhessenClient/MainWindow.Map.cs b/MeshhessenClient/MainWindow.Map.cs index 53a5d35..7c21d21 100644 --- a/MeshhessenClient/MainWindow.Map.cs +++ b/MeshhessenClient/MainWindow.Map.cs @@ -118,6 +118,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. @@ -146,6 +150,9 @@ private void InitializeMap() // Copyright-Hinweis basierend auf Kartenquelle setzen UpdateMapCopyright(); + + // Umweltdaten-Overlay (Button-Sichtbarkeit + Boxen/Heatmap) + ApplyEnvironmentUi(); } catch (Exception ex) { @@ -252,7 +259,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 +326,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 +558,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); diff --git a/MeshhessenClient/MainWindow.xaml b/MeshhessenClient/MainWindow.xaml index deaabfc..41a700f 100644 --- a/MeshhessenClient/MainWindow.xaml +++ b/MeshhessenClient/MainWindow.xaml @@ -1631,6 +1631,37 @@ AllowsTransparency="True"> + + +