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).
This commit is contained in:
Graham McIntire 2026-04-12 13:34:12 -05:00
parent 80de61451c
commit cb6cc8a6e2
3 changed files with 82 additions and 44 deletions

View file

@ -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<string, unknown> & {
}
scores.forEach((s) => this.scoreGrid.put(s.lat, s.lon, s.score))
if (!this.colorCache) {
this.colorCache = new Array<string>(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<string, { el: HTMLCanvasElement; coords: L.Coords }> })._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<string>(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<string, unknown> & {
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)

View file

@ -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,

View file

@ -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)