Add interactive popups to contact map, dedup reciprocals, darken line colors

This commit is contained in:
Graham McIntire 2026-04-01 12:39:02 -05:00
commit f7a753626d
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
3 changed files with 69 additions and 26 deletions

View file

@ -1,23 +1,25 @@
// Band MHz -> color mapping // Band MHz -> color mapping
const BAND_COLORS = { const BAND_COLORS = {
1296: "#94a3b8", // slate 1296: "#475569", // slate-600
2304: "#a78bfa", // violet 2304: "#7c3aed", // violet-600
3456: "#818cf8", // indigo 3456: "#4f46e5", // indigo-600
5760: "#60a5fa", // blue 5760: "#2563eb", // blue-600
10000: "#34d399", // emerald 10000: "#059669", // emerald-600
24000: "#fbbf24", // amber 24000: "#d97706", // amber-600
47000: "#fb923c", // orange 47000: "#ea580c", // orange-600
68000: "#f87171", // red 68000: "#dc2626", // red-600
75000: "#e879f9", // fuchsia 75000: "#c026d3", // fuchsia-600
122000: "#f472b6", // pink 122000: "#db2777", // pink-600
134000: "#fb7185", // rose 134000: "#e11d48", // rose-600
241000: "#ff5555", // bright red 241000: "#b91c1c", // red-700
} }
const BAND_LABELS = { function bandLabel(mhz) {
1296: "1296 MHz", 2304: "2304 MHz", 3456: "3456 MHz", 5760: "5760 MHz", if (mhz >= 1000) {
10000: "10 GHz", 24000: "24 GHz", 47000: "47 GHz", 68000: "68 GHz", const ghz = mhz / 1000
75000: "76 GHz", 122000: "122 GHz", 134000: "134 GHz", 241000: "241 GHz" return (Number.isInteger(ghz) ? ghz : ghz.toFixed(1)) + " GHz"
}
return mhz + " MHz"
} }
function bandColor(band) { function bandColor(band) {
@ -52,20 +54,35 @@ export const ContactsMap = {
// Collect all endpoint coords for endpoint density layer // Collect all endpoint coords for endpoint density layer
const endpointCounts = {} const endpointCounts = {}
// Draw lines per band using canvas polylines // Draw lines per band — interactive with popups
const bandLayers = {} const bandLayers = {}
for (const [band, entries] of Object.entries(byBand)) { for (const [band, entries] of Object.entries(byBand)) {
const color = bandColor(parseInt(band)) const color = bandColor(parseInt(band))
const lines = [] const lines = []
for (const c of entries) { for (const c of entries) {
const [lat1, lon1, lat2, lon2] = c const [lat1, lon1, lat2, lon2, bnd, s1, s2, mode, dist, ts, id] = c
lines.push(L.polyline([[lat1, lon1], [lat2, lon2]], { const line = L.polyline([[lat1, lon1], [lat2, lon2]], {
color: color, color: color,
weight: 1, weight: 2,
opacity: 0.4, opacity: 0.5
interactive: false })
}))
const distStr = dist != null ? `${Math.round(dist)} km` : "—"
line.bindPopup(
`<div style="font-size:12px;line-height:1.5;">` +
`<strong>${s1 || "?"} &harr; ${s2 || "?"}</strong><br/>` +
`${bandLabel(bnd)} &middot; ${mode || "?"}<br/>` +
`${distStr} &middot; ${ts || "?"} UTC<br/>` +
`<a href="/contacts/${id}" style="color:#3b82f6;">View details &rarr;</a>` +
`</div>`,
{closeButton: false, offset: [0, -4]}
)
line.on("mouseover", function() { this.setStyle({weight: 4, opacity: 1}) })
line.on("mouseout", function() { this.setStyle({weight: 2, opacity: 0.5}) })
lines.push(line)
// Track endpoints // Track endpoints
const k1 = `${lat1.toFixed(2)},${lon1.toFixed(2)}` const k1 = `${lat1.toFixed(2)},${lon1.toFixed(2)}`
@ -106,7 +123,7 @@ export const ContactsMap = {
let html = "<div style='font-weight:700;margin-bottom:4px;'>Bands</div>" let html = "<div style='font-weight:700;margin-bottom:4px;'>Bands</div>"
for (const [band, entries] of sorted) { for (const [band, entries] of sorted) {
const color = bandColor(parseInt(band)) const color = bandColor(parseInt(band))
const label = BAND_LABELS[parseInt(band)] || band + " MHz" const label = bandLabel(parseInt(band))
html += `<div><span style="display:inline-block;width:14px;height:3px;background:${color};margin-right:6px;vertical-align:middle;border-radius:1px;"></span>${label} (${entries.length.toLocaleString()})</div>` html += `<div><span style="display:inline-block;width:14px;height:3px;background:${color};margin-right:6px;vertical-align:middle;border-radius:1px;"></span>${label} (${entries.length.toLocaleString()})</div>`
} }
div.innerHTML = html div.innerHTML = html

View file

@ -23,9 +23,15 @@ defmodule MicrowavepropWeb.ContactMapLive do
from(c in Contact, from(c in Contact,
where: not is_nil(c.pos1) and not is_nil(c.pos2), where: not is_nil(c.pos1) and not is_nil(c.pos2),
select: %{ select: %{
id: c.id,
pos1: c.pos1, pos1: c.pos1,
pos2: c.pos2, pos2: c.pos2,
band: c.band band: c.band,
station1: c.station1,
station2: c.station2,
mode: c.mode,
distance_km: c.distance_km,
qso_timestamp: c.qso_timestamp
} }
) )
|> Repo.all() |> Repo.all()
@ -35,12 +41,24 @@ defmodule MicrowavepropWeb.ContactMapLive do
lat2 = c.pos2["lat"] lat2 = c.pos2["lat"]
lon2 = c.pos2["lon"] || c.pos2["lng"] lon2 = c.pos2["lon"] || c.pos2["lng"]
band = if c.band, do: Decimal.to_integer(c.band), else: 0 band = if c.band, do: Decimal.to_integer(c.band), else: 0
dist = if c.distance_km, do: Decimal.to_float(c.distance_km)
ts = if c.qso_timestamp, do: Calendar.strftime(c.qso_timestamp, "%Y-%m-%d %H:%M")
if lat1 && lon1 && lat2 && lon2 do if lat1 && lon1 && lat2 && lon2 do
[lat1, lon1, lat2, lon2, band] [lat1, lon1, lat2, lon2, band, c.station1, c.station2, c.mode, dist, ts, c.id]
end end
end) end)
|> Enum.reject(&is_nil/1) |> Enum.reject(&is_nil/1)
|> dedup_reciprocals()
end
# Deduplicate reciprocal contacts (A↔B same band/hour = same contact logged by both sides)
defp dedup_reciprocals(contacts) do
Enum.uniq_by(contacts, fn [_lat1, _lon1, _lat2, _lon2, band, s1, s2, _mode, _dist, ts, _id] ->
stations = Enum.sort([s1 || "", s2 || ""])
hour = if ts, do: String.slice(ts, 0..12), else: ""
{stations, band, hour}
end)
end end
@impl true @impl true

View file

@ -6,6 +6,14 @@ defmodule MicrowavepropWeb.ContactLiveTest do
alias Microwaveprop.Radio.Contact alias Microwaveprop.Radio.Contact
alias Microwaveprop.Repo alias Microwaveprop.Repo
setup do
Req.Test.stub(Microwaveprop.Terrain.ElevationClient, fn conn ->
Req.Test.json(conn, [])
end)
:ok
end
defp create_contact(attrs \\ %{}) do defp create_contact(attrs \\ %{}) do
default = %{ default = %{
station1: "W5XD", station1: "W5XD",