feat(contact-map): date-range filter alongside callsign filter
Adds start/end date inputs (UTC) to the sidebar (mobile + desktop) with Apply dates and Clear buttons. The hook keeps a sliced YYYY-MM-DD on each line entry so the range check is a string compare during filter re-application; combines with the callsign filter so e.g. AA5C between 2025-01-01 and 2026-01-01 is one query.
This commit is contained in:
parent
01341d20f3
commit
a3fff198c9
3 changed files with 169 additions and 5 deletions
|
|
@ -11,11 +11,17 @@ interface ApplyFilterPayload {
|
||||||
callsign: string | null
|
callsign: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ApplyDateFilterPayload {
|
||||||
|
start: string | null
|
||||||
|
end: string | null
|
||||||
|
}
|
||||||
|
|
||||||
interface LineEntry {
|
interface LineEntry {
|
||||||
line: L.Polyline
|
line: L.Polyline
|
||||||
band: number
|
band: number
|
||||||
s1: string
|
s1: string
|
||||||
s2: string
|
s2: string
|
||||||
|
tsDate: string
|
||||||
lat1: number
|
lat1: number
|
||||||
lon1: number
|
lon1: number
|
||||||
lat2: number
|
lat2: number
|
||||||
|
|
@ -25,6 +31,8 @@ interface LineEntry {
|
||||||
interface ContactsMapHook extends ViewHook {
|
interface ContactsMapHook extends ViewHook {
|
||||||
allContacts: ContactTuple[]
|
allContacts: ContactTuple[]
|
||||||
callsignFilter: string
|
callsignFilter: string
|
||||||
|
startDate: string
|
||||||
|
endDate: string
|
||||||
enabledBands: Set<number>
|
enabledBands: Set<number>
|
||||||
bandGroups: Map<number, L.LayerGroup>
|
bandGroups: Map<number, L.LayerGroup>
|
||||||
lines: LineEntry[]
|
lines: LineEntry[]
|
||||||
|
|
@ -44,6 +52,14 @@ interface ContactsMapHook extends ViewHook {
|
||||||
restyleLines(this: ContactsMapHook): void
|
restyleLines(this: ContactsMapHook): void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function inDateRange(tsDate: string, startDate: string, endDate: string): boolean {
|
||||||
|
if (!startDate && !endDate) return true
|
||||||
|
if (!tsDate) return false
|
||||||
|
if (startDate && tsDate < startDate) return false
|
||||||
|
if (endDate && tsDate > endDate) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// Amateur-band palette. Nine of these map to the design system's --band-*
|
// Amateur-band palette. Nine of these map to the design system's --band-*
|
||||||
// CSS vars (defined in assets/css/app.css); the higher mm-wave bands above
|
// CSS vars (defined in assets/css/app.css); the higher mm-wave bands above
|
||||||
// 47 GHz fall back to hard-coded hues since the design set only spans up
|
// 47 GHz fall back to hard-coded hues since the design set only spans up
|
||||||
|
|
@ -103,6 +119,8 @@ export const ContactsMap = {
|
||||||
const fetchUrl = this.el.dataset.fetchUrl || "/api/contacts/map"
|
const fetchUrl = this.el.dataset.fetchUrl || "/api/contacts/map"
|
||||||
this.allContacts = []
|
this.allContacts = []
|
||||||
this.callsignFilter = ""
|
this.callsignFilter = ""
|
||||||
|
this.startDate = ""
|
||||||
|
this.endDate = ""
|
||||||
this.enabledBands = new Set()
|
this.enabledBands = new Set()
|
||||||
this.bandGroups = new Map()
|
this.bandGroups = new Map()
|
||||||
this.lines = []
|
this.lines = []
|
||||||
|
|
@ -117,6 +135,13 @@ export const ContactsMap = {
|
||||||
this.rebuildDots()
|
this.rebuildDots()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
this.handleEvent("apply_date_filter", ({start, end}: ApplyDateFilterPayload) => {
|
||||||
|
this.startDate = start || ""
|
||||||
|
this.endDate = end || ""
|
||||||
|
this.applyCallsignFilter()
|
||||||
|
this.rebuildDots()
|
||||||
|
})
|
||||||
|
|
||||||
this.wireControls()
|
this.wireControls()
|
||||||
|
|
||||||
// Re-color polylines when the theme toggles. Leaflet writes `stroke` as
|
// Re-color polylines when the theme toggles. Leaflet writes `stroke` as
|
||||||
|
|
@ -272,6 +297,10 @@ export const ContactsMap = {
|
||||||
band,
|
band,
|
||||||
s1: (s1 || "").toUpperCase(),
|
s1: (s1 || "").toUpperCase(),
|
||||||
s2: (s2 || "").toUpperCase(),
|
s2: (s2 || "").toUpperCase(),
|
||||||
|
// Date inputs come in as YYYY-MM-DD; ts is "YYYY-MM-DD HH:MM" so
|
||||||
|
// pre-slicing the date portion lets the range check be a string
|
||||||
|
// compare without having to substring per-line every render.
|
||||||
|
tsDate: (ts || "").substring(0, 10),
|
||||||
lat1, lon1, lat2, lon2
|
lat1, lon1, lat2, lon2
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -288,14 +317,18 @@ export const ContactsMap = {
|
||||||
},
|
},
|
||||||
|
|
||||||
applyCallsignFilter(this: ContactsMapHook) {
|
applyCallsignFilter(this: ContactsMapHook) {
|
||||||
// Callsign filter: iterate individual polylines and add/remove them
|
// Callsign + date filter: iterate individual polylines and add/remove
|
||||||
// from their band's group. Cheap compared to rebuild (no polyline
|
// them from their band's group. Cheap compared to rebuild (no polyline
|
||||||
// creation). Only runs when the filter string changes.
|
// creation). Runs when either filter changes.
|
||||||
const match = this.callsignFilter
|
const match = this.callsignFilter
|
||||||
|
const startDate = this.startDate
|
||||||
|
const endDate = this.endDate
|
||||||
for (const entry of this.lines) {
|
for (const entry of this.lines) {
|
||||||
const group = this.bandGroups.get(entry.band)
|
const group = this.bandGroups.get(entry.band)
|
||||||
if (!group) continue
|
if (!group) continue
|
||||||
const visible = !match || entry.s1.includes(match) || entry.s2.includes(match)
|
const callsignOk = !match || entry.s1.includes(match) || entry.s2.includes(match)
|
||||||
|
const dateOk = inDateRange(entry.tsDate, startDate, endDate)
|
||||||
|
const visible = callsignOk && dateOk
|
||||||
const present = group.hasLayer(entry.line)
|
const present = group.hasLayer(entry.line)
|
||||||
if (visible && !present) group.addLayer(entry.line)
|
if (visible && !present) group.addLayer(entry.line)
|
||||||
else if (!visible && present) group.removeLayer(entry.line)
|
else if (!visible && present) group.removeLayer(entry.line)
|
||||||
|
|
@ -305,12 +338,15 @@ export const ContactsMap = {
|
||||||
rebuildDots(this: ContactsMapHook) {
|
rebuildDots(this: ContactsMapHook) {
|
||||||
this.dotLayer.clearLayers()
|
this.dotLayer.clearLayers()
|
||||||
const match = this.callsignFilter
|
const match = this.callsignFilter
|
||||||
|
const startDate = this.startDate
|
||||||
|
const endDate = this.endDate
|
||||||
const endpointCounts: Record<string, number> = {}
|
const endpointCounts: Record<string, number> = {}
|
||||||
let lineCount = 0
|
let lineCount = 0
|
||||||
|
|
||||||
for (const entry of this.lines) {
|
for (const entry of this.lines) {
|
||||||
if (!this.enabledBands.has(entry.band)) continue
|
if (!this.enabledBands.has(entry.band)) continue
|
||||||
if (match && !entry.s1.includes(match) && !entry.s2.includes(match)) continue
|
if (match && !entry.s1.includes(match) && !entry.s2.includes(match)) continue
|
||||||
|
if (!inDateRange(entry.tsDate, startDate, endDate)) continue
|
||||||
lineCount++
|
lineCount++
|
||||||
const k1 = `${entry.lat1.toFixed(2)},${entry.lon1.toFixed(2)}`
|
const k1 = `${entry.lat1.toFixed(2)},${entry.lon1.toFixed(2)}`
|
||||||
const k2 = `${entry.lat2.toFixed(2)},${entry.lon2.toFixed(2)}`
|
const k2 = `${entry.lat2.toFixed(2)},${entry.lon2.toFixed(2)}`
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,9 @@ defmodule MicrowavepropWeb.ContactMapLive do
|
||||||
page_title: "Contact Map",
|
page_title: "Contact Map",
|
||||||
contact_count: count,
|
contact_count: count,
|
||||||
all_bands: bands,
|
all_bands: bands,
|
||||||
callsign_filter: ""
|
callsign_filter: "",
|
||||||
|
start_date: "",
|
||||||
|
end_date: ""
|
||||||
)}
|
)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -54,6 +56,27 @@ defmodule MicrowavepropWeb.ContactMapLive do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("filter_dates", %{"start_date" => start_date, "end_date" => end_date}, socket) do
|
||||||
|
start_trimmed = String.trim(start_date)
|
||||||
|
end_trimmed = String.trim(end_date)
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(start_date: start_trimmed, end_date: end_trimmed)
|
||||||
|
|> push_event("apply_date_filter", %{start: start_trimmed, end: end_trimmed})
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("clear_dates", _params, socket) do
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(start_date: "", end_date: "")
|
||||||
|
|> push_event("apply_date_filter", %{start: "", end: ""})
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
defp band_label(mhz) when mhz >= 1000 do
|
defp band_label(mhz) when mhz >= 1000 do
|
||||||
ghz = mhz / 1000
|
ghz = mhz / 1000
|
||||||
label = if ghz == trunc(ghz), do: "#{trunc(ghz)}", else: "#{Float.round(ghz / 1, 1)}"
|
label = if ghz == trunc(ghz), do: "#{trunc(ghz)}", else: "#{Float.round(ghz / 1, 1)}"
|
||||||
|
|
@ -120,6 +143,38 @@ defmodule MicrowavepropWeb.ContactMapLive do
|
||||||
<button type="submit" class="btn btn-xs btn-primary">Filter</button>
|
<button type="submit" class="btn btn-xs btn-primary">Filter</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<form phx-submit="filter_dates" class="flex flex-col gap-1">
|
||||||
|
<span class="text-[10px] opacity-70">Date range (UTC)</span>
|
||||||
|
<div class="flex gap-1 items-center">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="start_date"
|
||||||
|
value={@start_date}
|
||||||
|
class="input input-xs flex-1 min-w-0"
|
||||||
|
/>
|
||||||
|
<span class="text-xs">→</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="end_date"
|
||||||
|
value={@end_date}
|
||||||
|
class="input input-xs flex-1 min-w-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button type="submit" class="btn btn-xs btn-primary flex-1">
|
||||||
|
Apply dates
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:if={@start_date != "" or @end_date != ""}
|
||||||
|
type="button"
|
||||||
|
phx-click="clear_dates"
|
||||||
|
class="btn btn-xs btn-ghost"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-xs font-bold">Bands</span>
|
<span class="text-xs font-bold">Bands</span>
|
||||||
<span class="flex gap-2">
|
<span class="flex gap-2">
|
||||||
|
|
@ -215,6 +270,41 @@ defmodule MicrowavepropWeb.ContactMapLive do
|
||||||
<button type="submit" class="btn btn-sm btn-primary">Filter</button>
|
<button type="submit" class="btn btn-sm btn-primary">Filter</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<%!-- Date range filter --%>
|
||||||
|
<form phx-submit="filter_dates" class="flex flex-col gap-1">
|
||||||
|
<span class="text-[10px] uppercase font-semibold opacity-70 px-1">
|
||||||
|
Date range (UTC)
|
||||||
|
</span>
|
||||||
|
<div class="flex gap-1 items-center">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="start_date"
|
||||||
|
value={@start_date}
|
||||||
|
class="input input-sm flex-1 min-w-0"
|
||||||
|
/>
|
||||||
|
<span class="text-xs">→</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="end_date"
|
||||||
|
value={@end_date}
|
||||||
|
class="input input-sm flex-1 min-w-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary flex-1">
|
||||||
|
Apply dates
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:if={@start_date != "" or @end_date != ""}
|
||||||
|
type="button"
|
||||||
|
phx-click="clear_dates"
|
||||||
|
class="btn btn-sm btn-ghost"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
<%!-- Band checkboxes --%>
|
<%!-- Band checkboxes --%>
|
||||||
<div class="flex items-center justify-between px-1">
|
<div class="flex items-center justify-between px-1">
|
||||||
<span class="text-xs font-bold">Bands</span>
|
<span class="text-xs font-bold">Bands</span>
|
||||||
|
|
|
||||||
|
|
@ -100,4 +100,42 @@ defmodule MicrowavepropWeb.ContactMapLiveTest do
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "filter_dates event" do
|
||||||
|
test "stores trimmed start/end dates and round-trips them in the inputs", %{conn: conn} do
|
||||||
|
{:ok, lv, _html} = live(conn, ~p"/contacts/map")
|
||||||
|
|
||||||
|
html =
|
||||||
|
lv
|
||||||
|
|> form(~s|form[phx-submit="filter_dates"]:has(input.input-sm)|,
|
||||||
|
start_date: "2025-01-01",
|
||||||
|
end_date: "2026-01-01"
|
||||||
|
)
|
||||||
|
|> render_submit()
|
||||||
|
|
||||||
|
assert html =~ ~s(name="start_date" value="2025-01-01")
|
||||||
|
assert html =~ ~s(name="end_date" value="2026-01-01")
|
||||||
|
# Clear button shows up only once a filter is set.
|
||||||
|
assert html =~ ~s(phx-click="clear_dates")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "clear_dates wipes both inputs", %{conn: conn} do
|
||||||
|
{:ok, lv, _html} = live(conn, ~p"/contacts/map")
|
||||||
|
|
||||||
|
lv
|
||||||
|
|> form(~s|form[phx-submit="filter_dates"]:has(input.input-sm)|,
|
||||||
|
start_date: "2025-01-01",
|
||||||
|
end_date: "2026-01-01"
|
||||||
|
)
|
||||||
|
|> render_submit()
|
||||||
|
|
||||||
|
html =
|
||||||
|
render_click(
|
||||||
|
element(lv, ~s|form:has(input.input-sm) button[phx-click="clear_dates"]|)
|
||||||
|
)
|
||||||
|
|
||||||
|
refute html =~ ~s(value="2025-01-01")
|
||||||
|
refute html =~ ~s(value="2026-01-01")
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue