fix(map-timeline): never label a future forecast hour as 'Now'
Both the Elixir initial-cursor pick and the JS timeline renderer used nearest-by-absolute-distance, so any wall-clock time past :30 snapped the 'Now' label onto the next top-of-hour forecast — up to ~30 min in the future. Switch to the latest valid_time at or before wall-clock, with a fallback to the earliest time when every slot is future (shouldn't happen for HRRR, safe default).
This commit is contained in:
parent
6c652ef2d4
commit
28a9eb3a92
3 changed files with 63 additions and 7 deletions
|
|
@ -1330,9 +1330,16 @@ export const PropagationMap: Record<string, unknown> & {
|
||||||
return { ...t, dt, isSelected, isPast, offsetH, idx, label: "" }
|
return { ...t, dt, isSelected, isPast, offsetH, idx, label: "" }
|
||||||
})
|
})
|
||||||
|
|
||||||
// "Now" = the item closest to current time
|
// "Now" = the latest forecast hour at or before wall-clock. Picking
|
||||||
const closestIdx = items.reduce((best, item, i) =>
|
// by absolute distance rounds up past the half-hour and labels a
|
||||||
Math.abs(item.dt.getTime() - now.getTime()) < Math.abs(items[best].dt.getTime() - now.getTime()) ? i : best, 0)
|
// future slot "Now" (e.g. at 18:35 UTC, 19:00 is 25 min away vs.
|
||||||
|
// 18:00 at 35 min). Fall back to the earliest slot if they're all
|
||||||
|
// in the future.
|
||||||
|
const nowMs = now.getTime()
|
||||||
|
const pastOrNow = items.filter(t => t.dt.getTime() <= nowMs)
|
||||||
|
const closestIdx = pastOrNow.length > 0
|
||||||
|
? items.indexOf(pastOrNow.reduce((latest, t) => t.dt.getTime() > latest.dt.getTime() ? t : latest))
|
||||||
|
: items.reduce((earliest, t, i) => items[earliest].dt.getTime() < t.dt.getTime() ? earliest : i, 0)
|
||||||
|
|
||||||
items.forEach((t, i) => {
|
items.forEach((t, i) => {
|
||||||
if (i === closestIdx) {
|
if (i === closestIdx) {
|
||||||
|
|
|
||||||
|
|
@ -424,11 +424,25 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
"#{hours}h ago"
|
"#{hours}h ago"
|
||||||
end
|
end
|
||||||
|
|
||||||
defp closest_to_now([]), do: nil
|
defp closest_to_now(times), do: cursor_for_now(times, DateTime.utc_now())
|
||||||
|
|
||||||
defp closest_to_now(times) do
|
@doc false
|
||||||
now = DateTime.utc_now()
|
# Picks the forecast valid_time the map should display as "Now".
|
||||||
Enum.min_by(times, fn t -> abs(DateTime.diff(t, now)) end)
|
# The rule is "latest valid_time at or before `now`" — a nearest-by-
|
||||||
|
# absolute-distance pick can land on a future hour (e.g. at 18:35 UTC,
|
||||||
|
# 19:00 is 25 min away vs. 18:00 at 35 min, so nearest would label
|
||||||
|
# 19:00 "Now"). Falls back to the earliest time only if every slot is
|
||||||
|
# in the future, which HRRR analysis + forecast lists shouldn't produce.
|
||||||
|
@spec cursor_for_now([DateTime.t()], DateTime.t()) :: DateTime.t() | nil
|
||||||
|
def cursor_for_now([], _now), do: nil
|
||||||
|
|
||||||
|
def cursor_for_now(times, %DateTime{} = now) do
|
||||||
|
past_or_now = Enum.filter(times, &(DateTime.compare(&1, now) != :gt))
|
||||||
|
|
||||||
|
case past_or_now do
|
||||||
|
[] -> Enum.min_by(times, &DateTime.to_unix/1)
|
||||||
|
past -> Enum.max_by(past, &DateTime.to_unix/1)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp score_range_km(score, config) do
|
defp score_range_km(score, config) do
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,41 @@ defmodule MicrowavepropWeb.MapLiveTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "cursor_for_now/2" do
|
||||||
|
alias MicrowavepropWeb.MapLive
|
||||||
|
|
||||||
|
test "returns nil for an empty list" do
|
||||||
|
assert MapLive.cursor_for_now([], DateTime.utc_now()) == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "picks the latest time at or before now — never future" do
|
||||||
|
# Valid times at 18:00, 19:00, 20:00 UTC. Wall clock 19:35.
|
||||||
|
# Nearest-by-abs would round up to 20:00 (25 min future);
|
||||||
|
# floor picks 19:00 (35 min past).
|
||||||
|
times = [~U[2026-04-18 18:00:00Z], ~U[2026-04-18 19:00:00Z], ~U[2026-04-18 20:00:00Z]]
|
||||||
|
now = ~U[2026-04-18 19:35:00Z]
|
||||||
|
|
||||||
|
assert MapLive.cursor_for_now(times, now) == ~U[2026-04-18 19:00:00Z]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "matches exact hour when now is on the hour" do
|
||||||
|
times = [~U[2026-04-18 18:00:00Z], ~U[2026-04-18 19:00:00Z]]
|
||||||
|
assert MapLive.cursor_for_now(times, ~U[2026-04-18 19:00:00Z]) == ~U[2026-04-18 19:00:00Z]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "falls back to the earliest time when every slot is in the future" do
|
||||||
|
times = [~U[2026-04-18 20:00:00Z], ~U[2026-04-18 21:00:00Z]]
|
||||||
|
now = ~U[2026-04-18 19:00:00Z]
|
||||||
|
assert MapLive.cursor_for_now(times, now) == ~U[2026-04-18 20:00:00Z]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "ignores list order" do
|
||||||
|
times = [~U[2026-04-18 20:00:00Z], ~U[2026-04-18 18:00:00Z], ~U[2026-04-18 19:00:00Z]]
|
||||||
|
now = ~U[2026-04-18 19:35:00Z]
|
||||||
|
assert MapLive.cursor_for_now(times, now) == ~U[2026-04-18 19:00:00Z]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "format_data_timestamp/1" do
|
describe "format_data_timestamp/1" do
|
||||||
alias MicrowavepropWeb.MapLive
|
alias MicrowavepropWeb.MapLive
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue