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
parent cd8d7a0f52
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
const BAND_COLORS = {
1296: "#94a3b8", // slate
2304: "#a78bfa", // violet
3456: "#818cf8", // indigo
5760: "#60a5fa", // blue
10000: "#34d399", // emerald
24000: "#fbbf24", // amber
47000: "#fb923c", // orange
68000: "#f87171", // red
75000: "#e879f9", // fuchsia
122000: "#f472b6", // pink
134000: "#fb7185", // rose
241000: "#ff5555", // bright red
1296: "#475569", // slate-600
2304: "#7c3aed", // violet-600
3456: "#4f46e5", // indigo-600
5760: "#2563eb", // blue-600
10000: "#059669", // emerald-600
24000: "#d97706", // amber-600
47000: "#ea580c", // orange-600
68000: "#dc2626", // red-600
75000: "#c026d3", // fuchsia-600
122000: "#db2777", // pink-600
134000: "#e11d48", // rose-600
241000: "#b91c1c", // red-700
}
const BAND_LABELS = {
1296: "1296 MHz", 2304: "2304 MHz", 3456: "3456 MHz", 5760: "5760 MHz",
10000: "10 GHz", 24000: "24 GHz", 47000: "47 GHz", 68000: "68 GHz",
75000: "76 GHz", 122000: "122 GHz", 134000: "134 GHz", 241000: "241 GHz"
function bandLabel(mhz) {
if (mhz >= 1000) {
const ghz = mhz / 1000
return (Number.isInteger(ghz) ? ghz : ghz.toFixed(1)) + " GHz"
}
return mhz + " MHz"
}
function bandColor(band) {
@ -52,20 +54,35 @@ export const ContactsMap = {
// Collect all endpoint coords for endpoint density layer
const endpointCounts = {}
// Draw lines per band using canvas polylines
// Draw lines per band — interactive with popups
const bandLayers = {}
for (const [band, entries] of Object.entries(byBand)) {
const color = bandColor(parseInt(band))
const lines = []
for (const c of entries) {
const [lat1, lon1, lat2, lon2] = c
lines.push(L.polyline([[lat1, lon1], [lat2, lon2]], {
const [lat1, lon1, lat2, lon2, bnd, s1, s2, mode, dist, ts, id] = c
const line = L.polyline([[lat1, lon1], [lat2, lon2]], {
color: color,
weight: 1,
opacity: 0.4,
interactive: false
}))
weight: 2,
opacity: 0.5
})
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
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>"
for (const [band, entries] of sorted) {
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>`
}
div.innerHTML = html

View file

@ -23,9 +23,15 @@ defmodule MicrowavepropWeb.ContactMapLive do
from(c in Contact,
where: not is_nil(c.pos1) and not is_nil(c.pos2),
select: %{
id: c.id,
pos1: c.pos1,
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()
@ -35,12 +41,24 @@ defmodule MicrowavepropWeb.ContactMapLive do
lat2 = c.pos2["lat"]
lon2 = c.pos2["lon"] || c.pos2["lng"]
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
[lat1, lon1, lat2, lon2, band]
[lat1, lon1, lat2, lon2, band, c.station1, c.station2, c.mode, dist, ts, c.id]
end
end)
|> 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
@impl true

View file

@ -6,6 +6,14 @@ defmodule MicrowavepropWeb.ContactLiveTest do
alias Microwaveprop.Radio.Contact
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
default = %{
station1: "W5XD",