perf(wire)+ts: pack scores as flat tuples, bump ProfilesFile gzip, tighten TS types

**Wire format — /map scores payload.** Every `update_scores` /
`preload_forecast` / `data-scores` embed previously shipped each
point as `{"lat":X,"lon":Y,"score":Z}`. At 95k cells in full CONUS
that's ~36 bytes/point × 95k = 3.4 MB of JSON, ~50% of it repeating
the three key names. Packing as `[lat, lon, score]` tuples drops
per-point overhead to ~19 bytes → ~1.8 MB on the wire, ~45% smaller.
New `Propagation.pack_scores/1` is used at every push_event +
initial_scores_json boundary; internal Elixir still uses the
`%{lat, lon, score}` map so no other callers change.

JS hook: `ScorePoint` becomes `[number, number, number]`, the
`renderScores` loop destructures to positional args. Regression
test in map_live_test updated to match the new shape.

**ProfilesFile compression.** Rust writer was at gzip level 6
(default). Files are write-once-per-forecast-hour but read by
every pod mount + point_detail click. Bumped to level 9 (best):
~30% slower encode (hidden by spawn_blocking — not on user path),
~5–10% smaller on semi-structured MessagePack bodies. No reader
changes needed — same gzip stream.

**TypeScript strictness.**
- `app.ts`: replace `({detail}: any)` on phx:live_reload:attached
  with a CustomEvent<Reloader> shape naming only the API we touch.
- `weather_map_hook.ts`: drop the `(this as any)._map` pair by
  declaring a `GridLayerWithMap = L.GridLayer & { _map: L.Map }`
  intersection on the overlay's createTile `this` parameter.
- `global.d.ts`: replace `liveSocket: any` / `liveReloader: any`
  with focused LiveSocketLike / LiveReloaderLike shapes that name
  only the devtools-visible API.

No more `any` in the codebase (verified via rg).
This commit is contained in:
Graham McIntire 2026-04-21 17:45:01 -05:00
parent a232670139
commit e688d8c244
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
8 changed files with 93 additions and 28 deletions

View file

@ -167,7 +167,15 @@ window.liveSocket = liveSocket
// 2. click on elements to jump to their definitions in your code editor
//
if (process.env.NODE_ENV === "development") {
window.addEventListener("phx:live_reload:attached", ({detail: reloader}: any) => {
// Phoenix.LiveReloader attaches a small API surface to its detached
// `reloader` instance. Type only what we actually touch instead of `any`.
type Reloader = {
enableServerLogs: () => void
openEditorAtCaller: (target: EventTarget | null) => void
openEditorAtDef: (target: EventTarget | null) => void
}
window.addEventListener("phx:live_reload:attached", ((event: CustomEvent<Reloader>) => {
const reloader = event.detail
// Enable server log streaming to client.
// Disable with reloader.disableServerLogs()
reloader.enableServerLogs()
@ -192,5 +200,5 @@ if (process.env.NODE_ENV === "development") {
}, true)
window.liveReloader = reloader
})
}) as EventListener)
}

View file

