57,186 prod contacts stored pos1/pos2 with 'lng'; 1,133 used 'lon'. Every Elixir caller carried a `pos["lon"] || pos["lng"]` fallback — which just caused a SQL widget to silently miscount 98% of contacts (count_narr_done used `pos1->>'lon'` directly, no fallback, so every lng-keyed row returned NULL and failed the coverage check). - Migration rewrites every pos1/pos2 JSONB in place, renaming 'lng' to 'lon' and dropping 'lng'. - Removes all 20+ `|| pos["lng"]` fallbacks across lib/, workers, scorer, weather, radio.ex, contact show view, and recalibrator. - lib_ml/propagation_analyze.ex SQL now reads pos1->>'lon' directly (was reading 'lng' only, which would have broken after migration). - priv/repo/import_contacts.exs one-time seed script now emits 'lon' with string keys, matching production shape. - Test fixtures in 4 test files normalized to 'lon'. - Two lng-characterization tests deleted — nonsensical post-normalize. - Updated notebook + old import_weather script to match. - JS hook contact_map_hook.ts TypeScript type narrowed to 'lon'.
86 lines
2.8 KiB
TypeScript
86 lines
2.8 KiB
TypeScript
import { updateGridOverlay } from "./maidenhead_grid"
|
|
|
|
interface ContactMapHook extends ViewHook {
|
|
gridLayer: L.LayerGroup
|
|
gridVisible: boolean
|
|
initMap(): void
|
|
}
|
|
|
|
export const ContactMap = {
|
|
mounted(this: ContactMapHook) {
|
|
// Defer init until container has dimensions (flex layout timing)
|
|
requestAnimationFrame(() => this.initMap())
|
|
},
|
|
|
|
initMap(this: ContactMapHook) {
|
|
const pos1 = JSON.parse(this.el.dataset.pos1!) as { lat: number; lon: number }
|
|
const pos2 = JSON.parse(this.el.dataset.pos2!) as { lat: number; lon: number }
|
|
|
|
const lat1 = pos1.lat
|
|
const lon1 = pos1.lon
|
|
const lat2 = pos2.lat
|
|
const lon2 = pos2.lon
|
|
|
|
const bounds = L.latLngBounds([[lat1, lon1], [lat2, lon2]])
|
|
|
|
const map = L.map(this.el, {
|
|
zoomControl: true,
|
|
attributionControl: false,
|
|
scrollWheelZoom: false
|
|
}).fitBounds(bounds, {padding: [40, 40], maxZoom: 10})
|
|
|
|
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
|
maxZoom: 19
|
|
}).addTo(map)
|
|
|
|
// Grid overlay
|
|
this.gridLayer = L.layerGroup().addTo(map)
|
|
this.gridVisible = true
|
|
updateGridOverlay(map, this.gridLayer)
|
|
map.on("moveend", () => {
|
|
if (this.gridVisible) updateGridOverlay(map, this.gridLayer)
|
|
})
|
|
|
|
// Grid toggle control
|
|
const GridToggle = L.Control.extend({
|
|
options: { position: "topright" as const },
|
|
onAdd: () => {
|
|
const btn = L.DomUtil.create("button", "leaflet-bar leaflet-control")
|
|
btn.innerHTML = "Grid"
|
|
btn.title = "Toggle Maidenhead grid"
|
|
btn.style.cssText = "background:#fff;padding:4px 8px;font-size:12px;cursor:pointer;font-weight:600;"
|
|
L.DomEvent.disableClickPropagation(btn)
|
|
btn.onclick = () => {
|
|
this.gridVisible = !this.gridVisible
|
|
if (this.gridVisible) {
|
|
this.gridLayer.addTo(map)
|
|
updateGridOverlay(map, this.gridLayer)
|
|
btn.style.opacity = "1"
|
|
} else {
|
|
this.gridLayer.remove()
|
|
btn.style.opacity = "0.5"
|
|
}
|
|
}
|
|
return btn
|
|
}
|
|
})
|
|
new GridToggle().addTo(map)
|
|
|
|
const marker1 = L.circleMarker([lat1, lon1], {
|
|
radius: 6, color: "#2563eb", fillColor: "#3b82f6", fillOpacity: 0.9, weight: 2
|
|
}).addTo(map)
|
|
|
|
const marker2 = L.circleMarker([lat2, lon2], {
|
|
radius: 6, color: "#dc2626", fillColor: "#ef4444", fillOpacity: 0.9, weight: 2
|
|
}).addTo(map)
|
|
|
|
const station1 = this.el.dataset.station1
|
|
const station2 = this.el.dataset.station2
|
|
if (station1) marker1.bindTooltip(station1, {permanent: true, direction: "top", offset: [0, -8]})
|
|
if (station2) marker2.bindTooltip(station2, {permanent: true, direction: "top", offset: [0, -8]})
|
|
|
|
L.polyline([[lat1, lon1], [lat2, lon2]], {
|
|
color: "#6366f1", weight: 2, dashArray: "6 4", opacity: 0.8
|
|
}).addTo(map)
|
|
}
|
|
}
|