Curved earth surface in elevation profile with ducts following curvature
This commit is contained in:
parent
d99b7a6ea2
commit
58e4f55ba8
1 changed files with 127 additions and 48 deletions
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue