prop/assets/js/elevation_profile_hook.js
Graham McIntire 1da1cbed43
Fix nil access bugs found in security/bug audit
- TerrainProfileWorker: guard pos1/pos2 nil before accessing lat/lon,
  mark unavailable if missing instead of crashing
- Propagation analysis: guard pos1 nil in compute_factors
- contact_path_points: return [] instead of [{nil, nil}] on missing coords
- JS elevation hook: null check canvas before getContext
2026-04-02 12:38:49 -05:00

220 lines
6.6 KiB
JavaScript
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
// Chart.js plugin to draw horizontal duct bands
const ductPlugin = {
id: "ductBands",
beforeDraw(chart) {
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 = {hrrr: "Duct", sounding: "Duct (sounding)", inferred: "Est. duct"}
const units = {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 = {
mounted() {
const data = JSON.parse(this.el.dataset.profile)
if (!data || !data.points || data.points.length === 0) return
this.renderChart(data)
},
renderChart(data) {
const points = data.points
const distances = points.map(p => p.dist_km * KM2MI)
const elevations = points.map(p => p.elev * M2FT)
const losLine = points.map(p => p.beam * M2FT)
const fresnelLower = points.map(p => (p.beam - p.r1) * M2FT)
const showFresnel = data.freq_mhz >= 1000
const ducts = (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")
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: [],
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 => !item.text.startsWith("_")
}
},
tooltip: {
callbacks: {
title: items => items[0].label + " mi",
label: item => {
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 => {
const last = distances.length - 1
const totalMi = distances[last]
const step = Math.ceil(totalMi / 7) || 1
const ticks = []
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 => 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() {
if (this.chart) {
this.chart.destroy()
this.chart = null
}
}
}