From f7a753626dfeabcfa4b8004c51f0b94414400f37 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 1 Apr 2026 12:39:02 -0500 Subject: [PATCH] Add interactive popups to contact map, dedup reciprocals, darken line colors --- assets/js/contacts_map_hook.js | 65 ++++++++++++------- .../live/contact_map_live.ex | 22 ++++++- .../live/contact_live_test.exs | 8 +++ 3 files changed, 69 insertions(+), 26 deletions(-) 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",