diff --git a/assets/js/app.ts b/assets/js/app.ts index 6f3c5b05..87088500 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -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) => { + 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) } diff --git a/assets/js/propagation_map_hook.ts b/assets/js/propagation_map_hook.ts index 6ec997d8..b0f8a3a4 100644 --- a/assets/js/propagation_map_hook.ts +++ b/assets/js/propagation_map_hook.ts @@ -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 & { 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 & { } 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(101) @@ -1300,7 +1302,7 @@ export const PropagationMap: Record & { 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 diff --git a/assets/js/types/global.d.ts b/assets/js/types/global.d.ts index 99a2ecce..db6cc0e3 100644 --- a/assets/js/types/global.d.ts +++ b/assets/js/types/global.d.ts @@ -108,9 +108,28 @@ declare module "../../deps/live_stash/assets/js/live-stash.js" { ): (params: Record) => Record } +// 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 | null } diff --git a/assets/js/weather_map_hook.ts b/assets/js/weather_map_hook.ts index 7ab44ca1..18953767 100644 --- a/assets/js/weather_map_hook.ts +++ b/assets/js/weather_map_hook.ts @@ -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 diff --git a/lib/microwaveprop/propagation.ex b/lib/microwaveprop/propagation.ex index c8b4cbf7..ecd446d0 100644 --- a/lib/microwaveprop/propagation.ex +++ b/lib/microwaveprop/propagation.ex @@ -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 ~40–45% 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()}] diff --git a/lib/microwaveprop_web/live/map_live.ex b/lib/microwaveprop_web/live/map_live.ex index e3f623ee..de17159a 100644 --- a/lib/microwaveprop_web/live/map_live.ex +++ b/lib/microwaveprop_web/live/map_live.ex @@ -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 diff --git a/rust/prop_grid_rs/src/profiles_file.rs b/rust/prop_grid_rs/src/profiles_file.rs index 3160a4ab..497aac3e 100644 --- a/rust/prop_grid_rs/src/profiles_file.rs +++ b/rust/prop_grid_rs/src/profiles_file.rs @@ -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 5–10% 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(), diff --git a/test/microwaveprop_web/live/map_live_test.exs b/test/microwaveprop_web/live/map_live_test.exs index 0c5d55e9..53839e01 100644 --- a/test/microwaveprop_web/live/map_live_test.exs +++ b/test/microwaveprop_web/live/map_live_test.exs @@ -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