705 lines
28 KiB
JavaScript
705 lines
28 KiB
JavaScript
// HF skip / MUF explainer. Vanilla JS + 2D canvas. Scoped to one post.
|
||
(() => {
|
||
'use strict';
|
||
|
||
const C = {
|
||
bg: '#1a1d24',
|
||
panel: '#242932',
|
||
fg: '#d8dce4',
|
||
dim: '#8189a0',
|
||
grid: '#2d323d',
|
||
gridStrong: '#3a414e',
|
||
blue: '#78b5f3',
|
||
cyan: '#6dc5d3',
|
||
green: '#9dca83',
|
||
amber: '#d6a86a',
|
||
red: '#e58089',
|
||
yellow: '#e2c37d',
|
||
magenta: '#c78de0',
|
||
};
|
||
|
||
// ---- framework ----
|
||
function setupCanvas(canvas) {
|
||
const ctx = canvas.getContext('2d');
|
||
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
||
const fit = () => {
|
||
const rect = canvas.getBoundingClientRect();
|
||
canvas.width = Math.round(rect.width * dpr);
|
||
canvas.height = Math.round(rect.height * dpr);
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
ctx.lineCap = 'round';
|
||
ctx.lineJoin = 'round';
|
||
};
|
||
fit();
|
||
new ResizeObserver(fit).observe(canvas);
|
||
return { ctx, getSize: () => ({ w: canvas.clientWidth, h: canvas.clientHeight }) };
|
||
}
|
||
|
||
function scene(id, { height = 360, controls = [], readout = [], caption = '', threeCol = false } = {}) {
|
||
const root = document.getElementById(id);
|
||
if (!root) return null;
|
||
root.classList.add('scene-box');
|
||
|
||
const canvas = document.createElement('canvas');
|
||
canvas.style.width = '100%';
|
||
canvas.style.height = `${height}px`;
|
||
root.appendChild(canvas);
|
||
|
||
const readoutEl = document.createElement('div');
|
||
readoutEl.className = 'scene-readout';
|
||
const readoutSpans = {};
|
||
readout.forEach(r => {
|
||
const item = document.createElement('span');
|
||
item.className = 'scene-readout-item';
|
||
const label = document.createElement('span'); label.textContent = r.label + ':';
|
||
const val = document.createElement('span'); val.textContent = r.init ?? '';
|
||
item.appendChild(label); item.appendChild(val);
|
||
readoutEl.appendChild(item);
|
||
readoutSpans[r.key] = val;
|
||
});
|
||
if (readout.length) root.appendChild(readoutEl);
|
||
|
||
const controlsEl = document.createElement('div');
|
||
controlsEl.className = 'scene-controls' + (threeCol ? ' scene-controls--three' : '');
|
||
const values = {};
|
||
const inputs = {};
|
||
controls.forEach(c => {
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'scene-control';
|
||
const label = document.createElement('label');
|
||
const name = document.createElement('span'); name.textContent = c.label;
|
||
const valSpan = document.createElement('span'); valSpan.className = 'scene-value';
|
||
label.appendChild(name); label.appendChild(valSpan);
|
||
const input = document.createElement('input');
|
||
input.type = 'range';
|
||
input.min = c.min; input.max = c.max; input.step = c.step ?? 'any';
|
||
input.value = c.value;
|
||
values[c.key] = parseFloat(input.value);
|
||
const fmt = c.format || (v => v.toFixed(2));
|
||
valSpan.textContent = fmt(values[c.key]);
|
||
input.addEventListener('input', () => {
|
||
values[c.key] = parseFloat(input.value);
|
||
valSpan.textContent = fmt(values[c.key]);
|
||
});
|
||
inputs[c.key] = { input, valSpan, fmt };
|
||
wrap.appendChild(label); wrap.appendChild(input);
|
||
controlsEl.appendChild(wrap);
|
||
});
|
||
if (controls.length) root.appendChild(controlsEl);
|
||
|
||
if (caption) {
|
||
const cap = document.createElement('div');
|
||
cap.className = 'scene-caption';
|
||
cap.textContent = caption;
|
||
root.appendChild(cap);
|
||
}
|
||
|
||
const { ctx, getSize } = setupCanvas(canvas);
|
||
const setReadout = (key, text) => {
|
||
if (readoutSpans[key]) readoutSpans[key].textContent = text;
|
||
};
|
||
const setControl = (key, v) => {
|
||
const c = inputs[key];
|
||
if (!c) return;
|
||
c.input.value = v;
|
||
values[key] = parseFloat(c.input.value);
|
||
c.valSpan.textContent = c.fmt(values[key]);
|
||
};
|
||
return { canvas, ctx, getSize, values, setReadout, setControl, root };
|
||
}
|
||
|
||
function clear(ctx, w, h) { ctx.clearRect(0, 0, w, h); }
|
||
function text(ctx, str, x, y, color = C.fg, align = 'left', baseline = 'alphabetic', size = 12, family = 'system-ui, sans-serif') {
|
||
ctx.save();
|
||
ctx.fillStyle = color;
|
||
ctx.font = `${size}px ${family}`;
|
||
ctx.textAlign = align;
|
||
ctx.textBaseline = baseline;
|
||
ctx.fillText(str, x, y);
|
||
ctx.restore();
|
||
}
|
||
function roundRect(ctx, x, y, w, h, r) {
|
||
ctx.beginPath();
|
||
if (ctx.roundRect) ctx.roundRect(x, y, w, h, r);
|
||
else ctx.rect(x, y, w, h);
|
||
}
|
||
function pillLabel(ctx, str, x, y, color = C.fg, align = 'left', baseline = 'middle', size = 12) {
|
||
ctx.save();
|
||
ctx.font = `${size}px system-ui, sans-serif`;
|
||
ctx.textAlign = align;
|
||
ctx.textBaseline = baseline;
|
||
const tw = ctx.measureText(str).width;
|
||
const padX = 5, padY = 3;
|
||
const bh = size + 2 + padY * 2;
|
||
let bx;
|
||
if (align === 'right') bx = x - tw - padX;
|
||
else if (align === 'center') bx = x - tw / 2 - padX;
|
||
else bx = x - padX;
|
||
let by;
|
||
if (baseline === 'middle') by = y - bh / 2;
|
||
else if (baseline === 'top') by = y - padY;
|
||
else by = y - size - padY;
|
||
ctx.fillStyle = 'rgba(26, 29, 36, 0.85)';
|
||
roundRect(ctx, bx, by, tw + padX * 2, bh, 3); ctx.fill();
|
||
ctx.fillStyle = color;
|
||
ctx.fillText(str, x, y);
|
||
ctx.restore();
|
||
}
|
||
|
||
// ---- ionospheric model (simplified but qualitatively correct) ----
|
||
//
|
||
// Everything below is approximate. The goal is a visualization that tells
|
||
// the right story when the sliders move, not a geophysics textbook. Real
|
||
// models (IRI, NeQuick) involve several thousand lines of Fortran and an
|
||
// entire career.
|
||
|
||
// hour in 0..24; sun at horizon at 6 and 18, zenith at 12 (local solar time)
|
||
function sunElevation(hour) {
|
||
const a = (hour - 12) / 12 * Math.PI / 2; // -pi/2 at 6, 0 at noon, pi/2 at 18
|
||
return Math.cos(a); // 1 at noon, 0 at 6 and 18, negative in the night
|
||
}
|
||
|
||
// layer density (peak electrons/cm^3), crude time-and-solar-activity model
|
||
function layerDensity(name, hour, sfu) {
|
||
// sfu: solar flux proxy, 60 (quiet) .. 300 (very active)
|
||
const sun = sunElevation(hour);
|
||
const day = Math.max(0, sun);
|
||
const s = (sfu - 60) / 240; // 0..1
|
||
switch (name) {
|
||
case 'D': return 1e4 * day * day * (0.7 + 0.6 * s); // collapses at night
|
||
case 'E': return 1.5e5 * Math.max(0, day) * (0.7 + 0.6 * s);
|
||
case 'F1': return day > 0.1 ? 3e5 * day * (0.7 + 0.6 * s) : 0;
|
||
case 'F2': {
|
||
// F2 survives the night but at reduced density
|
||
const nightFloor = 2e5 * (0.6 + 0.8 * s);
|
||
const daypeak = 2.2e6 * (0.35 + 1.4 * s);
|
||
return nightFloor + (daypeak - nightFloor) * Math.max(0, day);
|
||
}
|
||
}
|
||
return 0;
|
||
}
|
||
// critical frequency in MHz from density (N in electrons/cm^3)
|
||
function critFreqMHz(N) { return 9e-3 * Math.sqrt(Math.max(0, N)); }
|
||
// MUF for a given takeoff angle, using Earth curvature so the secant
|
||
// factor saturates around 3-3.6 instead of blowing up at low angles.
|
||
// sin(theta_i) = (R / (R + h)) * cos(takeoff)
|
||
function mufMHz(fc, takeoffDeg, hKm = 350) {
|
||
const R = 6371;
|
||
const alpha = takeoffDeg * Math.PI / 180;
|
||
const sinThetaI = (R / (R + hKm)) * Math.cos(alpha);
|
||
const cosThetaI = Math.sqrt(Math.max(0.001, 1 - sinThetaI * sinThetaI));
|
||
return fc / cosThetaI;
|
||
}
|
||
|
||
// given a frequency and a set of layer densities, find the first layer that
|
||
// can refract the ray at a given takeoff angle; return the layer name and
|
||
// altitude in km, or null if all layers are transparent to it
|
||
const LAYERS = [
|
||
{ name: 'D', alt: 75 },
|
||
{ name: 'E', alt: 110 },
|
||
{ name: 'F1', alt: 220 },
|
||
{ name: 'F2', alt: 350 },
|
||
];
|
||
|
||
function refractAt(freq, takeoffDeg, hour, sfu) {
|
||
// D layer doesn't refract HF, it absorbs. Check absorption separately.
|
||
for (const L of LAYERS) {
|
||
if (L.name === 'D') continue;
|
||
const N = layerDensity(L.name, hour, sfu);
|
||
const fc = critFreqMHz(N);
|
||
const muf = mufMHz(fc, takeoffDeg, L.alt);
|
||
if (freq <= muf) return { layer: L.name, alt: L.alt, fc, muf };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// D-layer one-way absorption in dB, roughly proportional to density / f^2.
|
||
function dAbsorptionDb(freq, hour, sfu) {
|
||
const N = layerDensity('D', hour, sfu);
|
||
// empirical-ish coefficient so daytime 7 MHz sees ~20 dB of D-layer loss
|
||
return (N / 1e4) * 12 / (freq * freq);
|
||
}
|
||
|
||
// ============================================================
|
||
// Scene 1: Ionospheric layers with time of day and solar cycle
|
||
// ============================================================
|
||
function sceneLayers() {
|
||
const s = scene('scene-layers', {
|
||
height: 380,
|
||
controls: [
|
||
{ key: 'hour', label: 'Local solar time (hours)', min: 0, max: 24, value: 12, step: 0.1, format: v => {
|
||
const h = Math.floor(v); const m = Math.round((v - h) * 60);
|
||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||
} },
|
||
{ key: 'sfu', label: 'Solar flux (SFU, proxy for sunspots)', min: 60, max: 300, value: 140, step: 1, format: v => v.toFixed(0) },
|
||
{ key: 'play', label: 'Auto-advance time', min: 0, max: 1, value: 0, step: 1, format: v => v > 0.5 ? 'on' : 'off' },
|
||
],
|
||
readout: [
|
||
{ key: 'fD', label: 'fc(D)' },
|
||
{ key: 'fE', label: 'fc(E)' },
|
||
{ key: 'fF1', label: 'fc(F1)' },
|
||
{ key: 'fF2', label: 'fc(F2)' },
|
||
],
|
||
caption: 'Drag the time-of-day slider and watch the layers breathe. D collapses after sunset; E and F1 follow. F2 survives the night at reduced density, which is why 40 m is a nighttime DX band and 10 m is not. Solar flux scales the whole thing; at SFU 60 the ionosphere is anemic and at SFU 300 it is lit up.',
|
||
threeCol: true,
|
||
});
|
||
if (!s) return;
|
||
|
||
let last = performance.now();
|
||
|
||
function draw(ts) {
|
||
const dt = Math.min(100, ts - last);
|
||
last = ts;
|
||
if (s.values.play > 0.5) {
|
||
let h = s.values.hour + dt / 1000 * 0.5; // 0.5 hour per real second
|
||
if (h >= 24) h -= 24;
|
||
s.setControl('hour', h);
|
||
}
|
||
|
||
const { w, h } = s.getSize();
|
||
const ctx = s.ctx;
|
||
clear(ctx, w, h);
|
||
|
||
const m = { l: 50, r: 150, t: 20, b: 46 };
|
||
const pX = m.l, pY = m.t, pW = w - m.l - m.r, pH = h - m.t - m.b;
|
||
const altMax = 500;
|
||
const yOf = km => pY + pH - (km / altMax) * pH;
|
||
|
||
// sky gradient based on sun elevation
|
||
const sun = sunElevation(s.values.hour);
|
||
const dayness = Math.max(0, sun);
|
||
const skyTop = `rgba(${20 + 40 * dayness}, ${30 + 60 * dayness}, ${55 + 90 * dayness}, 1)`;
|
||
const skyBot = `rgba(${60 + 120 * dayness}, ${40 + 90 * dayness}, ${60 + 40 * dayness}, 1)`;
|
||
const gradSky = ctx.createLinearGradient(0, pY, 0, pY + pH);
|
||
gradSky.addColorStop(0, skyTop);
|
||
gradSky.addColorStop(1, skyBot);
|
||
ctx.fillStyle = gradSky;
|
||
ctx.fillRect(pX, pY, pW, pH);
|
||
|
||
// altitude ticks
|
||
for (const km of [100, 200, 300, 400, 500]) {
|
||
const y = yOf(km);
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath(); ctx.moveTo(pX, y); ctx.lineTo(pX + pW, y); ctx.stroke();
|
||
text(ctx, km + ' km', pX - 6, y, C.dim, 'right', 'middle', 11);
|
||
}
|
||
|
||
// draw each layer as a translucent band, thickness set by density
|
||
function drawLayer(name, lo, hi, color) {
|
||
const N = layerDensity(name, s.values.hour, s.values.sfu);
|
||
const peak = { D: 2e4, E: 3e5, F1: 4e5, F2: 4e6 }[name];
|
||
const a = Math.min(1, N / peak);
|
||
if (a < 0.02) return;
|
||
const y0 = yOf(hi), y1 = yOf(lo);
|
||
const g = ctx.createLinearGradient(0, y0, 0, y1);
|
||
g.addColorStop(0, `rgba(${color}, 0)`);
|
||
g.addColorStop(0.5, `rgba(${color}, ${0.25 * a + 0.05})`);
|
||
g.addColorStop(1, `rgba(${color}, 0)`);
|
||
ctx.fillStyle = g;
|
||
ctx.fillRect(pX, y0, pW, y1 - y0);
|
||
|
||
// a subtle noisy shimmer on the peak
|
||
ctx.save();
|
||
ctx.globalAlpha = 0.35 * a;
|
||
ctx.fillStyle = `rgba(${color}, 0.4)`;
|
||
const peakY = yOf((lo + hi) / 2);
|
||
for (let i = 0; i < 40; i++) {
|
||
const xx = pX + (i / 40) * pW;
|
||
const yy = peakY + Math.sin(i * 0.8 + performance.now() / 800) * 1.5;
|
||
ctx.fillRect(xx, yy, pW / 40 - 1, 0.8);
|
||
}
|
||
ctx.restore();
|
||
|
||
// label at right
|
||
pillLabel(ctx, `${name} fc=${critFreqMHz(N).toFixed(1)} MHz`,
|
||
pX + pW + 6, (y0 + y1) / 2, C.fg, 'left', 'middle', 11);
|
||
}
|
||
drawLayer('F2', 300, 500, '255, 180, 120');
|
||
drawLayer('F1', 150, 300, '255, 220, 140');
|
||
drawLayer('E', 90, 150, '140, 220, 255');
|
||
drawLayer('D', 60, 90, '235, 120, 140');
|
||
|
||
// ground
|
||
ctx.fillStyle = '#2a2f3a';
|
||
ctx.fillRect(pX, pY + pH, pW, m.b);
|
||
ctx.strokeStyle = '#3a414e';
|
||
ctx.beginPath(); ctx.moveTo(pX, pY + pH); ctx.lineTo(pX + pW, pY + pH); ctx.stroke();
|
||
|
||
// sun arc
|
||
const cx = pX + pW * 0.5, cy = pY + pH - 4, rSun = pW * 0.42;
|
||
ctx.save();
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
|
||
ctx.setLineDash([3, 4]);
|
||
ctx.beginPath(); ctx.arc(cx, cy, rSun, Math.PI, 2 * Math.PI); ctx.stroke();
|
||
ctx.restore();
|
||
const sunAng = Math.PI + (s.values.hour / 24) * 2 * Math.PI;
|
||
const sx = cx + Math.cos(sunAng) * rSun;
|
||
const sy = cy + Math.sin(sunAng) * rSun;
|
||
ctx.beginPath();
|
||
ctx.fillStyle = sun > 0 ? '#f5d37b' : '#566';
|
||
ctx.arc(sx, sy, 7, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
if (sun > 0) {
|
||
ctx.save();
|
||
ctx.globalAlpha = 0.25;
|
||
ctx.beginPath(); ctx.arc(sx, sy, 14, 0, Math.PI * 2);
|
||
ctx.fillStyle = '#f5d37b'; ctx.fill();
|
||
ctx.restore();
|
||
}
|
||
|
||
// time axis
|
||
for (const h2 of [0, 6, 12, 18, 24]) {
|
||
const ang = Math.PI + (h2 / 24) * 2 * Math.PI;
|
||
const x2 = cx + Math.cos(ang) * rSun;
|
||
const y2 = cy + Math.sin(ang) * rSun;
|
||
text(ctx, String(h2).padStart(2, '0'), x2, pY + pH + 14, C.dim, 'center', 'top', 10);
|
||
}
|
||
|
||
// readout
|
||
s.setReadout('fD', critFreqMHz(layerDensity('D', s.values.hour, s.values.sfu)).toFixed(2) + ' MHz');
|
||
s.setReadout('fE', critFreqMHz(layerDensity('E', s.values.hour, s.values.sfu)).toFixed(2) + ' MHz');
|
||
s.setReadout('fF1', critFreqMHz(layerDensity('F1', s.values.hour, s.values.sfu)).toFixed(2) + ' MHz');
|
||
s.setReadout('fF2', critFreqMHz(layerDensity('F2', s.values.hour, s.values.sfu)).toFixed(2) + ' MHz');
|
||
|
||
requestAnimationFrame(draw);
|
||
}
|
||
requestAnimationFrame(draw);
|
||
}
|
||
|
||
// ============================================================
|
||
// Scene 2: Ray fan, skip zone, MUF
|
||
// ============================================================
|
||
function sceneRays() {
|
||
const s = scene('scene-rays', {
|
||
height: 420,
|
||
controls: [
|
||
{ key: 'freq', label: 'Operating frequency (MHz)', min: 1.8, max: 50, value: 14, step: 0.1, format: v => v.toFixed(1) + ' MHz' },
|
||
{ key: 'hour', label: 'Local solar time (hours)', min: 0, max: 24, value: 12, step: 0.1, format: v => {
|
||
const h = Math.floor(v); const m = Math.round((v - h) * 60);
|
||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||
} },
|
||
{ key: 'sfu', label: 'Solar flux (SFU)', min: 60, max: 300, value: 140, step: 1, format: v => v.toFixed(0) },
|
||
],
|
||
readout: [
|
||
{ key: 'muf', label: 'MUF (F2, low angle)' },
|
||
{ key: 'skip', label: 'Skip distance' },
|
||
{ key: 'abs', label: 'D absorption' },
|
||
{ key: 'band', label: 'Band' },
|
||
],
|
||
caption: 'Rays launch at every takeoff angle from 5° to 85°. Each one finds the lowest layer that can bend it back, or punches through if every layer is too thin for its frequency. The dashed rays are the ones that escape; the solid ones land somewhere, drawing the skip zone as the dead gap between transmitter and the first place the band is audible.',
|
||
threeCol: true,
|
||
});
|
||
if (!s) return;
|
||
|
||
function bandName(f) {
|
||
if (f < 2) return '160 m'; if (f < 4) return '80 m'; if (f < 5.5) return '60 m';
|
||
if (f < 7.5) return '40 m'; if (f < 11) return '30 m'; if (f < 15.5) return '20 m';
|
||
if (f < 19.5) return '17 m'; if (f < 22.5) return '15 m'; if (f < 26) return '12 m';
|
||
if (f < 30) return '10 m'; if (f < 55) return '6 m'; return '?';
|
||
}
|
||
|
||
function draw() {
|
||
const { w, h } = s.getSize();
|
||
const ctx = s.ctx;
|
||
clear(ctx, w, h);
|
||
|
||
const m = { l: 60, r: 30, t: 20, b: 60 };
|
||
const pX = m.l, pY = m.t, pW = w - m.l - m.r, pH = h - m.t - m.b;
|
||
const altMax = 500;
|
||
const distMax = 4000; // km horizontally
|
||
const xOf = km => pX + (km / distMax) * pW;
|
||
const yOf = km => pY + pH - (km / altMax) * pH;
|
||
|
||
// background sky
|
||
const sun = sunElevation(s.values.hour);
|
||
const dayness = Math.max(0, sun);
|
||
const gradSky = ctx.createLinearGradient(0, pY, 0, pY + pH);
|
||
gradSky.addColorStop(0, `rgba(${15 + 40 * dayness}, ${22 + 60 * dayness}, ${45 + 90 * dayness}, 1)`);
|
||
gradSky.addColorStop(1, `rgba(${40 + 90 * dayness}, ${30 + 60 * dayness}, ${55 + 30 * dayness}, 1)`);
|
||
ctx.fillStyle = gradSky; ctx.fillRect(pX, pY, pW, pH);
|
||
|
||
// layers
|
||
function drawLayerBand(name, lo, hi, color) {
|
||
const N = layerDensity(name, s.values.hour, s.values.sfu);
|
||
const peak = { D: 2e4, E: 3e5, F1: 4e5, F2: 4e6 }[name];
|
||
const a = Math.min(1, N / peak);
|
||
if (a < 0.02) return;
|
||
ctx.fillStyle = `rgba(${color}, ${0.18 * a + 0.03})`;
|
||
ctx.fillRect(pX, yOf(hi), pW, yOf(lo) - yOf(hi));
|
||
}
|
||
drawLayerBand('F2', 300, 500, '255, 180, 120');
|
||
drawLayerBand('F1', 150, 300, '255, 220, 140');
|
||
drawLayerBand('E', 90, 150, '140, 220, 255');
|
||
drawLayerBand('D', 60, 90, '235, 120, 140');
|
||
|
||
// distance grid
|
||
for (let d = 0; d <= distMax; d += 500) {
|
||
const x = xOf(d);
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
|
||
ctx.beginPath(); ctx.moveTo(x, pY); ctx.lineTo(x, pY + pH); ctx.stroke();
|
||
text(ctx, d + ' km', x, pY + pH + 4, C.dim, 'center', 'top', 10);
|
||
}
|
||
for (const a of [100, 200, 300, 400]) {
|
||
const y = yOf(a);
|
||
text(ctx, a + ' km', pX - 4, y, C.dim, 'right', 'middle', 10);
|
||
}
|
||
// ground
|
||
ctx.fillStyle = '#2a2f3a';
|
||
ctx.fillRect(pX, pY + pH, pW, 2);
|
||
|
||
// tx at the left
|
||
const txX = xOf(0), txY = yOf(0);
|
||
ctx.fillStyle = C.cyan;
|
||
ctx.beginPath(); ctx.arc(txX, txY, 5, 0, Math.PI * 2); ctx.fill();
|
||
pillLabel(ctx, 'TX', txX, txY - 14, C.cyan, 'center', 'bottom', 11);
|
||
|
||
// for each takeoff angle: find refraction altitude, draw the ray
|
||
const freq = s.values.freq;
|
||
const dAbs = dAbsorptionDb(freq, s.values.hour, s.values.sfu);
|
||
let minLanding = Infinity, maxLanding = 0;
|
||
|
||
const angles = [];
|
||
for (let a = 5; a <= 85; a += 5) angles.push(a);
|
||
|
||
for (const angDeg of angles) {
|
||
const ref = refractAt(freq, angDeg, s.values.hour, s.values.sfu);
|
||
const angRad = angDeg * Math.PI / 180;
|
||
|
||
ctx.save();
|
||
ctx.lineWidth = 1.6;
|
||
if (ref) {
|
||
// bent by layer at altitude ref.alt
|
||
const altKm = ref.alt;
|
||
// landing distance = 2*h/tan(angle) (flat earth approximation)
|
||
const land = 2 * altKm / Math.tan(angRad);
|
||
minLanding = Math.min(minLanding, land);
|
||
maxLanding = Math.max(maxLanding, land);
|
||
// ray path: up to (land/2, altKm), then down to (land, 0). Draw as two segments with a quadratic bend at the apex.
|
||
const midX = xOf(land / 2);
|
||
const topY = yOf(altKm);
|
||
const endX = xOf(Math.min(distMax, land));
|
||
// loss-tint
|
||
const loss = 2 * dAbs; // up + down
|
||
const alpha = Math.max(0.15, 1 - loss / 30);
|
||
const col = ref.layer === 'F2' ? C.amber : ref.layer === 'F1' ? C.yellow : C.cyan;
|
||
ctx.strokeStyle = `rgba(${hexToRgb(col)}, ${alpha})`;
|
||
ctx.beginPath();
|
||
ctx.moveTo(txX, txY);
|
||
ctx.quadraticCurveTo(midX, topY - 12, endX, yOf(0));
|
||
ctx.stroke();
|
||
// landing marker
|
||
if (land <= distMax) {
|
||
ctx.fillStyle = col;
|
||
ctx.beginPath(); ctx.arc(xOf(land), yOf(0) - 2, 2.5, 0, Math.PI * 2); ctx.fill();
|
||
}
|
||
} else {
|
||
// escapes to space: straight line continuing until it leaves the box
|
||
const dx = Math.cos(angRad), dy = Math.sin(angRad);
|
||
// compute end where y reaches pY
|
||
const tToTop = (yOf(0) - pY) / (dy * (pH / altMax)); // km along ground dir
|
||
const endKm = tToTop; // horizontal km traveled when reaching top of plot
|
||
const endX = xOf(endKm);
|
||
const endY = pY;
|
||
ctx.strokeStyle = 'rgba(229, 128, 137, 0.55)';
|
||
ctx.setLineDash([4, 4]);
|
||
ctx.beginPath(); ctx.moveTo(txX, txY); ctx.lineTo(endX, endY); ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
// skip zone shading
|
||
if (isFinite(minLanding) && minLanding < distMax) {
|
||
ctx.save();
|
||
ctx.fillStyle = 'rgba(229, 128, 137, 0.1)';
|
||
ctx.fillRect(xOf(0), yOf(0), xOf(minLanding) - xOf(0), 2);
|
||
// hatch
|
||
ctx.strokeStyle = 'rgba(229, 128, 137, 0.2)';
|
||
for (let xk = 0; xk < minLanding; xk += 50) {
|
||
const x1 = xOf(xk); const x2 = xOf(Math.min(minLanding, xk + 30));
|
||
ctx.beginPath(); ctx.moveTo(x1, yOf(0) + 4); ctx.lineTo(x2, yOf(0) + 12); ctx.stroke();
|
||
}
|
||
pillLabel(ctx, `skip zone: 0 > ${Math.round(minLanding)} km`, xOf(minLanding / 2), yOf(0) + 20, C.red, 'center', 'middle', 10);
|
||
ctx.restore();
|
||
}
|
||
|
||
// readouts
|
||
const fF2 = critFreqMHz(layerDensity('F2', s.values.hour, s.values.sfu));
|
||
const mufLow = mufMHz(fF2, 5);
|
||
s.setReadout('muf', mufLow.toFixed(1) + ' MHz');
|
||
s.setReadout('skip', isFinite(minLanding) ? `${Math.round(minLanding)} – ${Math.round(Math.min(distMax, maxLanding))} km` : '(no skip)');
|
||
s.setReadout('abs', (dAbs * 2).toFixed(1) + ' dB (one hop)');
|
||
s.setReadout('band', bandName(freq));
|
||
|
||
requestAnimationFrame(draw);
|
||
}
|
||
requestAnimationFrame(draw);
|
||
}
|
||
|
||
function hexToRgb(hex) {
|
||
const h = hex.replace('#', '');
|
||
const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
|
||
return `${r}, ${g}, ${b}`;
|
||
}
|
||
|
||
// ============================================================
|
||
// Scene 3: Band clock
|
||
// ============================================================
|
||
function sceneBandClock() {
|
||
const s = scene('scene-bandclock', {
|
||
height: 460,
|
||
controls: [
|
||
{ key: 'sfu', label: 'Solar flux (SFU)', min: 60, max: 300, value: 140, step: 1, format: v => v.toFixed(0) },
|
||
{ key: 'hour', label: 'Hour marker', min: 0, max: 24, value: 12, step: 0.1, format: v => {
|
||
const h = Math.floor(v); const m = Math.round((v - h) * 60);
|
||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||
} },
|
||
],
|
||
readout: [
|
||
{ key: 'open', label: 'Open now (DX)' },
|
||
{ key: 'muf', label: 'F2 MUF now' },
|
||
],
|
||
caption: 'Every band, every hour of the day, under the current solar flux. Green rings are open for DX. Amber is local/short only. Red is where D-layer absorption is too expensive to use. Dark is closed. Spin the hour marker or change the sunspots and watch the pattern for the bands you actually operate.',
|
||
});
|
||
if (!s) return;
|
||
|
||
const bands = [
|
||
{ f: 1.9, name: '160 m' },
|
||
{ f: 3.7, name: '80 m' },
|
||
{ f: 7.1, name: '40 m' },
|
||
{ f: 14.2, name: '20 m' },
|
||
{ f: 21.2, name: '15 m' },
|
||
{ f: 28.4, name: '10 m' },
|
||
{ f: 50.2, name: '6 m' },
|
||
];
|
||
|
||
function bandStatus(freq, hour, sfu) {
|
||
// compute MUF using F2 at low angle
|
||
const fF2 = critFreqMHz(layerDensity('F2', hour, sfu));
|
||
const muf = mufMHz(fF2, 5);
|
||
const dAbs = dAbsorptionDb(freq, hour, sfu) * 2;
|
||
if (freq > muf) return 'closed';
|
||
if (dAbs > 25) return 'absorbed';
|
||
if (freq > muf * 0.85 || freq < 2.5) {
|
||
// near MUF or low bands with some absorption: short/local
|
||
if (dAbs > 6) return 'local';
|
||
}
|
||
// strong DX if between LUF and 0.85 MUF
|
||
if (freq > muf * 0.3 && freq < muf * 0.85 && dAbs < 12) return 'dx';
|
||
if (dAbs > 14) return 'absorbed';
|
||
return 'local';
|
||
}
|
||
|
||
function draw() {
|
||
const { w, h } = s.getSize();
|
||
const ctx = s.ctx;
|
||
clear(ctx, w, h);
|
||
|
||
const cx = w / 2, cy = h / 2;
|
||
const rOuter = Math.min(w, h) / 2 - 30;
|
||
const rInner = rOuter * 0.28;
|
||
const ringW = (rOuter - rInner) / bands.length;
|
||
|
||
// outer hour ring
|
||
for (let hr = 0; hr < 24; hr++) {
|
||
const a0 = -Math.PI / 2 + (hr / 24) * 2 * Math.PI;
|
||
const a1 = -Math.PI / 2 + ((hr + 1) / 24) * 2 * Math.PI;
|
||
const amid = (a0 + a1) / 2;
|
||
const tx = cx + Math.cos(amid) * (rOuter + 14);
|
||
const ty = cy + Math.sin(amid) * (rOuter + 14);
|
||
if (hr % 3 === 0) text(ctx, String(hr).padStart(2, '0'), tx, ty, C.dim, 'center', 'middle', 10);
|
||
}
|
||
|
||
// rings per band
|
||
for (let bi = 0; bi < bands.length; bi++) {
|
||
const band = bands[bi];
|
||
const r0 = rInner + bi * ringW;
|
||
const r1 = r0 + ringW - 3;
|
||
for (let hr = 0; hr < 24; hr++) {
|
||
const a0 = -Math.PI / 2 + (hr / 24) * 2 * Math.PI;
|
||
const a1 = -Math.PI / 2 + ((hr + 1) / 24) * 2 * Math.PI;
|
||
const status = bandStatus(band.f, hr + 0.5, s.values.sfu);
|
||
let color;
|
||
switch (status) {
|
||
case 'dx': color = '#9dca83'; break;
|
||
case 'local': color = '#d6a86a'; break;
|
||
case 'absorbed': color = '#8a4a52'; break;
|
||
default: color = '#2d323d';
|
||
}
|
||
ctx.fillStyle = color;
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, r1, a0, a1);
|
||
ctx.arc(cx, cy, r0, a1, a0, true);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
}
|
||
// ring border
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
|
||
ctx.beginPath(); ctx.arc(cx, cy, r0, 0, Math.PI * 2); ctx.stroke();
|
||
ctx.beginPath(); ctx.arc(cx, cy, r1, 0, Math.PI * 2); ctx.stroke();
|
||
}
|
||
// band labels stacked vertically at the left edge, one per ring.
|
||
// A short leader dot sits on each ring at the 9 o'clock position.
|
||
for (let bi = 0; bi < bands.length; bi++) {
|
||
const band = bands[bi];
|
||
const r0 = rInner + bi * ringW;
|
||
const rMid = r0 + ringW / 2;
|
||
const labelX = 12;
|
||
const labelY = cy - (bands.length - 1) * 6 + bi * 12;
|
||
text(ctx, band.name, labelX, labelY, C.fg, 'left', 'middle', 10);
|
||
// leader from label to ring
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
|
||
ctx.beginPath();
|
||
ctx.moveTo(labelX + 30, labelY);
|
||
ctx.lineTo(cx - rMid - 2, cy + (labelY - cy) * 0.15);
|
||
ctx.stroke();
|
||
}
|
||
|
||
// now marker
|
||
const hrNow = s.values.hour;
|
||
const ang = -Math.PI / 2 + (hrNow / 24) * 2 * Math.PI;
|
||
ctx.strokeStyle = C.cyan; ctx.lineWidth = 2;
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx + Math.cos(ang) * (rInner - 8), cy + Math.sin(ang) * (rInner - 8));
|
||
ctx.lineTo(cx + Math.cos(ang) * (rOuter + 6), cy + Math.sin(ang) * (rOuter + 6));
|
||
ctx.stroke();
|
||
ctx.lineWidth = 1;
|
||
|
||
// legend
|
||
const legY = h - 18;
|
||
const items = [
|
||
{ c: '#9dca83', t: 'DX open' },
|
||
{ c: '#d6a86a', t: 'Local / marginal' },
|
||
{ c: '#8a4a52', t: 'Absorbed' },
|
||
{ c: '#2d323d', t: 'Closed' },
|
||
];
|
||
let lx2 = 10;
|
||
items.forEach(it => {
|
||
ctx.fillStyle = it.c; ctx.fillRect(lx2, legY, 10, 10);
|
||
text(ctx, it.t, lx2 + 14, legY + 5, C.dim, 'left', 'middle', 10);
|
||
lx2 += ctx.measureText(it.t).width + 32;
|
||
});
|
||
|
||
// center UTC label
|
||
text(ctx, 'hour', cx, cy - 6, C.dim, 'center', 'middle', 10);
|
||
const hn = Math.floor(hrNow); const mn = Math.round((hrNow - hn) * 60);
|
||
text(ctx, `${String(hn).padStart(2, '0')}:${String(mn).padStart(2, '0')}`, cx, cy + 8, C.fg, 'center', 'middle', 14);
|
||
|
||
// readouts
|
||
const openNow = bands.filter(b => bandStatus(b.f, hrNow, s.values.sfu) === 'dx').map(b => b.name);
|
||
s.setReadout('open', openNow.length ? openNow.join(', ') : '(none)');
|
||
const fF2 = critFreqMHz(layerDensity('F2', hrNow, s.values.sfu));
|
||
s.setReadout('muf', mufMHz(fF2, 5).toFixed(1) + ' MHz');
|
||
|
||
requestAnimationFrame(draw);
|
||
}
|
||
requestAnimationFrame(draw);
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
sceneLayers();
|
||
sceneRays();
|
||
sceneBandClock();
|
||
});
|
||
})();
|