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 = { 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" : "" 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