Curved earth surface in elevation profile with ducts following curvature

This commit is contained in:
Graham McIntire 2026-04-11 17:10:38 -05:00
parent d99b7a6ea2
commit 58e4f55ba8

View file

@ -23,6 +23,8 @@ type DuctSource = "hrrr" | "sounding" | "inferred"
interface DuctBand { interface DuctBand {
base_ft: number base_ft: number
top_ft: number top_ft: number
agl_base_ft: number
agl_top_ft: number
strength: number strength: number
source: DuctSource source: DuctSource
likely: boolean likely: boolean
@ -36,9 +38,21 @@ interface ProfileData {
ducts?: DuctRaw[] ducts?: DuctRaw[]
} }
interface EarthCurveParams {
dTotal: number
k: number
R: number
minElev: number
}
interface ChartWithAreas { interface ChartWithAreas {
options: { options: {
plugins: { ductBands?: { ducts: DuctBand[] } } plugins: {
ductBands?: {
ducts: DuctBand[]
earthCurve: EarthCurveParams
}
}
} }
ctx: CanvasRenderingContext2D ctx: CanvasRenderingContext2D
chartArea: { left: number; right: number; top: number; bottom: number } chartArea: { left: number; right: number; top: number; bottom: number }
@ -50,55 +64,93 @@ interface ElevationProfileHook extends ViewHook {
renderChart(this: ElevationProfileHook, data: ProfileData): void renderChart(this: ElevationProfileHook, data: ProfileData): void
} }
// Chart.js plugin to draw horizontal duct bands 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 = { const ductPlugin = {
id: "ductBands", id: "ductBands",
beforeDraw(chart: ChartWithAreas): void { beforeDraw(chart: ChartWithAreas): void {
const ducts = chart.options.plugins.ductBands?.ducts const opts = chart.options.plugins.ductBands
if (!ducts || ducts.length === 0) return 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 { ctx, chartArea: { left, right, top, bottom }, scales: { y } } = chart
const chartWidth = right - left
ctx.save() ctx.save()
for (const duct of ducts) { for (const duct of ducts) {
const yTop = y.getPixelForValue(duct.top_ft)
const yBase = y.getPixelForValue(duct.base_ft)
// Clamp to chart area
const drawTop = Math.max(yTop, top)
const drawBase = Math.min(yBase, bottom)
if (drawTop >= drawBase) continue
const likely = duct.likely const likely = duct.likely
const fillAlpha = likely ? 0.18 : 0.08 const fillAlpha = likely ? 0.18 : 0.08
const strokeAlpha = likely ? 0.8 : 0.3 const strokeAlpha = likely ? 0.8 : 0.3
const lineWidth = likely ? 1.5 : 1 const lineWidth = likely ? 1.5 : 1
ctx.fillStyle = `rgba(34, 197, 94, ${fillAlpha})` ctx.fillStyle = `rgba(34, 197, 94, ${fillAlpha})`
ctx.fillRect(left, drawTop, right - left, drawBase - drawTop)
ctx.strokeStyle = `rgba(34, 197, 94, ${strokeAlpha})` ctx.strokeStyle = `rgba(34, 197, 94, ${strokeAlpha})`
ctx.lineWidth = lineWidth ctx.lineWidth = lineWidth
ctx.setLineDash(likely ? [] : [4, 3]) ctx.setLineDash(likely ? [] : [4, 3])
// Draw filled curved band by scanning x-pixels
ctx.beginPath() ctx.beginPath()
ctx.moveTo(left, drawTop) let firstTop = 0
ctx.lineTo(right, drawTop) let firstBase = 0
ctx.moveTo(left, drawBase)
ctx.lineTo(right, drawBase) // 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.stroke()
ctx.setLineDash([]) ctx.setLineDash([])
// Label // Label at left edge
const labelY = Math.max(drawTop + 12, top + 12) const labelY = Math.max(firstTop + 12, top + 12)
const textAlpha = likely ? 1.0 : 0.5 const textAlpha = likely ? 1.0 : 0.5
ctx.fillStyle = `rgba(34, 197, 94, ${textAlpha})` ctx.fillStyle = `rgba(34, 197, 94, ${textAlpha})`
ctx.font = likely ? "bold 10px sans-serif" : "9px sans-serif" ctx.font = likely ? "bold 10px sans-serif" : "9px sans-serif"
const labels: Record<DuctSource, string> = {hrrr: "Duct", sounding: "Duct (sounding)", inferred: "Est. duct"} 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 units: Record<DuctSource, string> = { hrrr: "M-units", sounding: "M-units", inferred: "dN/dh÷10" }
const prefix = labels[duct.source] || "Duct" const prefix = labels[duct.source] || "Duct"
const unit = units[duct.source] || "M-units" const unit = units[duct.source] || "M-units"
const tag = likely ? " — likely path" : "" const tag = likely ? " — likely path" : ""
ctx.fillText(`${prefix} ${Math.round(duct.base_ft)}${Math.round(duct.top_ft)} ft (${duct.strength} ${unit})${tag}`, left + 4, labelY) 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() ctx.restore()
} }
@ -113,37 +165,53 @@ export const ElevationProfile: ElevationProfileHook = {
renderChart(this: ElevationProfileHook, data: ProfileData) { renderChart(this: ElevationProfileHook, data: ProfileData) {
const points = data.points const points = data.points
const n = points.length - 1
const distances = points.map(p => p.dist_km * KM2MI) const distances = points.map(p => p.dist_km * KM2MI)
const showFresnel = data.freq_mhz >= 1000 const showFresnel = data.freq_mhz >= 1000
// Earth curvature: add bulge to terrain so it curves up in the middle
// relative to the straight LOS beam. bulge(f) = d1*d2 / (2*k*R)
const k = data.k_factor || (4 / 3) const k = data.k_factor || (4 / 3)
const R = 6371000 // earth radius in meters const R = 6371000
const dTotal = (data.dist_km || 0) * 1000 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 elevations = points.map((p, i) => {
const f = points.length > 1 ? i / (points.length - 1) : 0 const f = n > 0 ? i / n : 0
const bulge = (f * dTotal * (1 - f) * dTotal) / (2 * k * R) const bulge = (f * dTotal * (1 - f) * dTotal) / (2 * k * R)
return (p.elev + bulge) * M2FT return (p.elev + bulge) * M2FT
}) })
// LOS beam: straight line from antenna A to antenna B
const losLine = points.map(p => p.beam * M2FT) const losLine = points.map(p => p.beam * M2FT)
const fresnelLower = points.map(p => (p.beam - p.r1) * 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 => ({ const ducts: DuctBand[] = (data.ducts || []).map(d => ({
base_ft: d.base_m_msl * M2FT, base_ft: d.base_m_msl * M2FT,
top_ft: d.top_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, strength: d.strength,
source: d.source || "hrrr", source: d.source || "hrrr",
likely: d.likely || false likely: d.likely || false
})) }))
const allElevs = [...elevations, ...losLine] const allElevs = [...elevations, ...losLine, ...earthSurface]
let minElev = Math.min(...allElevs) - 60 let minY = Math.min(...allElevs) - 40
let maxElev = Math.max(...allElevs) + 120 let maxY = Math.max(...allElevs) + 120
// Extend Y axis to include duct tops if needed
for (const d of ducts) { for (const d of ducts) {
if (d.top_ft + 40 > maxElev) maxElev = d.top_ft + 40 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 const canvas = this.el.querySelector("canvas") as HTMLCanvasElement | null
@ -156,16 +224,27 @@ export const ElevationProfile: ElevationProfileHook = {
data: { data: {
labels: distances, labels: distances,
datasets: [ 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", label: "Terrain",
data: elevations, data: elevations,
borderColor: "#8B6914", borderColor: "#8B6914",
backgroundColor: "rgba(139, 105, 20, 0.3)", backgroundColor: "rgba(139, 105, 20, 0.4)",
fill: true, fill: "-1",
pointRadius: 0, pointRadius: 0,
borderWidth: 1.5, borderWidth: 1.5,
tension: 0.1, tension: 0.1,
order: 3 order: 4
}, },
{ {
label: "Line of Sight", label: "Line of Sight",
@ -198,7 +277,6 @@ export const ElevationProfile: ElevationProfileHook = {
order: 2 order: 2
} }
] : []), ] : []),
// Invisible dataset for duct legend entry
...(ducts.length > 0 ? [{ ...(ducts.length > 0 ? [{
label: "Duct Layer", label: "Duct Layer",
data: [] as number[], data: [] as number[],
@ -214,15 +292,15 @@ export const ElevationProfile: ElevationProfileHook = {
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
animation: {duration: 300}, animation: { duration: 300 },
plugins: { plugins: {
ductBands: {ducts}, ductBands: { ducts, earthCurve },
legend: { legend: {
display: true, display: true,
position: "top", position: "top",
labels: { labels: {
boxWidth: 12, boxWidth: 12,
font: {size: 10}, font: { size: 10 },
filter: (item: { text: string }) => !item.text.startsWith("_") filter: (item: { text: string }) => !item.text.startsWith("_")
} }
}, },
@ -231,6 +309,7 @@ export const ElevationProfile: ElevationProfileHook = {
title: (items: { label: string }[]) => items[0].label + " mi", title: (items: { label: string }[]) => items[0].label + " mi",
label: (item: { dataset: { label: string }; raw: number }) => { label: (item: { dataset: { label: string }; raw: number }) => {
if (item.dataset.label.startsWith("_")) return null if (item.dataset.label.startsWith("_")) return null
if (item.dataset.label === "Earth") return null
return item.dataset.label + ": " + Math.round(item.raw) + " ft" return item.dataset.label + ": " + Math.round(item.raw) + " ft"
} }
} }
@ -238,7 +317,7 @@ export const ElevationProfile: ElevationProfileHook = {
}, },
scales: { scales: {
x: { x: {
title: {display: true, text: "Distance (mi)", font: {size: 10}}, title: { display: true, text: "Distance (mi)", font: { size: 10 } },
afterBuildTicks: (axis: { ticks: { value: number }[] }) => { afterBuildTicks: (axis: { ticks: { value: number }[] }) => {
const last = distances.length - 1 const last = distances.length - 1
const totalMi = distances[last] const totalMi = distances[last]
@ -246,22 +325,22 @@ export const ElevationProfile: ElevationProfileHook = {
const ticks: { value: number }[] = [] const ticks: { value: number }[] = []
for (let mi = 0; mi < totalMi; mi += step) { for (let mi = 0; mi < totalMi; mi += step) {
const idx = distances.findIndex(d => d >= mi) const idx = distances.findIndex(d => d >= mi)
if (idx >= 0) ticks.push({value: idx}) if (idx >= 0) ticks.push({ value: idx })
} }
ticks.push({value: last}) ticks.push({ value: last })
axis.ticks = ticks axis.ticks = ticks
}, },
ticks: { ticks: {
font: {size: 9}, font: { size: 9 },
autoSkip: false, autoSkip: false,
callback: (val: number) => distances[val] !== undefined ? Math.round(distances[val]) : "" callback: (val: number) => distances[val] !== undefined ? Math.round(distances[val]) : ""
} }
}, },
y: { y: {
title: {display: true, text: "Elevation (ft)", font: {size: 10}}, title: { display: true, text: "Elevation (ft MSL)", font: { size: 10 } },
ticks: {font: {size: 9}}, ticks: { font: { size: 9 } },
min: Math.max(0, minElev), min: Math.max(0, minY),
max: maxElev max: maxY
} }
} }
} }