Add interactive popups to contact map, dedup reciprocals, darken line colors
This commit is contained in:
parent
cd8d7a0f52
commit
f7a753626d
3 changed files with 69 additions and 26 deletions
|
|
@ -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 || "?"} ↔ ${s2 || "?"}</strong><br/>` +
|
||||||
|
`${bandLabel(bnd)} · ${mode || "?"}<br/>` +
|
||||||
|
`${distStr} · ${ts || "?"} UTC<br/>` +
|
||||||
|
`<a href="/contacts/${id}" style="color:#3b82f6;">View details →</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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue