prop/assets/js/elevation_profile_hook.ts
Graham McIntire 1f368f9b61 Convert all JavaScript to TypeScript with type annotations
- Rename all .js files to .ts in assets/js/
- Add interfaces for hook state, data structures, and event payloads
- Add type annotations to function parameters and return types
- Create type declarations for vendor deps (Leaflet, Chart.js, Phoenix, topbar)
- Update tsconfig.json for strict TypeScript checking
- Update esbuild entry point to app.ts
2026-04-11 16:59:28 -05:00

277 lines
8.4 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
strength: number
source: DuctSource
likely: boolean
}
interface ProfileData {
points: ProfilePoint[]
freq_mhz: number
k_factor?: number
dist_km?: number
ducts?: DuctRaw[]
}
interface ChartWithAreas {
options: {
plugins: { ductBands?: { ducts: DuctBand[] } }
}
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
}
// Chart.js plugin to draw horizontal duct bands
const ductPlugin = {
id: "ductBands",
beforeDraw(chart: ChartWithAreas): void {
const ducts = chart.options.plugins.ductBands?.ducts
if (!ducts || ducts.length === 0) return
const {ctx, chartArea: {left, right, top, bottom}, scales: {y}} = chart
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])
ctx.beginPath()
ctx.moveTo(left, drawTop)
ctx.lineTo(right, drawTop)
ctx.moveTo(left, drawBase)
ctx.lineTo(right, drawBase)
ctx.stroke()
ctx.setLineDash([])
// Label
const labelY = Math.max(drawTop + 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" : ""
ctx.fillText(`${prefix} ${Math.round(duct.base_ft)}${Math.round(duct.top_ft)} 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 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 dTotal = (data.dist_km || 0) * 1000
const elevations = points.map((p, i) => {
const f = points.length > 1 ? i / (points.length - 1) : 0
const bulge = (f * dTotal * (1 - f) * dTotal) / (2 * k * R)
return (p.elev + bulge) * M2FT
})
const losLine = points.map(p => p.beam * M2FT)
const fresnelLower = points.map(p => (p.beam - p.r1) * M2FT)
const ducts: DuctBand[] = (data.ducts || []).map(d => ({
base_ft: d.base_m_msl * M2FT,
top_ft: d.top_m_msl * M2FT,
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
// 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 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: "Terrain",
data: elevations,
borderColor: "#8B6914",
backgroundColor: "rgba(139, 105, 20, 0.3)",
fill: true,
pointRadius: 0,
borderWidth: 1.5,
tension: 0.1,
order: 3
},
{
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
}
] : []),
// Invisible dataset for duct legend entry
...(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},
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
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,
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
}
}
}
})
},
destroyed(this: ElevationProfileHook) {
if (this.chart) {
this.chart.destroy()
this.chart = null
}
}
} as ElevationProfileHook