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:
parent
a232670139
commit
e688d8c244
8 changed files with 93 additions and 28 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
23
assets/js/types/global.d.ts
vendored
23
assets/js/types/global.d.ts
vendored
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()}]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue