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.
124 lines
3.3 KiB
TypeScript
124 lines
3.3 KiB
TypeScript
import {formatDistanceKm} from "./format"
|
|
|
|
interface CoverageCell {
|
|
lat: number
|
|
lon: number
|
|
color: string
|
|
label: string
|
|
rx_dbm: number
|
|
distance_km: number
|
|
score: number
|
|
}
|
|
|
|
interface ToggleCoveragePayload {
|
|
show: boolean
|
|
}
|
|
|
|
interface LoadCoveragePayload {
|
|
grid_step: number
|
|
cells: CoverageCell[]
|
|
show: boolean
|
|
}
|
|
|
|
interface BeaconMapHook extends ViewHook {
|
|
mounted(this: BeaconMapHook): void
|
|
}
|
|
|
|
export const BeaconMap = {
|
|
mounted(this: BeaconMapHook) {
|
|
const dataset = this.el.dataset
|
|
const lat = parseFloat(dataset.lat!)
|
|
const lon = parseFloat(dataset.lon!)
|
|
const label = dataset.label || ""
|
|
const onTheAir = dataset.onTheAir === "true"
|
|
|
|
const map = L.map(this.el, {
|
|
zoomControl: true,
|
|
attributionControl: false,
|
|
scrollWheelZoom: false
|
|
}).setView([lat, lon], 11)
|
|
|
|
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
|
maxZoom: 19
|
|
}).addTo(map)
|
|
|
|
// Beacon marker on top.
|
|
const markerColor = onTheAir ? "#16a34a" : "#94a3b8"
|
|
const markerFill = onTheAir ? "#22c55e" : "#cbd5e1"
|
|
L.circleMarker([lat, lon], {
|
|
radius: 7,
|
|
color: markerColor,
|
|
fillColor: markerFill,
|
|
fillOpacity: 1.0,
|
|
weight: 2
|
|
}).addTo(map).bindTooltip(label, {
|
|
permanent: true,
|
|
direction: "top",
|
|
offset: [0, -10]
|
|
})
|
|
|
|
// Coverage layer is lazy — populated on the first `load_coverage`
|
|
// event, which the server only sends when the user flips the
|
|
// toggle on. Keeping this off the mount path avoids parsing tens
|
|
// of thousands of cells into Leaflet rects on every page load.
|
|
let cellLayer: L.LayerGroup | null = null
|
|
let cellBounds: [number, number][] = []
|
|
|
|
const buildLayer = (cells: CoverageCell[], step: number): void => {
|
|
if (cellLayer) {
|
|
cellLayer.clearLayers()
|
|
} else {
|
|
cellLayer = L.layerGroup()
|
|
}
|
|
cellBounds = []
|
|
const half = step / 2.0
|
|
|
|
for (const c of cells) {
|
|
const sw: [number, number] = [c.lat - half, c.lon - half]
|
|
const ne: [number, number] = [c.lat + half, c.lon + half]
|
|
const rect = L.rectangle([sw, ne], {
|
|
color: c.color,
|
|
weight: 0,
|
|
fillColor: c.color,
|
|
fillOpacity: 0.55,
|
|
interactive: true
|
|
})
|
|
rect.bindTooltip(
|
|
`${c.label}<br>${c.rx_dbm} dBm<br>${formatDistanceKm(c.distance_km)} · score ${c.score}`,
|
|
{sticky: true}
|
|
)
|
|
rect.addTo(cellLayer)
|
|
cellBounds.push(sw, ne)
|
|
}
|
|
}
|
|
|
|
const showLayer = (): void => {
|
|
if (!cellLayer) return
|
|
if (!map.hasLayer(cellLayer)) cellLayer.addTo(map)
|
|
if (cellBounds.length > 0) {
|
|
map.fitBounds(L.latLngBounds(cellBounds), {padding: [20, 20]})
|
|
map.setZoom(map.getZoom() + 2)
|
|
}
|
|
}
|
|
|
|
const hideLayer = (): void => {
|
|
if (cellLayer && map.hasLayer(cellLayer)) map.removeLayer(cellLayer)
|
|
map.setView([lat, lon], 11)
|
|
}
|
|
|
|
this.handleEvent("load_coverage", ({grid_step, cells, show}: LoadCoveragePayload) => {
|
|
buildLayer(cells, grid_step)
|
|
if (show) showLayer()
|
|
})
|
|
|
|
this.handleEvent("toggle_coverage", ({show}: ToggleCoveragePayload) => {
|
|
if (show) {
|
|
showLayer()
|
|
} else {
|
|
hideLayer()
|
|
}
|
|
})
|
|
|
|
setTimeout(() => map.invalidateSize(), 50)
|
|
}
|
|
}
|