prop/assets/js/elevation_profile_hook.ts
Graham McIntire 4f647e7894
feat(canopy): add tree-canopy height layer for path obstruction analysis
Adds Microwaveprop.Canopy module that reads 1° × 1° uint8 per-degree
canopy-height tiles (mirrors SRTM .hgt naming/layout, 30 m resolution).
Returns 0 m for areas with no tile on disk so the lookup is safe to
thread through the existing path-clearance pipeline before any data
lands.

Wires canopy into:
  - PathTerrain.obstacle_top: per-sample blocker = ground +
    max(building_m, canopy_m), so the rover ranks paths through forests
    as obstructed even when no buildings sit on the line
  - CandidateDetail profile: each sample now carries canopy_m alongside
    building_m
  - Rover-detail SVG: green "trees" polygon stacked on terrain, under
    the red building polygon
  - Path calculator elevation chart: green Tree Canopy dataset between
    terrain and buildings

Data prep (download Potapov 2019 / Lang 2022 GeoTIFF, slice to per-degree
uint8 tiles, drop in /data/canopy/) is a separate one-shot operation —
without tiles, lookups return 0 and the existing behavior is unchanged.
2026-04-26 12:51:48 -05:00

420 lines
14 KiB
TypeScript
Raw Permalink 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
building_m?: number
canopy_m?: 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
})
// Buildings: terrain + max nearby building height. Same earth bulge so the
// dataset stacks correctly on top of terrain.
const hasBuildings = points.some(p => (p.building_m || 0) > 0.5)
const buildingTops = points.map((p, i) => {
const f = n > 0 ? i / n : 0
const bulge = (f * dTotal * (1 - f) * dTotal) / (2 * k * R)
return (p.elev + (p.building_m || 0) + bulge) * M2FT
})
// Canopy: terrain + nearby tree-canopy height (GEDI/Potapov 30m).
const hasCanopy = points.some(p => (p.canopy_m || 0) > 1)
const canopyTops = points.map((p, i) => {
const f = n > 0 ? i / n : 0
const bulge = (f * dTotal * (1 - f) * dTotal) / (2 * k * R)
return (p.elev + (p.canopy_m || 0) + 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,
...(hasBuildings ? buildingTops : []),
...(hasCanopy ? canopyTops : [])
]
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
},
...(hasCanopy ? [
{
label: "Tree Canopy",
data: canopyTops,
borderColor: "#16a34a",
backgroundColor: "rgba(22, 163, 74, 0.35)",
fill: "-1",
pointRadius: 0,
borderWidth: 1,
tension: 0.1,
order: 3
}
] : []),
...(hasBuildings ? [
{
label: "Buildings",
data: buildingTops,
borderColor: "#dc2626",
backgroundColor: "rgba(220, 38, 38, 0.45)",
fill: hasCanopy ? "-2" : "-1",
pointRadius: 0,
borderWidth: 1,
tension: 0.1,
order: 2
}
] : []),
{
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