diff --git a/assets/js/contacts_map_hook.js b/assets/js/contacts_map_hook.js
index 9ecec39a..238cf291 100644
--- a/assets/js/contacts_map_hook.js
+++ b/assets/js/contacts_map_hook.js
@@ -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(
+ `
` +
+ `
${s1 || "?"} ↔ ${s2 || "?"}` +
+ `${bandLabel(bnd)} · ${mode || "?"}
` +
+ `${distStr} · ${ts || "?"} UTC
` +
+ `
View details →` +
+ `
`,
+ {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 = "Bands
"
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 += `${label} (${entries.length.toLocaleString()})
`
}
div.innerHTML = html
diff --git a/lib/microwaveprop_web/live/contact_map_live.ex b/lib/microwaveprop_web/live/contact_map_live.ex
index 96c28688..f8e73e0b 100644
--- a/lib/microwaveprop_web/live/contact_map_live.ex
+++ b/lib/microwaveprop_web/live/contact_map_live.ex
@@ -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
diff --git a/test/microwaveprop_web/live/contact_live_test.exs b/test/microwaveprop_web/live/contact_live_test.exs
index f6ce5c6f..70329924 100644
--- a/test/microwaveprop_web/live/contact_live_test.exs
+++ b/test/microwaveprop_web/live/contact_live_test.exs
@@ -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",