prop/assets/js/elevation_profile_hook.js
Graham McIntire cd8d7a0f52
Add contact detail map, elevation profile chart, and UI improvements
- Leaflet map on contact detail pages showing both station locations
- SRTM elevation profile with Chart.js: terrain, LOS, Fresnel zone
- Path info: distance, azimuth, elevation angle in both directions
- Compact 4-column grid layout for contact details
- Contact map page with all contacts plotted
- Rename QSOs to Contacts in page header
- Wrap algo page in standard layout
2026-04-01 12:34:03 -05:00

139 lines
3.9 KiB
JavaScript

import Chart from "../vendor/chart.js"
const M2FT = 3.28084
const KM2MI = 0.621371
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 allElevs = [...elevations, ...losLine]
const minElev = Math.min(...allElevs) - 60
const maxElev = Math.max(...allElevs) + 120
const canvas = this.el.querySelector("canvas")
const ctx = canvas.getContext("2d")
this.chart = new Chart(ctx, {
type: "line",
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
}
] : [])
]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: {duration: 300},
plugins: {
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
}
}
}