From 58e4f55ba812e4822209df3b63789a1a59b7ec1d Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 11 Apr 2026 17:10:38 -0500 Subject: [PATCH] Curved earth surface in elevation profile with ducts following curvature --- assets/js/elevation_profile_hook.ts | 175 ++++++++++++++++++++-------- 1 file changed, 127 insertions(+), 48 deletions(-) diff --git a/assets/js/elevation_profile_hook.ts b/assets/js/elevation_profile_hook.ts index 0a289f20..0d201410 100644 --- a/assets/js/elevation_profile_hook.ts +++ b/assets/js/elevation_profile_hook.ts @@ -23,6 +23,8 @@ 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 @@ -36,9 +38,21 @@ interface ProfileData { ducts?: DuctRaw[] } +interface EarthCurveParams { + dTotal: number + k: number + R: number + minElev: number +} + interface ChartWithAreas { options: { - plugins: { ductBands?: { ducts: DuctBand[] } } + plugins: { + ductBands?: { + ducts: DuctBand[] + earthCurve: EarthCurveParams + } + } } ctx: CanvasRenderingContext2D chartArea: { left: number; right: number; top: number; bottom: number } @@ -50,55 +64,93 @@ interface ElevationProfileHook extends ViewHook { 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 = { id: "ductBands", beforeDraw(chart: ChartWithAreas): void { - const ducts = chart.options.plugins.ductBands?.ducts - if (!ducts || ducts.length === 0) return + 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 { ctx, chartArea: { left, right, top, bottom }, scales: { y } } = chart + const chartWidth = right - left ctx.save() 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 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.fillRect(left, drawTop, right - left, drawBase - drawTop) - 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() - ctx.moveTo(left, drawTop) - ctx.lineTo(right, drawTop) - ctx.moveTo(left, drawBase) - ctx.lineTo(right, drawBase) + 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 - const labelY = Math.max(drawTop + 12, top + 12) + // 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 = {hrrr: "Duct", sounding: "Duct (sounding)", inferred: "Est. duct"} - const units: Record = {hrrr: "M-units", sounding: "M-units", inferred: "dN/dh÷10"} + const labels: Record = { hrrr: "Duct", sounding: "Duct (sounding)", inferred: "Est. duct" } + const units: Record = { 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" : "" - 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() } @@ -113,37 +165,53 @@ export const ElevationProfile: ElevationProfileHook = { 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 - // 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 R = 6371000 // earth radius in meters + 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 = 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) 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] - let minElev = Math.min(...allElevs) - 60 - let maxElev = Math.max(...allElevs) + 120 + const allElevs = [...elevations, ...losLine, ...earthSurface] + let minY = Math.min(...allElevs) - 40 + let maxY = Math.max(...allElevs) + 120 - // Extend Y axis to include duct tops if needed 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 @@ -156,16 +224,27 @@ export const ElevationProfile: ElevationProfileHook = { 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.3)", - fill: true, + backgroundColor: "rgba(139, 105, 20, 0.4)", + fill: "-1", pointRadius: 0, borderWidth: 1.5, tension: 0.1, - order: 3 + order: 4 }, { label: "Line of Sight", @@ -198,7 +277,6 @@ export const ElevationProfile: ElevationProfileHook = { order: 2 } ] : []), - // Invisible dataset for duct legend entry ...(ducts.length > 0 ? [{ label: "Duct Layer", data: [] as number[], @@ -214,15 +292,15 @@ export const ElevationProfile: ElevationProfileHook = { options: { responsive: true, maintainAspectRatio: false, - animation: {duration: 300}, + animation: { duration: 300 }, plugins: { - ductBands: {ducts}, + ductBands: { ducts, earthCurve }, legend: { display: true, position: "top", labels: { boxWidth: 12, - font: {size: 10}, + font: { size: 10 }, filter: (item: { text: string }) => !item.text.startsWith("_") } }, @@ -231,6 +309,7 @@ export const ElevationProfile: ElevationProfileHook = { 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" } } @@ -238,7 +317,7 @@ export const ElevationProfile: ElevationProfileHook = { }, scales: { x: { - title: {display: true, text: "Distance (mi)", font: {size: 10}}, + title: { display: true, text: "Distance (mi)", font: { size: 10 } }, afterBuildTicks: (axis: { ticks: { value: number }[] }) => { const last = distances.length - 1 const totalMi = distances[last] @@ -246,22 +325,22 @@ export const ElevationProfile: ElevationProfileHook = { 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}) + if (idx >= 0) ticks.push({ value: idx }) } - ticks.push({value: last}) + ticks.push({ value: last }) axis.ticks = ticks }, ticks: { - font: {size: 9}, + font: { size: 9 }, autoSkip: false, callback: (val: number) => distances[val] !== undefined ? Math.round(distances[val]) : "" } }, y: { - title: {display: true, text: "Elevation (ft)", font: {size: 10}}, - ticks: {font: {size: 9}}, - min: Math.max(0, minElev), - max: maxElev + title: { display: true, text: "Elevation (ft MSL)", font: { size: 10 } }, + ticks: { font: { size: 9 } }, + min: Math.max(0, minY), + max: maxY } } }