Add Microwaveprop.Format.distance_km/1 and a matching formatDistanceKm helper for the JS side. Both emit "X mi (Y km)" with one decimal under 10 mi and whole numbers above. Replace the ad-hoc format_dist/format_km_mi helpers with the shared formatter and update every user-facing distance render: contact detail, contacts index column, user profile contact/involving tables, path finder summary + sounding list, contacts map line popups, beacon coverage tooltip, and propagation map range estimate + rain scatter cell popup.
342 lines
11 KiB
TypeScript
342 lines
11 KiB
TypeScript
import {formatDistanceKm} from "./format"
|
|
|
|
// Contact tuple indices: [lat1, lon1, lat2, lon2, band, s1, s2, mode, dist, ts, id]
|
|
type ContactTuple = [
|
|
number, number, number, number, number,
|
|
string | null, string | null, string | null,
|
|
number | null, string | null, string
|
|
]
|
|
|
|
interface ApplyFilterPayload {
|
|
callsign: string | null
|
|
}
|
|
|
|
interface LineEntry {
|
|
line: L.Polyline
|
|
band: number
|
|
s1: string
|
|
s2: string
|
|
lat1: number
|
|
lon1: number
|
|
lat2: number
|
|
lon2: number
|
|
}
|
|
|
|
interface ContactsMapHook extends ViewHook {
|
|
allContacts: ContactTuple[]
|
|
callsignFilter: string
|
|
enabledBands: Set<number>
|
|
bandGroups: Map<number, L.LayerGroup>
|
|
lines: LineEntry[]
|
|
dotLayer: L.LayerGroup
|
|
map: L.Map
|
|
delegatedChange: (e: Event) => void
|
|
delegatedClick: (e: Event) => void
|
|
themeObserver: MutationObserver | null
|
|
initMap(this: ContactsMapHook): void
|
|
buildLines(this: ContactsMapHook): void
|
|
syncBandLayers(this: ContactsMapHook): void
|
|
applyCallsignFilter(this: ContactsMapHook): void
|
|
rebuildDots(this: ContactsMapHook): void
|
|
updateCount(this: ContactsMapHook, count: number): void
|
|
wireControls(this: ContactsMapHook): void
|
|
unwireControls(this: ContactsMapHook): void
|
|
restyleLines(this: ContactsMapHook): void
|
|
}
|
|
|
|
// Amateur-band palette. Nine of these map to the design system's --band-*
|
|
// CSS vars (defined in assets/css/app.css); the higher mm-wave bands above
|
|
// 47 GHz fall back to hard-coded hues since the design set only spans up
|
|
// to 6mm. CSS vars are resolved once per page via getComputedStyle because
|
|
// Leaflet writes the color onto the SVG `stroke` attribute, which does not
|
|
// evaluate `var()` references.
|
|
const BAND_VAR: Record<number, string> = {
|
|
1296: "--band-23cm",
|
|
2304: "--band-13cm",
|
|
3400: "--band-9cm",
|
|
5760: "--band-6cm",
|
|
10000: "--band-3cm",
|
|
24000: "--band-1-25cm",
|
|
47000: "--band-6mm",
|
|
}
|
|
|
|
const BAND_FALLBACK: Record<number, string> = {
|
|
68000: "#dc2626",
|
|
75000: "#c026d3",
|
|
122000: "#db2777",
|
|
134000: "#e11d48",
|
|
241000: "#b91c1c",
|
|
}
|
|
|
|
let BAND_COLORS: Record<number, string> | null = null
|
|
|
|
function resolveBandColors(): Record<number, string> {
|
|
if (BAND_COLORS) return BAND_COLORS
|
|
const styles = getComputedStyle(document.documentElement)
|
|
const resolved: Record<number, string> = { ...BAND_FALLBACK }
|
|
for (const [band, cssVar] of Object.entries(BAND_VAR)) {
|
|
const value = styles.getPropertyValue(cssVar).trim()
|
|
if (value) resolved[Number(band)] = value
|
|
}
|
|
BAND_COLORS = resolved
|
|
return resolved
|
|
}
|
|
|
|
function invalidateBandColors(): void {
|
|
BAND_COLORS = null
|
|
}
|
|
|
|
function bandLabel(mhz: number): string {
|
|
if (mhz >= 1000) {
|
|
const ghz = mhz / 1000
|
|
return (Number.isInteger(ghz) ? ghz : ghz.toFixed(1)) + " GHz"
|
|
}
|
|
return mhz + " MHz"
|
|
}
|
|
|
|
function bandColor(band: number): string {
|
|
return resolveBandColors()[band] || "#64748b"
|
|
}
|
|
|
|
export const ContactsMap = {
|
|
mounted(this: ContactsMapHook) {
|
|
const fetchUrl = this.el.dataset.fetchUrl || "/api/contacts/map"
|
|
this.allContacts = []
|
|
this.callsignFilter = ""
|
|
this.enabledBands = new Set()
|
|
this.bandGroups = new Map()
|
|
this.lines = []
|
|
this.dotLayer = L.layerGroup()
|
|
this.themeObserver = null
|
|
|
|
// Callsign filter still roundtrips through LiveView (debounced) — it's
|
|
// cross-cutting and fires rarely. Band toggles are purely client-side.
|
|
this.handleEvent("apply_filter", ({callsign}: ApplyFilterPayload) => {
|
|
this.callsignFilter = (callsign || "").toUpperCase()
|
|
this.applyCallsignFilter()
|
|
this.rebuildDots()
|
|
})
|
|
|
|
this.wireControls()
|
|
|
|
// Re-color polylines when the theme toggles. Leaflet writes `stroke` as
|
|
// an SVG attribute (which does not resolve CSS vars), so we re-read the
|
|
// resolved --band-* values and call setStyle on each line.
|
|
this.themeObserver = new MutationObserver(() => {
|
|
invalidateBandColors()
|
|
this.restyleLines()
|
|
})
|
|
this.themeObserver.observe(document.documentElement, {
|
|
attributes: true,
|
|
attributeFilter: ["data-theme"]
|
|
})
|
|
|
|
requestAnimationFrame(() => this.initMap())
|
|
|
|
fetch(fetchUrl)
|
|
.then(resp => resp.json())
|
|
.then(contacts => {
|
|
this.allContacts = contacts
|
|
for (const c of contacts) this.enabledBands.add(c[4])
|
|
if (this.map) {
|
|
this.buildLines()
|
|
this.syncBandLayers()
|
|
this.rebuildDots()
|
|
}
|
|
})
|
|
.catch(err => console.error("Failed to load contacts:", err))
|
|
},
|
|
|
|
destroyed(this: ContactsMapHook) {
|
|
this.unwireControls()
|
|
if (this.themeObserver) {
|
|
this.themeObserver.disconnect()
|
|
this.themeObserver = null
|
|
}
|
|
},
|
|
|
|
restyleLines(this: ContactsMapHook) {
|
|
for (const entry of this.lines) {
|
|
entry.line.setStyle({ color: bandColor(entry.band) })
|
|
}
|
|
},
|
|
|
|
wireControls(this: ContactsMapHook) {
|
|
// Band checkboxes and All/None buttons live outside this hook's element
|
|
// (they're in the sidebar), so delegate from the document. The checkboxes
|
|
// have data-band and the bulk buttons have data-band-action="all"|"none".
|
|
this.delegatedChange = (e: Event) => {
|
|
const t = e.target as HTMLInputElement
|
|
if (!t || !t.matches || !t.matches("input[data-band]")) return
|
|
const band = parseInt(t.dataset.band || "", 10)
|
|
if (!Number.isFinite(band)) return
|
|
if (t.checked) this.enabledBands.add(band)
|
|
else this.enabledBands.delete(band)
|
|
this.syncBandLayers()
|
|
this.rebuildDots()
|
|
}
|
|
|
|
this.delegatedClick = (e: Event) => {
|
|
const t = e.target as HTMLElement
|
|
if (!t || !t.matches || !t.matches("[data-band-action]")) return
|
|
const action = t.dataset.bandAction
|
|
const inputs = document.querySelectorAll<HTMLInputElement>("input[data-band]")
|
|
if (action === "all") {
|
|
this.enabledBands = new Set()
|
|
for (const inp of inputs) {
|
|
inp.checked = true
|
|
const band = parseInt(inp.dataset.band || "", 10)
|
|
if (Number.isFinite(band)) this.enabledBands.add(band)
|
|
}
|
|
} else if (action === "none") {
|
|
this.enabledBands = new Set()
|
|
for (const inp of inputs) inp.checked = false
|
|
} else {
|
|
return
|
|
}
|
|
this.syncBandLayers()
|
|
this.rebuildDots()
|
|
}
|
|
|
|
document.addEventListener("change", this.delegatedChange)
|
|
document.addEventListener("click", this.delegatedClick)
|
|
},
|
|
|
|
unwireControls(this: ContactsMapHook) {
|
|
if (this.delegatedChange) document.removeEventListener("change", this.delegatedChange)
|
|
if (this.delegatedClick) document.removeEventListener("click", this.delegatedClick)
|
|
},
|
|
|
|
initMap(this: ContactsMapHook) {
|
|
this.map = L.map(this.el, {
|
|
center: [37.5, -96],
|
|
zoom: 5,
|
|
minZoom: 3,
|
|
maxZoom: 14,
|
|
preferCanvas: true
|
|
})
|
|
|
|
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
|
attribution: "© OpenStreetMap contributors",
|
|
maxZoom: 19
|
|
}).addTo(this.map)
|
|
|
|
this.dotLayer.addTo(this.map)
|
|
|
|
if (this.allContacts.length > 0) {
|
|
for (const c of this.allContacts) this.enabledBands.add(c[4])
|
|
this.buildLines()
|
|
this.syncBandLayers()
|
|
this.rebuildDots()
|
|
}
|
|
},
|
|
|
|
buildLines(this: ContactsMapHook) {
|
|
// One-time build of every polyline, grouped into per-band LayerGroups.
|
|
// After this, band toggling is just addLayer/removeLayer on the map —
|
|
// no polyline recreation ever.
|
|
this.lines = []
|
|
this.bandGroups = new Map()
|
|
|
|
for (const c of this.allContacts) {
|
|
const [lat1, lon1, lat2, lon2, band, s1, s2, mode, dist, ts, id] = c
|
|
const color = bandColor(band)
|
|
|
|
const line = L.polyline([[lat1, lon1], [lat2, lon2]], {
|
|
color, weight: 2, opacity: 0.5
|
|
})
|
|
|
|
const distStr = formatDistanceKm(dist)
|
|
line.bindPopup(
|
|
`<div style="font-size:12px;line-height:1.5;">` +
|
|
`<strong>${s1 || "?"} ↔ ${s2 || "?"}</strong><br/>` +
|
|
`${bandLabel(band)} · ${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: L.Polyline) { this.setStyle({weight: 4, opacity: 1}) })
|
|
line.on("mouseout", function(this: L.Polyline) { this.setStyle({weight: 2, opacity: 0.5}) })
|
|
|
|
let group = this.bandGroups.get(band)
|
|
if (!group) {
|
|
group = L.layerGroup()
|
|
this.bandGroups.set(band, group)
|
|
}
|
|
group.addLayer(line)
|
|
|
|
this.lines.push({
|
|
line,
|
|
band,
|
|
s1: (s1 || "").toUpperCase(),
|
|
s2: (s2 || "").toUpperCase(),
|
|
lat1, lon1, lat2, lon2
|
|
})
|
|
}
|
|
},
|
|
|
|
syncBandLayers(this: ContactsMapHook) {
|
|
// Fast path: flip whole-group on/off. O(bands), not O(contacts).
|
|
for (const [band, group] of this.bandGroups) {
|
|
const enabled = this.enabledBands.has(band)
|
|
const present = this.map.hasLayer(group)
|
|
if (enabled && !present) this.map.addLayer(group)
|
|
else if (!enabled && present) this.map.removeLayer(group)
|
|
}
|
|
},
|
|
|
|
applyCallsignFilter(this: ContactsMapHook) {
|
|
// Callsign filter: iterate individual polylines and add/remove them
|
|
// from their band's group. Cheap compared to rebuild (no polyline
|
|
// creation). Only runs when the filter string changes.
|
|
const match = this.callsignFilter
|
|
for (const entry of this.lines) {
|
|
const group = this.bandGroups.get(entry.band)
|
|
if (!group) continue
|
|
const visible = !match || entry.s1.includes(match) || entry.s2.includes(match)
|
|
const present = group.hasLayer(entry.line)
|
|
if (visible && !present) group.addLayer(entry.line)
|
|
else if (!visible && present) group.removeLayer(entry.line)
|
|
}
|
|
},
|
|
|
|
rebuildDots(this: ContactsMapHook) {
|
|
this.dotLayer.clearLayers()
|
|
const match = this.callsignFilter
|
|
const endpointCounts: Record<string, number> = {}
|
|
let lineCount = 0
|
|
|
|
for (const entry of this.lines) {
|
|
if (!this.enabledBands.has(entry.band)) continue
|
|
if (match && !entry.s1.includes(match) && !entry.s2.includes(match)) continue
|
|
lineCount++
|
|
const k1 = `${entry.lat1.toFixed(2)},${entry.lon1.toFixed(2)}`
|
|
const k2 = `${entry.lat2.toFixed(2)},${entry.lon2.toFixed(2)}`
|
|
endpointCounts[k1] = (endpointCounts[k1] || 0) + 1
|
|
endpointCounts[k2] = (endpointCounts[k2] || 0) + 1
|
|
}
|
|
|
|
for (const [key, count] of Object.entries(endpointCounts)) {
|
|
const [lat, lon] = key.split(",").map(Number)
|
|
const radius = Math.min(2 + Math.log2(count) * 1.5, 10)
|
|
|
|
L.circleMarker([lat, lon], {
|
|
radius, color: "#fff", weight: 1,
|
|
fillColor: "#3b82f6", fillOpacity: 0.7,
|
|
interactive: false
|
|
}).addTo(this.dotLayer)
|
|
}
|
|
|
|
this.updateCount(lineCount)
|
|
},
|
|
|
|
updateCount(this: ContactsMapHook, count: number) {
|
|
const label = `${count.toLocaleString()} contacts`
|
|
const countEl = document.getElementById("contact-count")
|
|
if (countEl) countEl.textContent = label
|
|
const countMobile = document.getElementById("contact-count-mobile")
|
|
if (countMobile) countMobile.textContent = label
|
|
}
|
|
}
|