prop/assets/js/beacons_list_map_hook.ts
Graham McIntire 810f5a22ce
Beacon list map, profile page polish, nav ordering fix
/beacons gains a Leaflet map above the table showing every approved
beacon as a circle marker with a popup that links to the detail page.
New BeaconsListMap JS hook (assets/js/beacons_list_map_hook.ts) walks a
data-beacons JSON payload, fitBounds over the marker set, and mirrors
the format_freq/1 integer-comma formatting used on the detail view so
the popups match the rest of the UI. Approved/on-air beacons render
green, off-air beacons render gray. BeaconLive.Index.mount/3 now
materializes the list into an assign so it can encode it into the data
attribute; the live PubSub path keeps the map in sync on
create/update/delete.

Profile (/u/:callsign) polish: drop the hero-radio, hero-signal, and
hero-information-circle icons per request, and replace the daisyUI
`avatar placeholder` wrapper with a plain flex-centered circle so the
initial letter actually lives inside the circle instead of anchored to
the top-left corner (daisyUI 5 removed the old placeholder-centering
behavior).

Navigation ordering: in the top navbar (layouts.ex) Contacts now sits
immediately before Contact Map instead of after it. All three vertical
sidebars already had them adjacent in the correct order, so only the
horizontal nav changed.
2026-04-12 16:23:09 -05:00

102 lines
3 KiB
TypeScript

// Map used by /beacons — renders every approved beacon as a circle
// marker with a popup linking to the detail page.
interface BeaconPoint {
id: string
callsign: string
lat: number
lon: number
frequency_mhz: number
grid: string
keying: string
on_the_air: boolean
}
interface BeaconsListMapHook extends ViewHook {
mounted(this: BeaconsListMapHook): void
}
function formatFreq(freq: number): string {
// Mirror Microwaveprop.Beacons.Beacon.format_freq/1 — integers get
// comma separators, floats keep up to three decimals trimmed.
if (Number.isInteger(freq)) return freq.toLocaleString("en-US")
const trimmed = freq.toFixed(3).replace(/\.?0+$/, "")
const [whole, frac] = trimmed.split(".")
const wholeFormatted = parseInt(whole, 10).toLocaleString("en-US")
return frac ? `${wholeFormatted}.${frac}` : wholeFormatted
}
function escapeHtml(str: string): string {
return str.replace(/[&<>"']/g, ch => {
switch (ch) {
case "&": return "&amp;"
case "<": return "&lt;"
case ">": return "&gt;"
case "\"": return "&quot;"
case "'": return "&#39;"
default: return ch
}
})
}
export const BeaconsListMap = {
mounted(this: BeaconsListMapHook) {
const beacons: BeaconPoint[] = JSON.parse(this.el.dataset.beacons || "[]")
// DFW fallback so the map still renders when no beacons exist yet.
const defaultCenter: [number, number] = [32.897, -97.038]
const map = L.map(this.el, {
zoomControl: true,
scrollWheelZoom: false,
attributionControl: true
}).setView(defaultCenter, 6)
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "&copy; OpenStreetMap contributors",
maxZoom: 19
}).addTo(map)
const bounds: [number, number][] = []
for (const b of beacons) {
if (!Number.isFinite(b.lat) || !Number.isFinite(b.lon)) continue
const color = b.on_the_air ? "#16a34a" : "#94a3b8"
const fill = b.on_the_air ? "#22c55e" : "#cbd5e1"
const popupHtml =
`<div style="font-size:12px;line-height:1.5;min-width:160px;">` +
`<strong>${escapeHtml(b.callsign)}</strong>` +
(b.on_the_air
? ""
: ` <span style="color:#ca8a04;">&middot; off-air</span>`) +
`<br/>` +
`${formatFreq(b.frequency_mhz)} MHz<br/>` +
`Grid ${escapeHtml(b.grid)}<br/>` +
`Keying: ${escapeHtml(b.keying)}<br/>` +
`<a href="/beacons/${b.id}" style="color:#3b82f6;">View details &rarr;</a>` +
`</div>`
L.circleMarker([b.lat, b.lon], {
radius: 7,
color,
fillColor: fill,
fillOpacity: 0.9,
weight: 2
})
.bindPopup(popupHtml, { closeButton: true })
.bindTooltip(b.callsign, { direction: "top", offset: [0, -8] })
.addTo(map)
bounds.push([b.lat, b.lon])
}
if (bounds.length > 0) {
map.fitBounds(L.latLngBounds(bounds), { padding: [30, 30], maxZoom: 10 })
}
// Invalidate once after mount in case the parent was hidden.
setTimeout(() => map.invalidateSize(), 50)
}
}