prop/assets/js/elevation_profile_hook.js
Graham McIntire 5f8c689035
Improve duct detection and compact contact detail layout
- Use all HRRR profiles along path (start, mid, end) for duct detection
- Fall back to sounding ducts when HRRR too coarse to detect layers
- Merge overlapping ducts, show top 3 strongest with source labels
- Limit surface observations to 5 closest stations along path
- Compact solar conditions as inline row
- Compact band/mode/distance info line
- Fix button text to "Flag as Invalid"
2026-04-01 13:28:42 -05:00

211 lines
6.2 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
ctx.fillStyle = "rgba(34, 197, 94, 0.12)"
ctx.fillRect(left, drawTop, right - left, drawBase - drawTop)
ctx.strokeStyle = "rgba(34, 197, 94, 0.5)"
ctx.lineWidth = 1
ctx.setLineDash([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)
ctx.fillStyle = "rgba(34, 197, 94, 0.8)"
ctx.font = "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"
ctx.fillText(`${prefix} ${Math.round(duct.base_ft)}${Math.round(duct.top_ft)} ft (${duct.strength} ${unit})`, 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"
}))
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")
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
}
}
}