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:
parent
80de61451c
commit
cb6cc8a6e2
3 changed files with 82 additions and 44 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue