prop/assets/js/elevation_profile_hook.ts
Graham McIntire 5488744f44
feat(contact): loading spinners, callsign-aware summaries, axis callsigns
Three improvements to the contact detail page:

* Track async hydration per source in `hydration_pending` so the
  Terrain / Soundings / Atmospheric Profile sections render a loading
  spinner while their task is in-flight instead of briefly flashing
  "no data available" before the payload arrives.
* Propagation analysis summary now names the origin station by
  callsign ("Path is terrain-obstructed at N mi from W5XD") instead
  of the generic "station 1" placeholder.
* Elevation profile chart labels the 0-mi and max-mi ticks with the
  matching callsign so it's obvious which end of the profile is
  which station.
2026-04-18 13:43:35 -05:00

369 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Chart from "../vendor/chart.js"
const M2FT = 3.28084
const KM2MI = 0.621371
interface ProfilePoint {
dist_km: number
elev: number
beam: number
r1: number
}
interface DuctRaw {
base_m_msl: number
top_m_msl: number
strength: number
source?: DuctSource
likely?: boolean
}
type DuctSource = "hrrr" | "sounding" | "inferred"
interface DuctBand {
base_ft: number
top_ft: number
agl_base_ft: number
agl_top_ft: number
strength: number
source: DuctSource
likely: boolean
}
interface ProfileData {
points: ProfilePoint[]
freq_mhz: number
k_factor?: number
dist_km?: number
ducts?: DuctRaw[]
}
interface EarthCurveParams {
dTotal: number
k: number
R: number
minElev: number
}
interface ChartWithAreas {
options: {
plugins: {
ductBands?: {
ducts: DuctBand[]
earthCurve: EarthCurveParams
}
}
}
ctx: CanvasRenderingContext2D
chartArea: { left: number; right: number; top: number; bottom: number }
scales: { y: { getPixelForValue(value: number): number } }
}
interface ElevationProfileHook extends ViewHook {
chart: Chart | null
renderChart(this: ElevationProfileHook, data: ProfileData): void
}
function earthSurfaceFt(f: number, params: EarthCurveParams): number {
const bulge = (f * params.dTotal * (1 - f) * params.dTotal) / (2 * params.k * params.R)
return (params.minElev + bulge) * M2FT
}
// Chart.js plugin to draw curved duct bands that follow earth curvature
const ductPlugin = {
id: "ductBands",
beforeDraw(chart: ChartWithAreas): void {
const opts = chart.options.plugins.ductBands
if (!opts) return
const { ducts, earthCurve } = opts
if (!ducts || ducts.length === 0 || !earthCurve) return
const { ctx, chartArea: { left, right, top, bottom }, scales: { y } } = chart
const chartWidth = right - left
ctx.save()
for (const duct of ducts) {
const likely = duct.likely
const fillAlpha = likely ? 0.18 : 0.08
const strokeAlpha = likely ? 0.8 : 0.3
const lineWidth = likely ? 1.5 : 1
ctx.fillStyle = `rgba(34, 197, 94, ${fillAlpha})`
ctx.strokeStyle = `rgba(34, 197, 94, ${strokeAlpha})`
ctx.lineWidth = lineWidth
ctx.setLineDash(likely ? [] : [4, 3])
// Draw filled curved band by scanning x-pixels
ctx.beginPath()
let firstTop = 0
let firstBase = 0
// Top edge (left to right)
for (let px = left; px <= right; px++) {
const f = (px - left) / chartWidth
const earthFt = earthSurfaceFt(f, earthCurve)
const ductTopFt = earthFt + duct.agl_top_ft
const yPx = Math.max(y.getPixelForValue(ductTopFt), top)
if (px === left) { ctx.moveTo(px, yPx); firstTop = yPx }
else ctx.lineTo(px, yPx)
}
// Base edge (right to left)
for (let px = right; px >= left; px--) {
const f = (px - left) / chartWidth
const earthFt = earthSurfaceFt(f, earthCurve)
const ductBaseFt = earthFt + duct.agl_base_ft
const yPx = Math.min(y.getPixelForValue(ductBaseFt), bottom)
ctx.lineTo(px, yPx)
if (px === left) firstBase = yPx
}
ctx.closePath()
ctx.fill()
// Draw top and base edge lines
ctx.beginPath()
for (let px = left; px <= right; px += 2) {
const f = (px - left) / chartWidth
const earthFt = earthSurfaceFt(f, earthCurve)
if (px === left) ctx.moveTo(px, Math.max(y.getPixelForValue(earthFt + duct.agl_top_ft), top))
else ctx.lineTo(px, Math.max(y.getPixelForValue(earthFt + duct.agl_top_ft), top))
}
ctx.stroke()
ctx.beginPath()
for (let px = left; px <= right; px += 2) {
const f = (px - left) / chartWidth
const earthFt = earthSurfaceFt(f, earthCurve)
if (px === left) ctx.moveTo(px, Math.min(y.getPixelForValue(earthFt + duct.agl_base_ft), bottom))
else ctx.lineTo(px, Math.min(y.getPixelForValue(earthFt + duct.agl_base_ft), bottom))
}
ctx.stroke()
ctx.setLineDash([])
// Label at left edge
const labelY = Math.max(firstTop + 12, top + 12)
const textAlpha = likely ? 1.0 : 0.5
ctx.fillStyle = `rgba(34, 197, 94, ${textAlpha})`
ctx.font = likely ? "bold 10px sans-serif" : "9px sans-serif"
const labels: Record<DuctSource, string> = { hrrr: "Duct", sounding: "Duct (sounding)", inferred: "Est. duct" }
const units: Record<DuctSource, string> = { hrrr: "M-units", sounding: "M-units", inferred: "dN/dh÷10" }
const prefix = labels[duct.source] || "Duct"
const unit = units[duct.source] || "M-units"
const tag = likely ? " — likely path" : ""
const baseFt = Math.round(duct.base_ft)
const topFt = Math.round(duct.top_ft)
ctx.fillText(`${prefix} ${baseFt}${topFt} ft (${duct.strength} ${unit})${tag}`, left + 4, labelY)
}
ctx.restore()
}
}
export const ElevationProfile: ElevationProfileHook = {
mounted(this: ElevationProfileHook) {
const data: ProfileData = JSON.parse(this.el.dataset.profile!)
if (!data || !data.points || data.points.length === 0) return
this.renderChart(data)
},
renderChart(this: ElevationProfileHook, data: ProfileData) {
const points = data.points
const n = points.length - 1
const distances = points.map(p => p.dist_km * KM2MI)
const showFresnel = data.freq_mhz >= 1000
const station1 = this.el.dataset.station1 || ""
const station2 = this.el.dataset.station2 || ""
const k = data.k_factor || (4 / 3)
const R = 6371000
const dTotal = (data.dist_km || 0) * 1000
const minElev = Math.min(...points.map(p => p.elev))
const earthCurve: EarthCurveParams = { dTotal, k, R, minElev }
// Earth surface: smooth parabolic curve (earth curvature without terrain features)
const earthSurface = points.map((_p, i) => {
const f = n > 0 ? i / n : 0
return earthSurfaceFt(f, earthCurve)
})
// Terrain: actual elevation + earth curvature bulge (sits on top of earth surface)
const elevations = points.map((p, i) => {
const f = n > 0 ? i / n : 0
const bulge = (f * dTotal * (1 - f) * dTotal) / (2 * k * R)
return (p.elev + bulge) * M2FT
})
// LOS beam: straight line from antenna A to antenna B
const losLine = points.map(p => p.beam * M2FT)
const fresnelLower = points.map(p => (p.beam - p.r1) * M2FT)
// Ducts: convert to AGL relative to earth surface at endpoints
const refElevFt = ((points[0].elev + points[n].elev) / 2) * M2FT
const ducts: DuctBand[] = (data.ducts || []).map(d => ({
base_ft: d.base_m_msl * M2FT,
top_ft: d.top_m_msl * M2FT,
agl_base_ft: d.base_m_msl * M2FT - refElevFt,
agl_top_ft: d.top_m_msl * M2FT - refElevFt,
strength: d.strength,
source: d.source || "hrrr",
likely: d.likely || false
}))
const allElevs = [...elevations, ...losLine, ...earthSurface]
let minY = Math.min(...allElevs) - 40
let maxY = Math.max(...allElevs) + 120
for (const d of ducts) {
const topAtMid = earthSurfaceFt(0.5, earthCurve) + d.agl_top_ft
if (topAtMid + 40 > maxY) maxY = topAtMid + 40
}
const canvas = this.el.querySelector("canvas") as HTMLCanvasElement | null
if (!canvas) return
const ctx = canvas.getContext("2d")!
this.chart = new Chart(ctx, {
type: "line",
plugins: [ductPlugin],
data: {
labels: distances,
datasets: [
{
label: "Earth",
data: earthSurface,
borderColor: "#5C4033",
backgroundColor: "rgba(60, 30, 10, 0.5)",
borderWidth: 1,
fill: "origin",
pointRadius: 0,
tension: 0.4,
order: 5
},
{
label: "Terrain",
data: elevations,
borderColor: "#8B6914",
backgroundColor: "rgba(139, 105, 20, 0.4)",
fill: "-1",
pointRadius: 0,
borderWidth: 1.5,
tension: 0.1,
order: 4
},
{
label: "Line of Sight",
data: losLine,
borderColor: "#2563eb",
borderWidth: 2,
borderDash: [6, 3],
pointRadius: 0,
fill: false,
order: 1
},
...(showFresnel ? [
{
label: "Fresnel Zone",
data: fresnelLower,
borderColor: "rgba(239, 68, 68, 0.4)",
backgroundColor: "rgba(239, 68, 68, 0.08)",
borderWidth: 1,
borderDash: [3, 3],
pointRadius: 0,
fill: "+1",
order: 2
},
{
label: "_fresnelBase",
data: losLine,
borderWidth: 0,
pointRadius: 0,
fill: false,
order: 2
}
] : []),
...(ducts.length > 0 ? [{
label: "Duct Layer",
data: [] as number[],
borderColor: "rgba(34, 197, 94, 0.5)",
backgroundColor: "rgba(34, 197, 94, 0.12)",
borderWidth: 2,
borderDash: [4, 3],
pointRadius: 0,
fill: false
}] : [])
]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 300 },
plugins: {
ductBands: { ducts, earthCurve },
legend: {
display: true,
position: "top",
labels: {
boxWidth: 12,
font: { size: 10 },
filter: (item: { text: string }) => !item.text.startsWith("_")
}
},
tooltip: {
callbacks: {
title: (items: { label: string }[]) => items[0].label + " mi",
label: (item: { dataset: { label: string }; raw: number }) => {
if (item.dataset.label.startsWith("_")) return null
if (item.dataset.label === "Earth") return null
return item.dataset.label + ": " + Math.round(item.raw) + " ft"
}
}
}
},
scales: {
x: {
title: { display: true, text: "Distance (mi)", font: { size: 10 } },
afterBuildTicks: (axis: { ticks: { value: number }[] }) => {
const last = distances.length - 1
const totalMi = distances[last]
const step = Math.ceil(totalMi / 7) || 1
const ticks: { value: number }[] = []
for (let mi = 0; mi < totalMi; mi += step) {
const idx = distances.findIndex(d => d >= mi)
if (idx >= 0) ticks.push({ value: idx })
}
ticks.push({ value: last })
axis.ticks = ticks
},
ticks: {
font: { size: 9 },
autoSkip: false,
// Chart.js renders string[] tick labels as stacked lines, so we
// tuck the station callsign onto the second line at the 0-mi
// and max-mi endpoints — gives each end of the profile a
// station identity without needing a separate axis.
callback: (val: number) => {
const mi = distances[val]
if (mi === undefined) return ""
const mileage = Math.round(mi)
if (val === 0 && station1) return [`${mileage}`, station1]
if (val === distances.length - 1 && station2) return [`${mileage}`, station2]
return `${mileage}`
}
}
},
y: {
title: { display: true, text: "Elevation (ft MSL)", font: { size: 10 } },
ticks: { font: { size: 9 } },
min: Math.max(0, minY),
max: maxY
}
}
}
})
},
destroyed(this: ElevationProfileHook) {
if (this.chart) {
this.chart.destroy()
this.chart = null
}
}
} as ElevationProfileHook