diff --git a/CLAUDE.md b/CLAUDE.md
index b7dc887..684e8eb 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -8,8 +8,8 @@ Static website for W5ISP (amateur radio callsign) built with [Zola](https://www.
## Commands
-- `zola serve` - Local dev server with live reload (default: http://127.0.0.1:1111)
-- `zola build` - Build site to `public/` directory
+- `zola serve --drafts` - Local dev server with live reload, including drafts (default: http://127.0.0.1:1111). Always pass `--drafts` in dev so future-dated and draft posts are visible while working on them.
+- `zola build` - Build site to `public/` directory (drafts excluded, which is what deploys need)
- `zola check` - Validate links and templates
## Architecture
diff --git a/content/blog/hf-skip-muf.md b/content/blog/hf-skip-muf.md
new file mode 100644
index 0000000..b6c05b5
--- /dev/null
+++ b/content/blog/hf-skip-muf.md
@@ -0,0 +1,77 @@
++++
+title = "HF skip and the MUF: why the sky reflects some frequencies and shrugs at others"
+date = 2026-05-20
+draft = true
++++
+
+
+
+
+
+The microwave bands treat the atmosphere as a faintly annoying medium that occasionally bends signals around hills by a few degrees and otherwise stays out of the way. On HF, the atmosphere is the point. A 14 MHz signal launched at the right angle from Texas hits an invisible ceiling a few hundred kilometers up, bounces off, and lands in Germany. It does so without the transmitter doing anything clever, on equipment that would not look out of place in 1950, and using a physical mechanism that nobody designed and that we get to use for free.
+
+The ceiling is the ionosphere, the bounce is refraction rather than reflection (but everyone says reflection anyway because it's shorter), and the rules that decide whether any given signal gets bounced or ignored come down to two numbers: the frequency of the signal and the density of free electrons in the layer it's trying to punch through. Get the ratio right and the sky throws your signal around the planet. Get it wrong and your signal sails into space, never to be heard from again except possibly by some very confused extraterrestrials wondering why Earth has so much to say about DX.
+
+## The layers, briefly
+
+Sunlight knocks electrons loose from atoms and molecules in the upper atmosphere. The density of those electrons, by altitude, is not uniform. It clumps into layers, each with its own chemistry, its own diurnal habits, and its own opinions about which amateur bands it likes.
+
+- **D layer**, 60 to 90 km up. Present only during the day because its electrons recombine the moment the sun stops topping them up. The D layer doesn't reflect HF signals; it absorbs them, with losses that scale roughly as 1/f2. This is why 80 meters is useless at noon and excellent at midnight.
+- **E layer**, 90 to 150 km. Also mostly a daytime feature, supports short-skip propagation on the lower HF bands and occasionally hosts [sporadic E](https://en.wikipedia.org/wiki/Sporadic_E_propagation), which is a separate phenomenon involving patchy ionization clouds that refract 6 meters and 10 meters in ways that seem to violate basic physics but don't.
+- **F layer**, splitting into F1 and F2 during the day and merging into a single F layer at night. F2 sits around 250 to 500 km up, survives the night because the recombination rate at that altitude is slow, and does essentially all of the long-haul HF work. When somebody works Japan from Texas on 20 meters, they are using F2.
+
+
+
+Drag the time slider and watch the ionosphere breathe. Each layer's color intensity tracks its electron density at that hour, and the critical frequency fc printed on each band is what it can reflect at straight-up incidence. Flip the auto-advance on and let the whole thing run; the layers do the same thing they've been doing for 4.5 billion years, which is to say ignore radio entirely and respond only to where the sun is pointing.
+
+## Critical frequency and the MUF
+
+Point a signal straight up at the ionosphere. At low frequencies, every photon bounces back. Raise the frequency. At some point, called the critical frequency *f*c, the signal starts punching through and disappearing into space. The critical frequency depends on the electron density of the layer, and the relationship is *f*c (MHz) ~ 9 * sqrt(*N*), with *N* in electrons per cubic centimeter * 10-6. For a quiet nighttime F2 layer, *f*c might be 3 or 4 MHz. For the same F2 under a solar maximum noon sun, it can top 15.
+
+That's straight-up incidence. Signals launched at an angle don't have to overcome as much ionization to be bent back down, because the geometry gives them more path length inside the layer. The [Maximum Usable Frequency](https://en.wikipedia.org/wiki/Maximum_usable_frequency), or MUF, is the highest frequency that still refracts back to Earth for a given path angle, and it's related to the critical frequency by *MUF* = *f*c / cos(θ), where θ is the angle of incidence at the layer. For a low takeoff angle (long-distance path), the MUF can be three or four times the critical frequency. This is the single most important equation in HF propagation, and it has been quietly running the amateur bands since before any of us were born.
+
+Below the MUF, signals refract. Above it, signals escape. Right at the MUF, signals do both, poorly. Operators care because every HF band has exactly one answer to "is today a good day to work DX on this frequency?" and the answer is "is the MUF for your path higher than the band?"
+
+## The skip zone
+
+A signal leaving an antenna at a high takeoff angle reaches the F2 layer at a steep angle of incidence and needs a high *f*c to bounce. A signal leaving at a low takeoff angle reaches the layer at a shallow angle and bounces at a much lower *f*c. For any given operating frequency, some takeoff angles refract back to Earth and some punch through.
+
+The consequence is the [skip zone](https://en.wikipedia.org/wiki/Skip_zone), an annular dead region around the transmitter. The ground wave dies off within a few tens of kilometers. Anything closer than the shortest path that still bounces (the skip distance) hears nothing. Past the skip distance, signals start coming down, and the band is usable from there out to the limit of a single hop, which is around 4000 km for F2. Beyond that, signals either make multiple hops (losing a few dB each time they touch the ground) or ride the layer for long stretches in [chordal propagation](https://en.wikipedia.org/wiki/Skywave) that nobody fully predicts.
+
+
+
+A fan of rays leaves the transmitter at every takeoff angle from 5° to 85°. Solid rays bend back down at the lowest layer that can hold them; dashed rays punch through every layer and keep going into space (sorry). Crank the frequency past the F2 MUF and the dashed rays take over, which is what band closure actually looks like. The hatched region is the skip zone, where nobody hears you no matter how much power you run.
+
+The skip zone is why, on 20 meters, the guy 200 km away is impossible to hear and the guy 3000 km away is loud. His signal found an angle that worked. Yours, for him, did the same.
+
+## The daily cycle
+
+Sunrise: D layer starts absorbing the low bands, F2 rises and its electron density climbs. 160, 80, and 40 meters get progressively louder in absorption and quieter in usable signal. 20, 15, 10 meters start to open. By noon, the low bands are closed to anything but local contacts and the high bands are carrying most of the traffic.
+
+Sunset: D layer recombines within a couple of hours, because chemistry. Absorption on the low bands evaporates. 80 and 40 meters light up, and 160 meters (which has effectively no daytime to speak of) becomes usable for the first time since yesterday. F2 starts to decay but slowly enough that 20 meters often holds through the evening. The highest bands (10, 12 meters) close first, then 15, then 17, then 20, in a cascade that tracks the F2 electron density as it coasts downward through the night. Around 2 AM, 40 meters is doing long-haul DX work and 10 meters is as dead as it's going to get.
+
+Come dawn, it all starts over. Every day. For as long as there is a sun.
+
+
+
+Every amateur band, every hour of the day, colored by how useful it is at the current solar flux. Move the sunspot slider and watch the high bands bloom green at solar max and go dark at solar min. Move the hour marker and see the shift from the low bands at night to the high bands by day. This is, to a first approximation, the amateur's worldview compressed onto one circle.
+
+## The solar cycle
+
+Superimposed on the daily cycle is an 11-year wobble in how active the sun is, measured by [sunspot number](https://en.wikipedia.org/wiki/Wolf_number). More sunspots, more extreme ultraviolet, more ionization, higher F2 electron density, higher MUF. At solar maximum, 10 meters is open worldwide for much of the day and MUFs routinely clear 30 MHz, which opens 6 meters (technically VHF) to F2 propagation. At solar minimum, 20 meters is the highest band anyone trusts for DX and 10 meters is mostly silence punctuated by beacons hoping for an ear.
+
+The current cycle (Cycle 25, peaking somewhere around now) has been unexpectedly strong. The previous cycle (24) was a dud. The cycle before that (23) was fine. There is no good model of which cycles will be strong and which won't, and people who claim otherwise are selling something. What we have is the data, which goes back to the 1750s and shows a noisy periodic signal that averages 11 years and occasionally produces decades-long lulls ([Maunder minimum](https://en.wikipedia.org/wiki/Maunder_Minimum), which coincided with HF being not yet invented, so it didn't much matter at the time).
+
+The practical consequence is that the same HF station does wildly different things depending on where you are in the cycle. A 100-watt rig and a dipole that worked Europe routinely on 20 meters at solar max will struggle to work the next state over at solar min. The equipment hasn't changed. The sky has.
+
+## Why this is the opposite of microwave NLOS
+
+Microwave NLOS contacts happen when tropospheric physics (ducting, diffraction, rain scatter) briefly makes an atmosphere that otherwise doesn't cooperate do something unusual. They're rare, they require specific weather, and the operator hunts for the conditions rather than the other way around.
+
+HF skip is the opposite deal. The ionosphere cooperates every day. There is no exotic mechanism. A dipole, a hundred watts, and the right frequency for the time of day will work the world routinely, and has been doing so since Marconi. The challenge isn't finding a mechanism that works. The challenge is picking the frequency whose MUF currently crosses the path you want to use, which is a forecasting problem and a propagation problem and a "look at who's active on the band right now" problem, and which is the whole game.
+
+A microwave operator stares at weather maps. An HF operator stares at solar flux numbers and sunspot counts and [ionospheric sounder](https://en.wikipedia.org/wiki/Ionosonde) data, and is watching the same planet do a completely different trick with radio waves two hundred kilometers higher up. Both are working. Neither is straightforward. Both get called, by outsiders, "just radio," which undersells them a bit.
+
+
+
+
diff --git a/static/js/hf-skip.js b/static/js/hf-skip.js
new file mode 100644
index 0000000..4e330fc
--- /dev/null
+++ b/static/js/hf-skip.js
@@ -0,0 +1,705 @@
+// 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();
+ });
+})();