From cb6cc8a6e26d70dad9c13cbb96bd686816bb9aa9 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 12 Apr 2026 13:34:12 -0500 Subject: [PATCH] Fix map forecast hours, blink, and broken prod email - Forecast hour clicks showed only a small square of coverage because :preload_forecast ran at mount using a hardcoded fallback bounding box instead of the client's actual viewport. Move the preload trigger into handle_event("map_bounds", ...) so the cache always matches the viewport the client just asked about. - Map overlay blinked on load/update because renderScores called Leaflet GridLayer.redraw(), which removes every tile element before recreating them. Repaint the existing tile canvases in place instead. - Prod SMTP to mail.smtp2go.com was failing with an "unexpected_message" TLS alert: gen_smtp wasn't sending SNI so the wildcard cert endpoint rejected the handshake. Set tls: :always with explicit tls_options (server_name_indication, versions, verify). --- assets/js/propagation_map_hook.ts | 98 ++++++++++++++++---------- config/runtime.exs | 18 +++-- lib/microwaveprop_web/live/map_live.ex | 10 ++- 3 files changed, 82 insertions(+), 44 deletions(-) diff --git a/assets/js/propagation_map_hook.ts b/assets/js/propagation_map_hook.ts index b31a68a8..6a7b182f 100644 --- a/assets/js/propagation_map_hook.ts +++ b/assets/js/propagation_map_hook.ts @@ -146,12 +146,14 @@ interface PropagationMapHook extends ViewHook { showDetailPanel(this: PropagationMapHook): void hideDetailPanel(this: PropagationMapHook): void renderScores(this: PropagationMapHook, scores: ScorePoint[]): void + drawTileCanvas(this: PropagationMapHook, canvas: HTMLCanvasElement, coords: L.Coords, layer: L.GridLayer): void interpolateScore(this: PropagationMapHook, lat: number, lon: number, step: number): number | null scoreColorRGB(this: PropagationMapHook, score: number): RGB lookupPoint(this: PropagationMapHook, lat: number, lon: number): (ScorePoint & BandInfo) | null drawScatterMarkers(this: PropagationMapHook, scatter: RainScatter): void renderTimeline(this: PropagationMapHook): void sendBounds(this: PropagationMapHook): void + colorCache: string[] } // --- Constants --- @@ -995,21 +997,31 @@ export const PropagationMap: Record & { } scores.forEach((s) => this.scoreGrid.put(s.lat, s.lon, s.score)) + if (!this.colorCache) { + this.colorCache = new Array(101) + for (let i = 0; i <= 100; i++) { + const c = this.scoreColorRGB(i) + this.colorCache[i] = `rgba(${c.r},${c.g},${c.b},0.55)` + } + } + if (this.scoreOverlay) { - // Layer already on the map — just redraw existing tiles against the - // updated grid data. Avoids the remove/add flash that caused the - // overlay to blink during the initial load pipeline. - this.scoreOverlay.redraw() + // Repaint each existing tile canvas in place. Leaflet's redraw() calls + // _removeAllTiles() which briefly leaves the overlay empty — visible as + // a flash on every update_scores event (mount sendBounds, invalidateSize + // moveend, preload_forecast, etc.). Writing fresh pixels onto existing + // canvases keeps the DOM stable and eliminates the blink. + const tiles = (this.scoreOverlay as unknown as { _tiles: Record })._tiles || {} + for (const key in tiles) { + const t = tiles[key] + if (t && t.el && t.coords) { + this.drawTileCanvas(t.el, t.coords, this.scoreOverlay) + } + } return } // First render — build the GridLayer once and reuse it for every update. - const colorCache = new Array(101) - for (let i = 0; i <= 100; i++) { - const c = this.scoreColorRGB(i) - colorCache[i] = `rgba(${c.r},${c.g},${c.b},0.55)` - } - // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this this.scoreOverlay = new (L.GridLayer.extend({ @@ -1018,41 +1030,49 @@ export const PropagationMap: Record & { const size = this.getTileSize() tile.width = size.x tile.height = size.y - const ctx = tile.getContext("2d")! - - const step = 0.125 - const nwPoint = coords.scaleBy(size) - const nw = (this as any)._map.unproject(nwPoint, coords.z) as L.LatLng - const se = (this as any)._map.unproject(nwPoint.add(size), coords.z) as L.LatLng - - const latPerPx = (nw.lat - se.lat) / size.y - const lonPerPx = (se.lng - nw.lng) / size.x - const cellPxX = Math.max(1, Math.ceil(step / lonPerPx)) - const cellPxY = Math.max(1, Math.ceil(step / Math.abs(latPerPx))) - const absLatPerPx = Math.abs(latPerPx) - - let lastColor: string | null = null - for (let py = 0; py < size.y; py += cellPxY) { - const lat = nw.lat - py * absLatPerPx - for (let px = 0; px < size.x; px += cellPxX) { - const lon = nw.lng + px * lonPerPx - const score = self.interpolateScore(lat, lon, step) - if (score !== null) { - const color = colorCache[Math.max(0, Math.min(100, Math.round(score)))] - if (color !== lastColor) { - ctx.fillStyle = color - lastColor = color - } - ctx.fillRect(px, py, cellPxX, cellPxY) - } - } - } + self.drawTileCanvas(tile, coords, this) return tile } }))({ opacity: 1.0 }) as L.GridLayer this.scoreOverlay.addTo(this.map) }, + drawTileCanvas(this: PropagationMapHook, canvas: HTMLCanvasElement, coords: L.Coords, layer: L.GridLayer) { + const ctx = canvas.getContext("2d")! + const size = layer.getTileSize() + ctx.clearRect(0, 0, size.x, size.y) + + const step = 0.125 + const nwPoint = coords.scaleBy(size) + const map = (layer as unknown as { _map: L.Map })._map + if (!map) return + const nw = map.unproject(nwPoint, coords.z) as L.LatLng + const se = map.unproject(nwPoint.add(size), coords.z) as L.LatLng + + const latPerPx = (nw.lat - se.lat) / size.y + const lonPerPx = (se.lng - nw.lng) / size.x + const cellPxX = Math.max(1, Math.ceil(step / lonPerPx)) + const cellPxY = Math.max(1, Math.ceil(step / Math.abs(latPerPx))) + const absLatPerPx = Math.abs(latPerPx) + + let lastColor: string | null = null + for (let py = 0; py < size.y; py += cellPxY) { + const lat = nw.lat - py * absLatPerPx + for (let px = 0; px < size.x; px += cellPxX) { + const lon = nw.lng + px * lonPerPx + const score = this.interpolateScore(lat, lon, step) + if (score !== null) { + const color = this.colorCache[Math.max(0, Math.min(100, Math.round(score)))] + if (color !== lastColor) { + ctx.fillStyle = color + lastColor = color + } + ctx.fillRect(px, py, cellPxX, cellPxY) + } + } + } + }, + interpolateScore(this: PropagationMapHook, lat: number, lon: number, _step: number): number | null { // Snapped cell — hot path, zero allocations const latIdxR = Math.round((lat - GRID_LAT_MIN) / GRID_STEP) diff --git a/config/runtime.exs b/config/runtime.exs index 74250e02..635fb69e 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -99,15 +99,25 @@ if config_env() == :prod do # # config :microwaveprop, Microwaveprop.Mailer, # adapter: Swoosh.Adapters.Mailgun, + # tls: :always + explicit tls_options is required — SMTP2GO's endpoint sits + # behind a wildcard cert (*.smtp2go.com) so SNI is mandatory, and gen_smtp + # won't set it without explicit server_name_indication. Without this, the + # handshake fails with an "unexpected_message" TLS alert. + email_server = System.get_env("EMAIL_SERVER") + config :microwaveprop, Microwaveprop.Mailer, adapter: Swoosh.Adapters.SMTP, - relay: System.get_env("EMAIL_SERVER"), + relay: email_server, port: 587, username: System.get_env("EMAIL_USERNAME"), password: System.get_env("EMAIL_PASSWORD"), - tls: :if_available, - auth: :always, - ssl: false + tls: :always, + tls_options: [ + server_name_indication: String.to_charlist(email_server || ""), + versions: [:"tlsv1.2", :"tlsv1.3"], + verify: :verify_none + ], + auth: :always config :microwaveprop, Microwaveprop.Repo, # ssl: true, diff --git a/lib/microwaveprop_web/live/map_live.ex b/lib/microwaveprop_web/live/map_live.ex index a2a82101..8a6ffd13 100644 --- a/lib/microwaveprop_web/live/map_live.ex +++ b/lib/microwaveprop_web/live/map_live.ex @@ -32,7 +32,11 @@ defmodule MicrowavepropWeb.MapLive do bounds = bounds_around(center) initial_scores = Propagation.scores_at(@default_band, selected_time, bounds) - if connected?(socket), do: send(self(), :preload_forecast) + # preload_forecast runs on the first map_bounds event instead of + # here — mount only knows a hardcoded fallback bounding box, so + # preloading now populates the forecast cache with scores for the + # wrong viewport and forecast-hour clicks show a small square of + # coverage instead of the full map. {:ok, assign(socket, @@ -169,6 +173,10 @@ defmodule MicrowavepropWeb.MapLive do def handle_event("map_bounds", bounds, socket) do scores = Propagation.scores_at(socket.assigns.selected_band, socket.assigns.selected_time, bounds) + # Refresh the forecast cache for the new viewport — the client cleared its + # local cache in sendBounds() so any timeline scrub would otherwise miss. + send(self(), :preload_forecast) + socket = socket |> assign(:bounds, bounds)