- 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
277 lines
8.4 KiB
TypeScript
277 lines
8.4 KiB
TypeScript
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
|