@ -51,12 +51,14 @@ interface LatLon {
lon: number
}
interface ScorePoint {
lat: number
lon: number
score: number
[key: string]: unknown
}
// Wire format from the server: [lat, lon, score] per point. Flat
// 3-tuples avoid repeating the three JSON keys across ~95k points
// (~45% payload shrink vs the old {lat, lon, score} object form).
type ScorePoint = [number, number, number]
// Object shape returned by lookupPoint — merges a resolved cell's
// coordinates + score with the active band metadata for UI display.
type LookupPointResult = { lat: number; lon: number; score: number } & BandInfo
interface ForecastPoint {
score: number
@ -173,7 +175,7 @@ interface PropagationMapHook extends ViewHook {
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
lookupPoint(this: PropagationMapHook, lat: number, lon: number): LookupPointResult | null
drawScatterMarkers(this: PropagationMapHook, scatter: RainScatter): void
renderTimeline(this: PropagationMapHook): void
selectTimelineTime(this: PropagationMapHook, time: string): void
@ -675,7 +677,7 @@ export const PropagationMap: Record<string, unknown> & {
renderScores(this: PropagationMapHook, scores: ScorePoint[]): 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
lookupPoint(this: PropagationMapHook, lat: number, lon: number): LookupPointResult | null
drawScatterMarkers(this: PropagationMapHook, scatter: RainScatter): void
renderTimeline(this: PropagationMapHook): void
selectTimelineTime(this: PropagationMapHook, time: string): void
@ -1168,7 +1170,7 @@ export const PropagationMap: Record<string, unknown> & {
} else {
this.scoreGrid.reset()
}
scores.forEach((s) => this.scoreGrid.put(s.lat, s.lon, s.score))
scores.forEach(([lat, lon, score]) => this.scoreGrid.put(lat, lon, score))
if (!this.colorCache) {
this.colorCache = new Array<string>(101)
@ -1300,7 +1302,7 @@ export const PropagationMap: Record<string, unknown> & {
return { r: last.r, g: last.g, b: last.b }
},
lookupPoint(this: PropagationMapHook, lat: number, lon: number): (ScorePoint & BandInfo) | null {
lookupPoint(this: PropagationMapHook, lat: number, lon: number): LookupPointResult | null {
if (!this.scoreGrid) return null
const rlat = Math.round(lat / GRID_STEP) * GRID_STEP
const rlon = Math.round(lon / GRID_STEP) * GRID_STEP

View file

@ -108,9 +108,28 @@ declare module "../../deps/live_stash/assets/js/live-stash.js" {
): (params: Record<string, unknown>) => Record<string, unknown>
}
// Phoenix LiveSocket + LiveReloader are stashed on window for the
// DevTools console. Dev-only use; we only need the minimal API shapes
// that other code references, not the complete vendor interfaces.
interface LiveSocketLike {
connect(): void
disconnect(): void
enableDebug(): void
disableDebug(): void
enableLatencySim(ms: number): void
disableLatencySim(): void
}
interface LiveReloaderLike {
enableServerLogs(): void
disableServerLogs(): void
openEditorAtCaller(target: EventTarget | null): void
openEditorAtDef(target: EventTarget | null): void
}
interface Window {
L: typeof import("leaflet")
liveSocket: any
liveReloader: any
liveSocket: LiveSocketLike
liveReloader: LiveReloaderLike
confetti: (opts?: ConfettiOptions) => Promise<void> | null
}

View file

@ -633,8 +633,13 @@ export const WeatherMap: WeatherMapHook = {
const self = this
const isDucting = layerId === "ducting"
// Leaflet's GridLayer attaches a `_map` back-reference at runtime
// but doesn't expose it on the public typing. Narrow the `this`
// shape so we can pull the active map without a cast-to-any.
type GridLayerWithMap = L.GridLayer & { _map: L.Map }
const Overlay = L.GridLayer.extend({
createTile(this: L.GridLayer, coords: L.Coords) {
createTile(this: GridLayerWithMap, coords: L.Coords) {
const tile = document.createElement("canvas")
const size = this.getTileSize()
tile.width = size.x
@ -643,8 +648,8 @@ export const WeatherMap: WeatherMapHook = {
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 nw = this._map.unproject(nwPoint, coords.z)
const se = this._map.unproject(nwPoint.add(size), coords.z)
const latPerPx = (nw.lat - se.lat) / size.y
const lonPerPx = (se.lng - nw.lng) / size.x

View file

@ -363,6 +363,23 @@ defmodule Microwaveprop.Propagation do
end)
end
@doc """
Pack a list of `%{lat, lon, score}` into `[[lat, lon, score], ...]`
for transmission to the browser. Repeating the three JSON keys for
every one of ~95k cells added ~50% to the wire payload; the flat
array representation trims ~4045% of the bytes without changing
the information content. Browser-side, the typed `ScorePoint`
tuple is `[lat, lon, score]` and `ScoreGrid.put` takes positional
args, so the client decoder is also one allocation lighter per
point.
"""
@spec pack_scores([%{lat: float(), lon: float(), score: non_neg_integer()}]) :: [
[number()]
]
def pack_scores(scores) when is_list(scores) do
Enum.map(scores, fn %{lat: lat, lon: lon, score: score} -> [lat, lon, score] end)
end
@doc "Get the latest scores for a band (alias for scores_at with earliest valid_time)."
@spec latest_scores(non_neg_integer(), %{optional(String.t()) => float()} | nil) ::
[%{lat: float(), lon: float(), score: non_neg_integer(), valid_time: DateTime.t()}]

View file

@ -88,7 +88,7 @@ defmodule MicrowavepropWeb.MapLive do
{"[]", AsyncResult.loading(), socket}
else
scores = Propagation.scores_at(selected_band, selected_time, bounds)
{Jason.encode!(scores), AsyncResult.ok(scores), socket}
{Jason.encode!(Propagation.pack_scores(scores)), AsyncResult.ok(scores), socket}
end
# preload_forecast runs on the first map_bounds event instead of
@ -232,7 +232,7 @@ defmodule MicrowavepropWeb.MapLive do
|> assign(selected_band: band, valid_times: valid_times, selected_time: selected_time)
|> assign(:tracking_now?, tracking_now?(selected_time, valid_times, DateTime.utc_now()))
|> LiveStash.stash_assigns([:selected_band, :selected_time])
|> push_event("update_scores", %{scores: scores})
|> push_event("update_scores", %{scores: Propagation.pack_scores(scores)})
|> push_event("update_band_info", %{band_info: band_info(band)})
|> push_timeline()
|> patch_map_url()
@ -249,7 +249,7 @@ defmodule MicrowavepropWeb.MapLive do
socket
|> assign(:selected_time, time)
|> assign(:tracking_now?, tracking_now?(time, socket.assigns.valid_times, DateTime.utc_now()))
|> push_event("update_scores", %{scores: scores})
|> push_event("update_scores", %{scores: Propagation.pack_scores(scores)})
|> patch_map_url()
{:noreply, socket}
@ -395,7 +395,7 @@ defmodule MicrowavepropWeb.MapLive do
{:noreply,
socket
|> assign(:bounds_flush_timer, nil)
|> push_event("update_scores", %{scores: scores})}
|> push_event("update_scores", %{scores: Propagation.pack_scores(scores)})}
end
def handle_info(:preload_forecast, socket) do
@ -414,7 +414,12 @@ defmodule MicrowavepropWeb.MapLive do
hours =
forecast_times
|> Task.async_stream(
fn t -> %{time: DateTime.to_iso8601(t), scores: Propagation.scores_at(band, t, bounds)} end,
fn t ->
%{
time: DateTime.to_iso8601(t),
scores: Propagation.pack_scores(Propagation.scores_at(band, t, bounds))
}
end,
max_concurrency: 4,
ordered: true,
timeout: 10_000
@ -455,7 +460,7 @@ defmodule MicrowavepropWeb.MapLive do
socket
|> assign(valid_times: valid_times, selected_time: selected_time)
|> assign(:pipeline_status, PipelineStatus.current())
|> push_event("update_scores", %{scores: scores})
|> push_event("update_scores", %{scores: Propagation.pack_scores(scores)})
|> push_timeline()
{:noreply, socket}
@ -474,7 +479,7 @@ defmodule MicrowavepropWeb.MapLive do
socket =
socket
|> assign(:selected_time, new_time)
|> push_event("update_scores", %{scores: scores})
|> push_event("update_scores", %{scores: Propagation.pack_scores(scores)})
|> push_timeline()
|> patch_map_url(replace: true)
@ -516,7 +521,7 @@ defmodule MicrowavepropWeb.MapLive do
socket =
socket
|> assign(:initial_scores, AsyncResult.ok(current, scores))
|> push_event("update_scores", %{scores: scores})
|> push_event("update_scores", %{scores: Propagation.pack_scores(scores)})
{:noreply, socket}
end

View file

@ -113,7 +113,15 @@ pub fn write_atomic(
{
let file = File::create(&tmp)?;
let mut gz = GzEncoder::new(BufWriter::new(file), Compression::default());
// ProfilesFile is a write-once / read-many blob — each hourly
// cycle writes it ~once but every LiveView mount, point_detail
// click, and forecast scrub pays the read cost (up to ~3 MB
// uncompressed per file × ~5 cached hours per pod). Level 9 is
// ~30% slower to encode than the default (6) but 510% smaller
// on semi-structured MessagePack, and our write path already
// off-loads to `spawn_blocking` so encode latency isn't on the
// user path. Readers are level-agnostic — same gzip stream.
let mut gz = GzEncoder::new(BufWriter::new(file), Compression::best());
let body = FileBody {
v: FORMAT_VERSION,
valid_time: valid_time.format("%Y-%m-%dT%H:%M:%SZ").to_string(),

View file

@ -555,9 +555,10 @@ defmodule MicrowavepropWeb.MapLiveTest do
send(lv.pid, {:propagation_updated, [valid_time]})
# The map must emit the new score (90), not the stale cached one (40).
# Wire format is packed: each point is `[lat, lon, score]`.
assert_push_event(lv, "update_scores", %{scores: scores})
assert Enum.any?(scores, fn s -> s.score == 90 end)
refute Enum.any?(scores, fn s -> s.score == 40 end)
assert Enum.any?(scores, fn [_, _, score] -> score == 90 end)
refute Enum.any?(scores, fn [_, _, score] -> score == 40 end)
end
end