feat(path): hover-on-dot factor breakdown + tag fallback factors
The 18-hour forecast chart on /path now shows the same weighted-
criteria panel the /map popup builds when the user clicks a grid
cell. Hovering a dot fires a server roundtrip that fetches
Propagation.point_detail at the path's midpoint for that valid_time;
results are cached client-side so re-hovering is instant.
`Propagation.point_detail/4` now returns a `:profile_source` tag —
`:exact`, `{:fallback, fallback_valid_time}`, or `:unavailable` —
distinguishing real per-hour breakdowns (Rust pipeline writes a
profile file for every f00..f18 step) from rare cycle-miss fallbacks
where we approximate from a recent analysis profile. Stale comment
about "only f00 persists profiles" updated.
Map utilities (FACTOR_META, FACTOR_ORDER, factorBar,
factorExplanation, scoreTier, FactorMeta, ScoreTier) are now exported
from propagation_map_hook so the new path hook reuses the exact same
look + copy.
This commit is contained in:
parent
2c30682f2f
commit
2fc434df56
5 changed files with 314 additions and 25 deletions
|
|
@ -13,6 +13,7 @@ import "../vendor/canvas-confetti"
|
||||||
|
|
||||||
import initLiveStash from "../../deps/live_stash/assets/js/live-stash.js"
|
import initLiveStash from "../../deps/live_stash/assets/js/live-stash.js"
|
||||||
import {PropagationMap} from "./propagation_map_hook"
|
import {PropagationMap} from "./propagation_map_hook"
|
||||||
|
import {PathForecast} from "./path_forecast_hook"
|
||||||
import {ContactMap} from "./contact_map_hook"
|
import {ContactMap} from "./contact_map_hook"
|
||||||
import {ContactsMap} from "./contacts_map_hook"
|
import {ContactsMap} from "./contacts_map_hook"
|
||||||
import {ElevationProfile} from "./elevation_profile_hook"
|
import {ElevationProfile} from "./elevation_profile_hook"
|
||||||
|
|
@ -310,7 +311,7 @@ const csrfToken = document.querySelector("meta[name='csrf-token']")!.getAttribut
|
||||||
const liveSocket = new LiveSocket("/live", Socket, {
|
const liveSocket = new LiveSocket("/live", Socket, {
|
||||||
longPollFallbackMs: 2500,
|
longPollFallbackMs: 2500,
|
||||||
params: initLiveStash({_csrf_token: csrfToken}),
|
params: initLiveStash({_csrf_token: csrfToken}),
|
||||||
hooks: {...colocatedHooks, PropagationMap, UtcClock, ContactMap, ContactsMap, ElevationProfile, WeatherMap, LocateMe, CopyLink, RoverMap, BeaconMap, BeaconsListMap, CommaNumber, ImportConfetti, EmeGlobe, Skewt},
|
hooks: {...colocatedHooks, PropagationMap, PathForecast, UtcClock, ContactMap, ContactsMap, ElevationProfile, WeatherMap, LocateMe, CopyLink, RoverMap, BeaconMap, BeaconsListMap, CommaNumber, ImportConfetti, EmeGlobe, Skewt},
|
||||||
})
|
})
|
||||||
|
|
||||||
// live_table rows are dead on their own — wire up whole-row clicks to
|
// live_table rows are dead on their own — wire up whole-row clicks to
|
||||||
|
|
|
||||||
207
assets/js/path_forecast_hook.ts
Normal file
207
assets/js/path_forecast_hook.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
import type { ViewHook } from "phoenix_live_view"
|
||||||
|
import {
|
||||||
|
FACTOR_META,
|
||||||
|
FACTOR_ORDER,
|
||||||
|
factorBar,
|
||||||
|
factorExplanation,
|
||||||
|
scoreTier
|
||||||
|
} from "./propagation_map_hook"
|
||||||
|
|
||||||
|
interface PathForecastDetail {
|
||||||
|
iso: string
|
||||||
|
score: number
|
||||||
|
factors: Record<string, number>
|
||||||
|
band_label: string
|
||||||
|
humidity_effect: string
|
||||||
|
unavailable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PathForecastHook extends ViewHook {
|
||||||
|
tooltip: HTMLDivElement | null
|
||||||
|
cache: Map<string, PathForecastDetail>
|
||||||
|
pendingIso: string | null
|
||||||
|
hideTimer: ReturnType<typeof setTimeout> | null
|
||||||
|
enterHandler: (e: MouseEvent) => void
|
||||||
|
leaveHandler: (e: MouseEvent) => void
|
||||||
|
moveHandler: (e: MouseEvent) => void
|
||||||
|
|
||||||
|
showFor(this: PathForecastHook, target: SVGCircleElement, e: MouseEvent): void
|
||||||
|
hideSoon(this: PathForecastHook): void
|
||||||
|
cancelHide(this: PathForecastHook): void
|
||||||
|
position(this: PathForecastHook, e: MouseEvent): void
|
||||||
|
render(this: PathForecastHook, iso: string): void
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHTML(detail: PathForecastDetail): string {
|
||||||
|
if (detail.unavailable) {
|
||||||
|
return `
|
||||||
|
<div style="padding:10px 14px;font-size:12px;opacity:0.85;">
|
||||||
|
Factor breakdown unavailable for this hour
|
||||||
|
<div style="opacity:0.55;font-size:10px;margin-top:2px;">
|
||||||
|
(no analysis profile in lookback window)
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
const tier = scoreTier(detail.score)
|
||||||
|
const time = new Date(detail.iso).toISOString().replace("T", " ").slice(0, 16) + " UTC"
|
||||||
|
|
||||||
|
let rows = ""
|
||||||
|
let explanations = ""
|
||||||
|
for (const key of FACTOR_ORDER) {
|
||||||
|
const meta = FACTOR_META[key]
|
||||||
|
const value = detail.factors[key]
|
||||||
|
if (value === undefined || value === null) continue
|
||||||
|
const pct = Math.round(meta.weight * 100)
|
||||||
|
rows += `<tr>
|
||||||
|
<td style="padding:1px 6px 1px 0;white-space:nowrap;font-size:11px;">${meta.label}</td>
|
||||||
|
<td style="padding:1px 4px;font-family:monospace;font-size:11px;letter-spacing:-0.5px;">${factorBar(value)}</td>
|
||||||
|
<td style="padding:1px 4px;text-align:right;font-size:11px;font-weight:600;">${value}</td>
|
||||||
|
<td style="padding:1px 4px;font-size:10px;opacity:0.5;">(${pct}%)</td>
|
||||||
|
</tr>`
|
||||||
|
const expl = factorExplanation(key, value, { humidity_effect: detail.humidity_effect })
|
||||||
|
if (!expl) continue
|
||||||
|
const eTier = scoreTier(value)
|
||||||
|
explanations += `<div style="padding:2px 0;font-size:12px;display:flex;gap:6px;">
|
||||||
|
<span style="color:${eTier.color};flex-shrink:0;">\u25CF</span>
|
||||||
|
<span><strong>${meta.label}:</strong> ${expl}</span>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div style="background:${tier.color};color:#000;padding:5px 10px;display:flex;justify-content:space-between;align-items:center;">
|
||||||
|
<strong style="font-size:14px;">${detail.score}/100</strong>
|
||||||
|
<span style="font-size:11px;font-weight:700;">${tier.label}</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:6px 10px;">
|
||||||
|
<div style="font-size:11px;opacity:0.7;margin-bottom:4px;">${detail.band_label} · ${time}</div>
|
||||||
|
<table style="width:100%;border-collapse:collapse;border-top:1px solid rgba(255,255,255,0.15);padding-top:4px;">${rows}</table>
|
||||||
|
</div>
|
||||||
|
<div style="padding:6px 10px;border-top:1px solid rgba(255,255,255,0.15);">
|
||||||
|
<div style="font-size:10px;font-weight:700;opacity:0.5;margin-bottom:3px;text-transform:uppercase;letter-spacing:0.5px;">Analysis</div>
|
||||||
|
${explanations}
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLoadingHTML(iso: string): string {
|
||||||
|
const time = new Date(iso).toISOString().replace("T", " ").slice(0, 16) + " UTC"
|
||||||
|
return `
|
||||||
|
<div style="padding:8px 12px;font-size:12px;display:flex;align-items:center;gap:8px;">
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
<span>Loading factors for ${time}\u2026</span>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PathForecast: Partial<PathForecastHook> = {
|
||||||
|
mounted(this: PathForecastHook) {
|
||||||
|
this.cache = new Map()
|
||||||
|
this.pendingIso = null
|
||||||
|
this.hideTimer = null
|
||||||
|
|
||||||
|
const tooltip = document.createElement("div")
|
||||||
|
tooltip.style.cssText = [
|
||||||
|
"position:absolute",
|
||||||
|
"z-index:50",
|
||||||
|
"min-width:280px",
|
||||||
|
"max-width:340px",
|
||||||
|
"background:#1f2937",
|
||||||
|
"color:#f1f5f9",
|
||||||
|
"border:1px solid rgba(255,255,255,0.12)",
|
||||||
|
"border-radius:6px",
|
||||||
|
"box-shadow:0 4px 18px rgba(0,0,0,0.45)",
|
||||||
|
"pointer-events:none",
|
||||||
|
"display:none",
|
||||||
|
"transition:opacity 80ms"
|
||||||
|
].join(";")
|
||||||
|
document.body.appendChild(tooltip)
|
||||||
|
this.tooltip = tooltip
|
||||||
|
|
||||||
|
this.enterHandler = (e: MouseEvent) => {
|
||||||
|
const target = (e.target as Element)?.closest("circle[data-iso]") as SVGCircleElement | null
|
||||||
|
if (target) this.showFor(target, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.leaveHandler = (e: MouseEvent) => {
|
||||||
|
const related = e.relatedTarget as Element | null
|
||||||
|
if (related && related.closest && related.closest("circle[data-iso]")) return
|
||||||
|
this.hideSoon()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.moveHandler = (e: MouseEvent) => {
|
||||||
|
if (this.tooltip && this.tooltip.style.display !== "none") this.position(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.el.addEventListener("mouseover", this.enterHandler)
|
||||||
|
this.el.addEventListener("mouseout", this.leaveHandler)
|
||||||
|
this.el.addEventListener("mousemove", this.moveHandler)
|
||||||
|
|
||||||
|
this.handleEvent("path_forecast_detail", (payload: PathForecastDetail) => {
|
||||||
|
this.cache.set(payload.iso, payload)
|
||||||
|
if (this.pendingIso === payload.iso) this.render(payload.iso)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
destroyed(this: PathForecastHook) {
|
||||||
|
if (this.tooltip) this.tooltip.remove()
|
||||||
|
if (this.hideTimer) clearTimeout(this.hideTimer)
|
||||||
|
this.el.removeEventListener("mouseover", this.enterHandler)
|
||||||
|
this.el.removeEventListener("mouseout", this.leaveHandler)
|
||||||
|
this.el.removeEventListener("mousemove", this.moveHandler)
|
||||||
|
},
|
||||||
|
|
||||||
|
showFor(this: PathForecastHook, target: SVGCircleElement, e: MouseEvent) {
|
||||||
|
const iso = target.dataset.iso
|
||||||
|
if (!iso || !this.tooltip) return
|
||||||
|
|
||||||
|
this.cancelHide()
|
||||||
|
this.pendingIso = iso
|
||||||
|
this.tooltip.style.display = "block"
|
||||||
|
this.position(e)
|
||||||
|
|
||||||
|
const cached = this.cache.get(iso)
|
||||||
|
if (cached) {
|
||||||
|
this.tooltip.innerHTML = buildHTML(cached)
|
||||||
|
} else {
|
||||||
|
this.tooltip.innerHTML = buildLoadingHTML(iso)
|
||||||
|
this.pushEvent("path_forecast_detail", { iso })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
render(this: PathForecastHook, iso: string) {
|
||||||
|
const cached = this.cache.get(iso)
|
||||||
|
if (cached && this.tooltip) this.tooltip.innerHTML = buildHTML(cached)
|
||||||
|
},
|
||||||
|
|
||||||
|
hideSoon(this: PathForecastHook) {
|
||||||
|
this.cancelHide()
|
||||||
|
this.hideTimer = setTimeout(() => {
|
||||||
|
if (this.tooltip) this.tooltip.style.display = "none"
|
||||||
|
this.pendingIso = null
|
||||||
|
}, 120)
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelHide(this: PathForecastHook) {
|
||||||
|
if (this.hideTimer) {
|
||||||
|
clearTimeout(this.hideTimer)
|
||||||
|
this.hideTimer = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
position(this: PathForecastHook, e: MouseEvent) {
|
||||||
|
if (!this.tooltip) return
|
||||||
|
const pad = 14
|
||||||
|
const rect = this.tooltip.getBoundingClientRect()
|
||||||
|
const vw = window.innerWidth
|
||||||
|
const vh = window.innerHeight
|
||||||
|
|
||||||
|
let x = e.clientX + pad
|
||||||
|
let y = e.clientY + pad
|
||||||
|
if (x + rect.width > vw - 8) x = e.clientX - rect.width - pad
|
||||||
|
if (y + rect.height > vh - 8) y = e.clientY - rect.height - pad
|
||||||
|
if (x < 8) x = 8
|
||||||
|
if (y < 8) y = 8
|
||||||
|
|
||||||
|
this.tooltip.style.left = `${x + window.scrollX}px`
|
||||||
|
this.tooltip.style.top = `${y + window.scrollY}px`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,13 +22,13 @@ function kmCeilToMi(highKm: number): string {
|
||||||
|
|
||||||
// --- Interfaces ---
|
// --- Interfaces ---
|
||||||
|
|
||||||
interface FactorMeta {
|
export interface FactorMeta {
|
||||||
label: string
|
label: string
|
||||||
weight: number
|
weight: number
|
||||||
unit: string
|
unit: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ScoreTier {
|
export interface ScoreTier {
|
||||||
label: string
|
label: string
|
||||||
color: string
|
color: string
|
||||||
}
|
}
|
||||||
|
|
@ -245,7 +245,7 @@ class ScoreGrid {
|
||||||
|
|
||||||
// Weights must match BandConfig.weights() in band_config.ex
|
// Weights must match BandConfig.weights() in band_config.ex
|
||||||
// Recalibrated 2026-04-11 via gradient descent (loss 0.42 → 0.12)
|
// Recalibrated 2026-04-11 via gradient descent (loss 0.42 → 0.12)
|
||||||
const FACTOR_META: Record<string, FactorMeta> = {
|
export const FACTOR_META: Record<string, FactorMeta> = {
|
||||||
rain: { label: "Rain", weight: 0.1362, unit: "" },
|
rain: { label: "Rain", weight: 0.1362, unit: "" },
|
||||||
humidity: { label: "Humidity", weight: 0.1243, unit: "g/m\u00b3" },
|
humidity: { label: "Humidity", weight: 0.1243, unit: "g/m\u00b3" },
|
||||||
pwat: { label: "PWAT", weight: 0.1128, unit: "mm" },
|
pwat: { label: "PWAT", weight: 0.1128, unit: "mm" },
|
||||||
|
|
@ -258,14 +258,14 @@ const FACTOR_META: Record<string, FactorMeta> = {
|
||||||
time_of_day: { label: "Time of Day", weight: 0.0496, unit: "" }
|
time_of_day: { label: "Time of Day", weight: 0.0496, unit: "" }
|
||||||
}
|
}
|
||||||
|
|
||||||
const FACTOR_ORDER: string[] = [
|
export const FACTOR_ORDER: string[] = [
|
||||||
"rain", "humidity", "pwat", "season", "refractivity",
|
"rain", "humidity", "pwat", "season", "refractivity",
|
||||||
"pressure", "td_depression", "sky", "wind", "time_of_day"
|
"pressure", "td_depression", "sky", "wind", "time_of_day"
|
||||||
]
|
]
|
||||||
|
|
||||||
// --- Helper functions ---
|
// --- Helper functions ---
|
||||||
|
|
||||||
function scoreTier(score: number): ScoreTier {
|
export function scoreTier(score: number): ScoreTier {
|
||||||
if (score >= 80) return { label: "EXCELLENT", color: "#00ffa3" }
|
if (score >= 80) return { label: "EXCELLENT", color: "#00ffa3" }
|
||||||
if (score >= 65) return { label: "GOOD", color: "#7dffd4" }
|
if (score >= 65) return { label: "GOOD", color: "#7dffd4" }
|
||||||
if (score >= 50) return { label: "MARGINAL", color: "#ffe566" }
|
if (score >= 50) return { label: "MARGINAL", color: "#ffe566" }
|
||||||
|
|
@ -273,15 +273,19 @@ function scoreTier(score: number): ScoreTier {
|
||||||
return { label: "NEGLIGIBLE", color: "#ff4f4f" }
|
return { label: "NEGLIGIBLE", color: "#ff4f4f" }
|
||||||
}
|
}
|
||||||
|
|
||||||
function factorBar(score: number): string {
|
export function factorBar(score: number): string {
|
||||||
const filled = Math.round(score / 5)
|
const filled = Math.round(score / 5)
|
||||||
const empty = 20 - filled
|
const empty = 20 - filled
|
||||||
const tier = scoreTier(score)
|
const tier = scoreTier(score)
|
||||||
return `<span style="color:${tier.color}">${"\u2588".repeat(filled)}</span><span style="color:rgba(255,255,255,0.2)">${"\u2591".repeat(empty)}</span>`
|
return `<span style="color:${tier.color}">${"\u2588".repeat(filled)}</span><span style="color:rgba(255,255,255,0.2)">${"\u2591".repeat(empty)}</span>`
|
||||||
}
|
}
|
||||||
|
|
||||||
function factorExplanation(key: string, score: number, detail: PointDetail): string {
|
export function factorExplanation(
|
||||||
const beneficial = detail.humidity_effect === "beneficial"
|
key: string,
|
||||||
|
score: number,
|
||||||
|
ctx: { humidity_effect?: string }
|
||||||
|
): string {
|
||||||
|
const beneficial = ctx.humidity_effect === "beneficial"
|
||||||
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "humidity":
|
case "humidity":
|
||||||
|
|
|
||||||
|
|
@ -474,13 +474,25 @@ defmodule Microwaveprop.Propagation do
|
||||||
{Float.round(Float.round(lat / step) * step, 3), Float.round(Float.round(lon / step) * step, 3)}
|
{Float.round(Float.round(lat / step) * step, 3), Float.round(Float.round(lon / step) * step, 3)}
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc "Get the full score and factors for a specific grid point, snapped to nearest grid."
|
@doc """
|
||||||
|
Get the full score and factors for a specific grid point, snapped to
|
||||||
|
the nearest grid cell.
|
||||||
|
|
||||||
|
`:profile_source` describes where the factor breakdown came from:
|
||||||
|
|
||||||
|
* `:exact` — rescored from this `valid_time`'s own profile file.
|
||||||
|
* `{:fallback, fallback_valid_time}` — the requested hour's profile
|
||||||
|
was missing, so we rescored from the most recent analysis profile
|
||||||
|
within the lookback window. Treat as approximate.
|
||||||
|
* `:unavailable` — no profile available; `factors` is `%{}`.
|
||||||
|
"""
|
||||||
@spec point_detail(non_neg_integer(), float(), float(), DateTime.t() | nil) ::
|
@spec point_detail(non_neg_integer(), float(), float(), DateTime.t() | nil) ::
|
||||||
%{
|
%{
|
||||||
lat: float(),
|
lat: float(),
|
||||||
lon: float(),
|
lon: float(),
|
||||||
score: non_neg_integer(),
|
score: non_neg_integer(),
|
||||||
factors: map(),
|
factors: map(),
|
||||||
|
profile_source: :exact | {:fallback, DateTime.t()} | :unavailable,
|
||||||
valid_time: DateTime.t()
|
valid_time: DateTime.t()
|
||||||
}
|
}
|
||||||
| nil
|
| nil
|
||||||
|
|
@ -504,11 +516,14 @@ defmodule Microwaveprop.Propagation do
|
||||||
nil
|
nil
|
||||||
|
|
||||||
score ->
|
score ->
|
||||||
|
{factors, source} = factors_for(band_mhz, time, snapped_lat, snapped_lon)
|
||||||
|
|
||||||
%{
|
%{
|
||||||
lat: snapped_lat,
|
lat: snapped_lat,
|
||||||
lon: snapped_lon,
|
lon: snapped_lon,
|
||||||
score: score,
|
score: score,
|
||||||
factors: factors_for(band_mhz, time, snapped_lat, snapped_lon),
|
factors: factors,
|
||||||
|
profile_source: source,
|
||||||
valid_time: time
|
valid_time: time
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
@ -516,30 +531,40 @@ defmodule Microwaveprop.Propagation do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Rebuild the factor breakdown for a clicked grid cell by rescoring
|
# Rebuild the factor breakdown for a clicked grid cell by rescoring
|
||||||
# the persisted HRRR profile. Only analysis hours (f00) persist
|
# the persisted HRRR profile.
|
||||||
# profiles, so forecast hours fall back to the most recent analysis
|
#
|
||||||
# profile within `@fallback_profile_lookback_hours` — the atmospheric
|
# The Rust pipeline (`prop_grid_rs`) writes a per-cell profile file
|
||||||
# breakdown still explains what's driving the score even if sampled
|
# for every chain step (f00..f18 since Phase 2 cutover), so the
|
||||||
# an hour or two earlier. Outside the lookback we prefer to return
|
# `:exact` branch covers a healthy production state. The fallback to
|
||||||
# an empty map so the UI shows "breakdown unavailable" rather than
|
# a recent analysis profile remains as a safety net for missed
|
||||||
# silently attributing scores to a day-old synoptic pattern.
|
# cycles or partial chain runs — when it kicks in we tag the result
|
||||||
|
# `{:fallback, profile_valid_time}` so the UI can label the
|
||||||
|
# breakdown as approximated rather than silently misrepresent it.
|
||||||
@fallback_profile_lookback_hours 24
|
@fallback_profile_lookback_hours 24
|
||||||
|
@spec factors_for(non_neg_integer(), DateTime.t(), float(), float()) ::
|
||||||
|
{map(), :exact | {:fallback, DateTime.t()} | :unavailable}
|
||||||
defp factors_for(band_mhz, valid_time, lat, lon) do
|
defp factors_for(band_mhz, valid_time, lat, lon) do
|
||||||
case ProfilesFile.read_point(valid_time, lat, lon) do
|
case ProfilesFile.read_point(valid_time, lat, lon) do
|
||||||
nil -> factors_from_fallback_profile(band_mhz, valid_time, lat, lon)
|
nil ->
|
||||||
profile -> factors_from_profile(band_mhz, valid_time, profile, lat, lon)
|
factors_from_fallback_profile(band_mhz, valid_time, lat, lon)
|
||||||
|
|
||||||
|
profile ->
|
||||||
|
{factors_from_profile(band_mhz, valid_time, profile, lat, lon), :exact}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp factors_from_fallback_profile(band_mhz, valid_time, lat, lon) do
|
defp factors_from_fallback_profile(band_mhz, valid_time, lat, lon) do
|
||||||
case latest_profile_time_within_lookback(valid_time) do
|
case latest_profile_time_within_lookback(valid_time) do
|
||||||
nil ->
|
nil ->
|
||||||
%{}
|
{%{}, :unavailable}
|
||||||
|
|
||||||
fallback_time ->
|
fallback_time ->
|
||||||
case ProfilesFile.read_point(fallback_time, lat, lon) do
|
case ProfilesFile.read_point(fallback_time, lat, lon) do
|
||||||
nil -> %{}
|
nil ->
|
||||||
profile -> factors_from_profile(band_mhz, fallback_time, profile, lat, lon)
|
{%{}, :unavailable}
|
||||||
|
|
||||||
|
profile ->
|
||||||
|
{factors_from_profile(band_mhz, fallback_time, profile, lat, lon), {:fallback, fallback_time}}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,39 @@ defmodule MicrowavepropWeb.PathLive do
|
||||||
{:noreply, push_patch(socket, to: ~p"/path?#{url_params}", replace: true)}
|
{:noreply, push_patch(socket, to: ~p"/path?#{url_params}", replace: true)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Hover on a forecast-chart dot — fetch and push back the per-factor
|
||||||
|
# breakdown for that hour at the path's midpoint, mirroring the
|
||||||
|
# weighted-criteria panel the main /map popup builds when the user
|
||||||
|
# clicks a grid cell. Cached client-side so the server only does the
|
||||||
|
# ProfilesFile read once per hour per session.
|
||||||
|
def handle_event("path_forecast_detail", %{"iso" => iso}, socket) do
|
||||||
|
with %{result: %{band_mhz: band_mhz, source: src, destination: dst, band_config: band_config}} <-
|
||||||
|
socket.assigns,
|
||||||
|
{:ok, valid_time, _} <- DateTime.from_iso8601(iso) do
|
||||||
|
midlat = (src.lat + dst.lat) / 2
|
||||||
|
midlon = (src.lon + dst.lon) / 2
|
||||||
|
|
||||||
|
payload =
|
||||||
|
case Propagation.point_detail(band_mhz, midlat, midlon, valid_time) do
|
||||||
|
%{score: score, factors: factors} when factors != %{} ->
|
||||||
|
%{
|
||||||
|
iso: iso,
|
||||||
|
score: score,
|
||||||
|
factors: factors,
|
||||||
|
band_label: band_config.label,
|
||||||
|
humidity_effect: humidity_effect_string(band_config)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
%{iso: iso, unavailable: true}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, push_event(socket, "path_forecast_detail", payload)}
|
||||||
|
else
|
||||||
|
_ -> {:noreply, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def handle_event("gps_location", %{"lat" => lat, "lon" => lon}, socket) do
|
def handle_event("gps_location", %{"lat" => lat, "lon" => lon}, socket) do
|
||||||
coords = "#{Float.round(lat / 1, 6)},#{Float.round(lon / 1, 6)}"
|
coords = "#{Float.round(lat / 1, 6)},#{Float.round(lon / 1, 6)}"
|
||||||
|
|
||||||
|
|
@ -405,6 +438,10 @@ defmodule MicrowavepropWeb.PathLive do
|
||||||
Regex.match?(~r/^[A-Ra-r]{2}[0-9]{2}/i, input)
|
Regex.match?(~r/^[A-Ra-r]{2}[0-9]{2}/i, input)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp humidity_effect_string(%{humidity_effect: :beneficial}), do: "beneficial"
|
||||||
|
defp humidity_effect_string(%{humidity_effect: :harmful}), do: "harmful"
|
||||||
|
defp humidity_effect_string(_), do: "neutral"
|
||||||
|
|
||||||
defp label_hrrr_point({label, lat, lon}, now, profile_valid_time) do
|
defp label_hrrr_point({label, lat, lon}, now, profile_valid_time) do
|
||||||
case profile_valid_time && ProfilesFile.read_point(profile_valid_time, lat, lon) do
|
case profile_valid_time && ProfilesFile.read_point(profile_valid_time, lat, lon) do
|
||||||
%{} = cell ->
|
%{} = cell ->
|
||||||
|
|
@ -1263,7 +1300,11 @@ defmodule MicrowavepropWeb.PathLive do
|
||||||
|> assign(:x_labels, forecast_x_labels(points))
|
|> assign(:x_labels, forecast_x_labels(points))
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<div class="bg-base-200 rounded-box p-4 mb-6">
|
<div
|
||||||
|
id="path-forecast-chart"
|
||||||
|
phx-hook="PathForecast"
|
||||||
|
class="bg-base-200 rounded-box p-4 mb-6"
|
||||||
|
>
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<div class="text-xs opacity-60">
|
<div class="text-xs opacity-60">
|
||||||
{length(@forecast)}-Hour Propagation Forecast · {@band_config.label}
|
{length(@forecast)}-Hour Propagation Forecast · {@band_config.label}
|
||||||
|
|
@ -1304,7 +1345,14 @@ defmodule MicrowavepropWeb.PathLive do
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
/>
|
/>
|
||||||
<%= for pt <- @points do %>
|
<%= for pt <- @points do %>
|
||||||
<circle cx={pt.x} cy={pt.y} r="4" fill={tier_color(pt.score)} />
|
<circle
|
||||||
|
cx={pt.x}
|
||||||
|
cy={pt.y}
|
||||||
|
r="6"
|
||||||
|
fill={tier_color(pt.score)}
|
||||||
|
data-iso={DateTime.to_iso8601(pt.valid_time)}
|
||||||
|
style="cursor:pointer;"
|
||||||
|
/>
|
||||||
<% end %>
|
<% end %>
|
||||||
<circle
|
<circle
|
||||||
cx={@now_pt.x}
|
cx={@now_pt.x}
|
||||||
|
|
@ -1313,12 +1361,16 @@ defmodule MicrowavepropWeb.PathLive do
|
||||||
fill="white"
|
fill="white"
|
||||||
stroke={tier_color(@now_pt.score)}
|
stroke={tier_color(@now_pt.score)}
|
||||||
stroke-width="3"
|
stroke-width="3"
|
||||||
|
data-iso={DateTime.to_iso8601(@now_pt.valid_time)}
|
||||||
|
style="cursor:pointer;"
|
||||||
/>
|
/>
|
||||||
<circle
|
<circle
|
||||||
cx={@best_pt.x}
|
cx={@best_pt.x}
|
||||||
cy={@best_pt.y}
|
cy={@best_pt.y}
|
||||||
r="8"
|
r="8"
|
||||||
fill={tier_color(@best_pt.score)}
|
fill={tier_color(@best_pt.score)}
|
||||||
|
data-iso={DateTime.to_iso8601(@best_pt.valid_time)}
|
||||||
|
style="cursor:pointer;"
|
||||||
stroke="white"
|
stroke="white"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue