diff --git a/content/blog/prop1.md b/content/blog/prop1.md index bc3bb29..34870e2 100644 --- a/content/blog/prop1.md +++ b/content/blog/prop1.md @@ -23,7 +23,7 @@ favorable conditions. An easy visual analogy: ships sometimes appear to float ab ![Atmospheric ducted ship](/images/atmospheric_ducting.jpg) -This is because there's a duct with favorable conditions for refracting visible light — the same +This is because there's a duct with favorable conditions for refracting visible light, the same thing happens with microwave signals. At one of the [NTMS](https://ntms.org) meetings, KM5PO presented on this exact topic and had used @@ -33,7 +33,7 @@ kicked off this project. Fast forward a few weeks and I've now built an Elixir/Phoenix app at that collects and processes everything. For a quick import of some known contacts, I pulled in results -from the ARRL 10 GHz and Up and 222 MHz and Up contests — VHF/UHF/microwave events where stations +from the ARRL 10 GHz and Up and 222 MHz and Up contests, VHF/UHF/microwave events where stations log long-distance contacts on those bands. With known contacts in place, it was time to backfill them with data. To predict ducting we need @@ -41,23 +41,23 @@ atmospheric data at fine vertical resolution, so the app pulls from a mix of wea surface observations, upper-air soundings, and terrain: ### Atmospheric -* HRRR (NOAA 3 km) — hourly f00–f18 propagation scoring hot path; native hybrid-sigma levels for fine-grained duct detection -* GEFS (NOAA 0.5° ensemble) — Day 2–7 extended-horizon outlook -* HRRR native (hybrid σ) — best-duct-band per cell, refractivity gradient at 10–50 m resolution -* IEMRE (Iowa State 0.125° reanalysis) — gridded hourly weather for per-QSO enrichment -* NARR (NCEI 32 km reanalysis) — pre-2014 historical backfill for old QSOs (HRRR archive doesn't cover them) +* HRRR (NOAA 3 km), hourly f00–f18 propagation scoring hot path; native hybrid-sigma levels for fine-grained duct detection +* GEFS (NOAA 0.5° ensemble), Day 2–7 extended-horizon outlook +* HRRR native (hybrid σ), best-duct-band per cell, refractivity gradient at 10–50 m resolution +* IEMRE (Iowa State 0.125° reanalysis), gridded hourly weather for per-contact enrichment +* NARR (NCEI 32 km reanalysis), pre-2014 historical backfill for old contacts (HRRR archive doesn't cover them) ### Surface observations -* ASOS (via IEM) — airport station surface weather, used by per-QSO enrichment and the 10-minute score nudge -* MRMS (NOAA PrecipRate) — 2-minute radar-derived rain rate that overlays HRRR's hourly precip for fast-moving cells -* NEXRAD (Level II composite reflectivity) — current-hour precip cell detection for f00 scoring +* ASOS (via IEM), airport station surface weather, used by per-contact enrichment and the 10-minute score nudge +* MRMS (NOAA PrecipRate), 2-minute radar-derived rain rate that overlays HRRR's hourly precip for fast-moving cells +* NEXRAD (Level II composite reflectivity), current-hour precip cell detection for f00 scoring ### Upper air -* RAOB (US radiosondes via IEM) — twice-daily soundings for duct/inversion analysis -* UWYO (Canadian radiosondes) — 00/12 Z Canadian profiles +* RAOB (US radiosondes via IEM), twice-daily soundings for duct/inversion analysis +* UWYO (Canadian radiosondes), 00/12 Z Canadian profiles ### Terrain & geography -* SRTM (90 m local tile cache + fallback API) — ITU-R P.526-16 path diffraction + viewshed +* SRTM (90 m local tile cache + fallback API), ITU-R P.526-16 path diffraction + viewshed It's taken a mountain of work and time to get all of this implemented, but most of the contacts have now been backfilled with this information. Feeding all of this in to Claude, we end up with an @@ -77,7 +77,7 @@ since both endpoints are known and fixed. The effort has been growing and we now have help from a senior meteorologist in Lubbock, TX and many hams submitting verified contacts. If you'd like to help out, we need as many contacts as -possible during non-contest times — contest weekends concentrate activity into a few days, but +possible during non-contest times, contest weekends concentrate activity into a few days, but the model needs samples spread across all conditions. We also need volunteers for future automated beacon monitoring. You can reach me at my first name at my last name dot me (it's in the footer). diff --git a/content/blog/why-signals-bend.md b/content/blog/why-signals-bend.md new file mode 100644 index 0000000..98f20ac --- /dev/null +++ b/content/blog/why-signals-bend.md @@ -0,0 +1,111 @@ ++++ +title = "Why signals bend (and why your 10 GHz signal sometimes goes through a hill)" +date = 2026-04-22 +draft = false ++++ + + + +
+ +A line-of-sight microwave link is easy to reason about. Two antennas, straight line, if nothing touches the line the path works. + +Ham radio doesn't cooperate with that story. People working the [ARRL 10 GHz and Up contest](https://www.arrl.org/10-ghz-up) log contacts over hundreds of kilometers where a string between the two stations would go straight through an Oklahoma mesa. The path profile looks broken. The contact happens anyway. + +Below, one layer at a time, with scenes you can interact with. We start from the straight-line-and-obstacle model and break it four times. + +## Start simple: a line between two antennas + +Two antennas, flat ground, a hill in the middle. If the hill sticks above the line, you get nothing. + +
+ +That model is how nearly every commercial point-to-point microwave link is designed. WISPs, utility SCADA, carrier backhaul, all of it. It hides two lies though. The Earth isn't flat, and the signal isn't a line. + +## Earth gets in the way too + +Zoom out far enough and the ground curves. From an antenna of height *h* the geometric horizon sits at about √(2·R·h). Two 30-meter towers can each see a horizon roughly 20 km away. Push them 80 km apart and the Earth bulges more than 120 meters up between them. + +
+ +No mast is tall enough to beat that on its own. + +## The signal is fatter than a line + +Here's the first thing in the geometry-only model that helps NLOS along a little. Radio energy doesn't ride a pencil-thin ray. It fills an ellipsoid between the antennas called the first Fresnel zone, and you want about 60% of that zone clear for the path to behave like free space. + +The ellipsoid is fattest at the middle of the path. Its radius there is √(λ·d/4), so it grows with wavelength. A 40 km path on 50 MHz has a midspan radius around 24 meters. Same path on 10 GHz is 1.7 meters. + +
+ +This is why microwave links behave so much worse than HF over the same obstructed path. Higher frequency, skinnier ellipsoid, easier to clip with a ridge that doesn't look like much on the path profile. + +## Ridges leak signal + +When the line of sight is fully blocked, a sharp ridge doesn't eat everything. The wavefront hitting that edge keeps going, and some energy diffracts into the shadow. The usual way to describe it is the Fresnel-Kirchhoff parameter ν, which mashes together the geometry and the wavelength. ITU-R [P.526](https://www.itu.int/rec/R-REC-P.526/) gives the loss curve, shown on the right. + +
+ +A knife edge grazing the LOS already costs 6 dB. Stick it one Fresnel zone above and you're down 20+. It's cheap power on VHF. On 10 GHz the same geometry is almost never workable on its own. + +## Real hills are rounded, which costs more + +Knife edges are a convenient fiction. A rounded summit scatters more energy away from the shadow zone, so actual loss is worse than the knife-edge baseline. P.526 tacks on an extra term based on the crest's radius of curvature. + +
+ +This is where path profile software starts lying to you. If it says a ridge clears by 5 meters and you'll be +3 dB over the noise floor, you can usually subtract another 5–10 dB once you account for the rounding. Most of the time that subtraction puts the link under water. Except the link works. So something else is going on. + +## Air isn't empty + +Microwave waves travel a hair slower through air than through vacuum, and the slowness changes with pressure, temperature, and humidity. Radio engineers express it as N-units: + +> N = (n − 1) · 10⁶ + +where *n* is the refractive index. At sea level N is usually around 315. It falls off with altitude, and the slope of that fall is what bends rays. + +
+ +The dashed line is the international standard atmosphere. Drag the slope and the regime label changes with it. Most of the useful ham band magic happens on the far left side of that slider. + +## Rays follow the gradient + +A horizontal ray in a medium whose refractive index falls with altitude bends downward. The tighter the gradient, the tighter the curve. Under the standard atmosphere the bend is gentle, about a quarter of Earth's own curvature, and engineers paper over it by pretending the Earth is 4/3 its real size and the rays are straight. That's the familiar k = 4/3 factor. + +
+ +Push the slope below −79 N/km and rays start bending faster than standard. A lot of VHF/UHF tropo propagation lives in that regime. Push below −157 N/km and rays curve tighter than the Earth curves away. The ray can't climb out anymore. + +## The signal gets trapped + +Here's where a ham-friendly explanation helps. Propagation models talk about "rays" because the math is a lot easier when you treat a radio wave as a bundle of straight (or curving) lines, each representing the direction the wavefront is moving at that point. In practice, those lines are the actual paths your RF follows out of the antenna. When the atmosphere bends them, your signal goes where the lines go. + +A *duct* is a layer of atmosphere where the refractivity drops steeply enough that any signal launched into the layer at a shallow enough elevation angle can't escape upward. Picture a slice of your antenna's radiation pattern between about −0.5° and +0.5° elevation. Normally that energy just keeps rising into space and is lost. Under a duct, it climbs into the layer, curves back down, hits the ground (or a lower boundary of the duct) and reflects, climbs again, curves back down again, and keeps repeating. The signal skips along inside the duct for hundreds of kilometers, losing very little on each bounce because the reflections are shallow and the layer is keeping the wavefront collimated. + +
+ +Green paths in the scene are the rays (ray = the direction the wavefront is traveling) that stay trapped inside the duct. Orange ones escape. The "critical angle" is the elevation window that stays trapped, and it's tiny, usually a fraction of a degree. That's why ducting is so fickle. Your antenna has to be radiating useful power inside that window, *and* the duct has to exist above your station, *and* the station on the other end has to be inside it or close enough for its energy to enter. When all three line up, your 10 GHz rig works a contact 400 km away. When any one of them is off, the path behaves like the terrain profile says it should. + +## Flavors of duct + +Three of these show up in the wild, all the same physics, different ways the atmosphere sets them up. + +
+ +Surface ducts form overnight as the ground cools faster than the air above it, and they collapse once the sun warms things back up. Elevated ducts sit aloft under a subsidence inversion, typical under a high-pressure ridge. Both ends of the link usually need to be inside the layer, or at least angled into it. Evaporation ducts are the interesting one: over warm oceans the humidity gradient in the first 5-40 meters is so sharp that a thin duct is there nearly 24/7. Every over-water microwave link above a few GHz lives inside one. + +## A real path + +This is the 65 km path I've been staring at: my QTH to the W5HN/B beacon on top of the TWU dorms in Denton. Terrain heights are the actual SRTM elevations along the great circle between the two antennas. Earth's curvature (using the standard k = 4/3 effective Earth) is added on top, which is why the middle of the profile bulges up about 62 m above the endpoints. The terrain obstructs the straight LOS. On the right mornings the path works anyway, because an elevated duct parks over North Texas and both ends get into it. + +
+ +Figuring out whether that duct is there on any given day is the whole point of . The app pulls HRRR at native hybrid-sigma levels, corrects against ASOS and radiosonde profiles, and computes the refractivity gradient along every path we track. When a duct is sitting on the path the score lights up. When it isn't, the path should behave the way the terrain profile says. + +The part that keeps me working on it is how often it *doesn't* behave that way. Ducting, super-refraction, and enhanced tropo scatter combine in ways that still surprise the old guys who've been operating these bands for forty years. The more verified contacts we log with good atmospheric data behind them, the better the model gets. + +If you run 50 MHz or up, submit your contacts or beacons at . A continuous, known-distance path is worth a lot more to the model than a pile of contest contacts. + +
+ + diff --git a/static/css/nlos.css b/static/css/nlos.css new file mode 100644 index 0000000..622ff52 --- /dev/null +++ b/static/css/nlos.css @@ -0,0 +1,131 @@ +/* Styles scoped to the NLOS propagation post. Only loaded when the post's + tag is on the page. */ + +.nlos { + --nlos-bg: #1f232b; + --nlos-panel: #242932; + --nlos-fg: #d4d8df; + --nlos-dim: #7d8594; + --nlos-grid: #323844; + --nlos-blue: #61afef; + --nlos-cyan: #56b6c2; + --nlos-green: #98c379; + --nlos-orange: #d19a66; + --nlos-red: #e06c75; + --nlos-yellow: #e5c07b; + --nlos-magenta: #c678dd; +} + +.nlos-scene { + margin: 2.5rem 0; + background: var(--nlos-bg); + border: 1px solid var(--nlos-grid); + border-radius: 6px; + padding: 0.75rem 0.75rem 1rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35); +} + +.nlos-scene canvas { + display: block; + width: 100%; + height: auto; + touch-action: none; + cursor: grab; + background: transparent; +} + +.nlos-scene canvas:active { + cursor: grabbing; +} + +.nlos-controls { + display: grid; + grid-template-columns: 1fr; + gap: 0.5rem; + margin-top: 0.75rem; + font-size: 0.9rem; +} + +@media (min-width: 640px) { + .nlos-controls { + grid-template-columns: repeat(2, 1fr); + } + .nlos-controls.nlos-controls--three { + grid-template-columns: repeat(3, 1fr); + } +} + +.nlos-control { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.nlos-control label { + color: var(--nlos-dim); + font-size: 0.8rem; + display: flex; + justify-content: space-between; + gap: 0.5rem; +} + +.nlos-control label .nlos-value { + color: var(--nlos-fg); + font-variant-numeric: tabular-nums; +} + +.nlos-control input[type="range"] { + width: 100%; + accent-color: var(--nlos-cyan); +} + +.nlos-scene .nlos-caption { + margin-top: 0.5rem; + font-size: 0.85rem; + color: var(--nlos-dim); + line-height: 1.45; +} + +.nlos-scene .nlos-readout { + display: flex; + flex-wrap: wrap; + gap: 0.75rem 1.5rem; + padding: 0.5rem 0; + margin-top: 0.25rem; + color: var(--nlos-fg); + font-size: 0.85rem; + font-variant-numeric: tabular-nums; +} + +.nlos-scene .nlos-readout .nlos-readout-item span:first-child { + color: var(--nlos-dim); + margin-right: 0.35rem; +} + +.nlos-buttons { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.nlos-buttons button { + background: var(--nlos-panel); + color: var(--nlos-fg); + border: 1px solid var(--nlos-grid); + border-radius: 4px; + padding: 0.35rem 0.65rem; + font-size: 0.85rem; + cursor: pointer; + font-family: inherit; +} + +.nlos-buttons button:hover { + border-color: var(--nlos-cyan); +} + +.nlos-buttons button[aria-pressed="true"] { + background: var(--nlos-cyan); + color: #111; + border-color: var(--nlos-cyan); +} diff --git a/static/js/nlos.js b/static/js/nlos.js new file mode 100644 index 0000000..98d9d3f --- /dev/null +++ b/static/js/nlos.js @@ -0,0 +1,1705 @@ +// NLOS propagation post, all interactive scenes. +// Vanilla JS, 2D canvas. Scoped to this one post only. +(() => { + 'use strict'; + + const COLORS = { + bg: '#1f232b', + grid: '#323844', + dim: '#5c6370', + fg: '#d4d8df', + blue: '#61afef', + cyan: '#56b6c2', + green: '#98c379', + orange: '#d19a66', + red: '#e06c75', + yellow: '#e5c07b', + magenta: '#c678dd', + }; + + // Real Earth radius in meters. + const R_EARTH = 6_371_000; + // Speed of light, m/s. + const C = 2.998e8; + + // ---------- 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); + }; + fit(); + const ro = new ResizeObserver(fit); + ro.observe(canvas); + return { ctx, getSize: () => ({ w: canvas.clientWidth, h: canvas.clientHeight }) }; + } + + // Build a scene: creates canvas, controls, readout. Returns helpers. + function scene(id, { height = 360, controls = [], readout = [], caption = '', threeCol = false } = {}) { + const root = document.getElementById(id); + if (!root) return null; + root.classList.add('nlos-scene'); + + const canvas = document.createElement('canvas'); + canvas.style.aspectRatio = `${16} / ${(16 * height) / 640}`; + canvas.style.width = '100%'; + canvas.style.height = `${height}px`; + root.appendChild(canvas); + + const readoutEl = document.createElement('div'); + readoutEl.className = 'nlos-readout'; + const readoutSpans = {}; + readout.forEach(r => { + const item = document.createElement('span'); + item.className = 'nlos-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 = 'nlos-controls' + (threeCol ? ' nlos-controls--three' : ''); + const values = {}; + controls.forEach(c => { + const wrap = document.createElement('div'); + wrap.className = 'nlos-control'; + const label = document.createElement('label'); + const name = document.createElement('span'); + name.textContent = c.label; + const valSpan = document.createElement('span'); + valSpan.className = 'nlos-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(1) + (c.unit || '')); + valSpan.textContent = fmt(values[c.key]); + input.addEventListener('input', () => { + values[c.key] = parseFloat(input.value); + valSpan.textContent = fmt(values[c.key]); + }); + wrap.appendChild(label); + wrap.appendChild(input); + controlsEl.appendChild(wrap); + }); + if (controls.length) root.appendChild(controlsEl); + + if (caption) { + const cap = document.createElement('div'); + cap.className = 'nlos-caption'; + cap.textContent = caption; + root.appendChild(cap); + } + + const { ctx, getSize } = setupCanvas(canvas); + const setReadout = (key, text) => { + if (readoutSpans[key]) readoutSpans[key].textContent = text; + }; + + // Simple drag handling. + const dragHandlers = []; + canvas.addEventListener('pointerdown', e => { + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + for (const h of dragHandlers) { + if (h.hit(x, y)) { + h.active = true; + canvas.setPointerCapture(e.pointerId); + h.onMove?.(x, y); + return; + } + } + }); + canvas.addEventListener('pointermove', e => { + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + for (const h of dragHandlers) if (h.active) h.onMove?.(x, y); + }); + canvas.addEventListener('pointerup', e => { + for (const h of dragHandlers) h.active = false; + }); + canvas.addEventListener('pointercancel', e => { + for (const h of dragHandlers) h.active = false; + }); + const addDrag = (hit, onMove) => dragHandlers.push({ hit, onMove, active: false }); + + return { canvas, ctx, getSize, values, setReadout, addDrag }; + } + + // ---------- shared drawing helpers ---------- + + function clear(ctx, w, h, bg = 'transparent') { + ctx.clearRect(0, 0, w, h); + if (bg !== 'transparent') { + ctx.fillStyle = bg; + ctx.fillRect(0, 0, w, h); + } + } + + function grid(ctx, w, h, { stepX = 40, stepY = 40, color = COLORS.grid } = {}) { + ctx.save(); + ctx.strokeStyle = color; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let x = 0; x < w; x += stepX) { ctx.moveTo(x, 0); ctx.lineTo(x, h); } + for (let y = 0; y < h; y += stepY) { ctx.moveTo(0, y); ctx.lineTo(w, y); } + ctx.stroke(); + ctx.restore(); + } + + function drawAntenna(ctx, x, groundY, heightPx, color = COLORS.orange) { + ctx.save(); + ctx.strokeStyle = color; + ctx.fillStyle = color; + ctx.lineWidth = 2; + // mast + ctx.beginPath(); + ctx.moveTo(x, groundY); + ctx.lineTo(x, groundY - heightPx); + ctx.stroke(); + // base + ctx.beginPath(); + ctx.moveTo(x - 6, groundY); + ctx.lineTo(x + 6, groundY); + ctx.stroke(); + // top + ctx.beginPath(); + ctx.arc(x, groundY - heightPx, 4, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + + function labelText(ctx, text, x, y, color = COLORS.fg, align = 'left', baseline = 'alphabetic') { + ctx.save(); + ctx.fillStyle = color; + ctx.font = '12px system-ui, sans-serif'; + ctx.textAlign = align; + ctx.textBaseline = baseline; + ctx.fillText(text, x, y); + ctx.restore(); + } + + // ---------- Scene 1: Straight-line LOS ---------- + function sceneLOS() { + const s = scene('scene-los', { + height: 320, + controls: [ + { key: 'h1', label: 'Station A height', min: 5, max: 80, value: 20, unit: ' m', format: v => v.toFixed(0) + ' m' }, + { key: 'h2', label: 'Station B height', min: 5, max: 80, value: 20, unit: ' m', format: v => v.toFixed(0) + ' m' }, + { key: 'obstacle', label: 'Hill height', min: 0, max: 100, value: 45, unit: ' m', format: v => v.toFixed(0) + ' m' }, + { key: 'obstaclePos', label: 'Hill position', min: 0.15, max: 0.85, value: 0.5, step: 0.01, format: v => (v * 100).toFixed(0) + '%' }, + ], + readout: [ + { key: 'status', label: 'Path' }, + { key: 'clearance', label: 'Clearance' }, + ], + caption: 'Two amateur stations on flat ground. A direct ray connects the antennas. If any terrain pokes above that line, the signal is blocked. This is what "line of sight" means in the simplest case.', + }); + if (!s) return; + + function draw() { + const { w, h } = s.getSize(); + const ctx = s.ctx; + clear(ctx, w, h); + const margin = 40; + const groundY = h - 40; + const xA = margin, xB = w - margin; + const maxH = 100; // meters corresponds to pixels + const pxPerM = 2; + + // ground + ctx.fillStyle = '#2b3039'; + ctx.fillRect(0, groundY, w, h - groundY); + ctx.strokeStyle = COLORS.grid; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(0, groundY); ctx.lineTo(w, groundY); ctx.stroke(); + + // hill + const hx = margin + (w - 2 * margin) * s.values.obstaclePos; + const hh = s.values.obstacle * pxPerM; + const hw = 80; + ctx.fillStyle = '#3a4150'; + ctx.strokeStyle = COLORS.dim; + ctx.beginPath(); + ctx.moveTo(hx - hw, groundY); + ctx.quadraticCurveTo(hx, groundY - hh * 2, hx + hw, groundY); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + + const h1px = s.values.h1 * pxPerM; + const h2px = s.values.h2 * pxPerM; + const yA = groundY - h1px, yB = groundY - h2px; + + // signal line, compute clearance over hill apex + const t = (hx - xA) / (xB - xA); + const yOnRay = yA + (yB - yA) * t; + const hillTop = groundY - hh * 2; + const clearance = hillTop - yOnRay; // negative = obstructed... actually no: smaller y = higher. yOnRay < hillTop (more negative offset) means ray is ABOVE hill => clearance positive. + // With screen coords (y down), ray above hill means yOnRay < hillTop. Clearance in meters = (hillTop - yOnRay)/pxPerM. Positive when ray above hill. + const clearanceM = (hillTop - yOnRay) / pxPerM; + const blocked = clearanceM < 0; + + // beam + ctx.save(); + ctx.strokeStyle = blocked ? COLORS.red : COLORS.cyan; + ctx.lineWidth = 2; + ctx.setLineDash(blocked ? [6, 4] : []); + ctx.beginPath(); + ctx.moveTo(xA, yA); + ctx.lineTo(xB, yB); + ctx.stroke(); + ctx.restore(); + + drawAntenna(ctx, xA, groundY, h1px, COLORS.orange); + drawAntenna(ctx, xB, groundY, h2px, COLORS.orange); + labelText(ctx, 'A', xA, groundY - h1px - 10, COLORS.orange, 'center'); + labelText(ctx, 'B', xB, groundY - h2px - 10, COLORS.orange, 'center'); + + s.setReadout('status', blocked ? 'BLOCKED' : 'CLEAR'); + s.setReadout('clearance', `${clearanceM.toFixed(1)} m`); + + requestAnimationFrame(draw); + } + draw(); + } + + // ---------- Scene 2: Earth curvature ---------- + function sceneEarthCurve() { + const s = scene('scene-curve', { + height: 360, + controls: [ + { key: 'h1', label: 'Station A height', min: 2, max: 300, value: 30, unit: ' m', format: v => v.toFixed(0) + ' m' }, + { key: 'h2', label: 'Station B height', min: 2, max: 300, value: 30, unit: ' m', format: v => v.toFixed(0) + ' m' }, + { key: 'dist', label: 'Distance', min: 10, max: 200, value: 80, unit: ' km', format: v => v.toFixed(0) + ' km' }, + ], + readout: [ + { key: 'horizonA', label: 'Horizon A' }, + { key: 'horizonB', label: 'Horizon B' }, + { key: 'bulge', label: 'Earth bulge' }, + { key: 'status', label: 'LOS' }, + ], + caption: 'Zoom out, and the ground stops being flat. The horizon distance for an antenna of height h is approximately √(2·R·h). If A and B are farther apart than both their horizon distances combined, the Earth’s bulge rises above the straight line between them.', + threeCol: true, + }); + if (!s) return; + + function draw() { + const { w, h } = s.getSize(); + const ctx = s.ctx; + clear(ctx, w, h); + const margin = 40; + const bottom = h - 30; + + const dKm = s.values.dist; + const dM = dKm * 1000; + + // compute Earth bulge at midpoint for given distance. + // bulge = R - R*cos(d/(2R)) ≈ d²/(8R). Use exact. + const halfAngle = dM / (2 * R_EARTH); + const bulgeM = R_EARTH * (1 - Math.cos(halfAngle)); + + // Vertical scale: we want antennas and bulge visible. Use max of bulge and heights for vertical scale. + const maxVertical = Math.max(bulgeM, s.values.h1, s.values.h2) * 1.4; + const pxPerM = (bottom - margin) / maxVertical; + + const xA = margin, xB = w - margin; + const cx = (xA + xB) / 2; + + // draw curved ground: arc whose chord is xA..xB and rise (bulge) at center. + // Parameterize ground: y(x) = bottom - (bulge - d_from_center_bulge(x))*pxPerM, where d_from_center_bulge(x) = R*(1 - cos(halfAngle*(1 - 2*|x-cx|/(xB-xA))))? easier: use the parabolic approx for rendering. y_above = (bulge - localBulge(x))*pxPerM. localBulge(x) = R*(1 - cos(halfAngle*(x-cx)/(xB-xA)*2*... )) no. Actually bulge at position along arc (fraction f from center, -1..1): R*(1 - cos(halfAngle*(1-|f|))). Hmm actually we want the ground to be the arc: for a chord of length d, the ground surface is the arc. Height above the chord at fraction u from one end: R*(cos(halfAngle - u*2*halfAngle) - cos(halfAngle)). Easier parametric approach using arc segment angle theta from -halfAngle to +halfAngle: + ctx.fillStyle = '#2b3039'; + ctx.beginPath(); + ctx.moveTo(xA, bottom); + const N = 60; + for (let i = 0; i <= N; i++) { + const u = i / N; // 0..1 + const theta = -halfAngle + u * 2 * halfAngle; + const local = R_EARTH * (Math.cos(theta) - Math.cos(halfAngle)); + const x = xA + u * (xB - xA); + const y = bottom - local * pxPerM; + ctx.lineTo(x, y); + } + ctx.lineTo(xB, h); + ctx.lineTo(xA, h); + ctx.closePath(); + ctx.fill(); + ctx.strokeStyle = COLORS.grid; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let i = 0; i <= N; i++) { + const u = i / N; + const theta = -halfAngle + u * 2 * halfAngle; + const local = R_EARTH * (Math.cos(theta) - Math.cos(halfAngle)); + const x = xA + u * (xB - xA); + const y = bottom - local * pxPerM; + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.stroke(); + + // antennas: placed at ends of arc + const yAground = bottom; // ends are on chord level (cos(±halfAngle) - cos(halfAngle) = 0) + const yBground = bottom; + const h1px = s.values.h1 * pxPerM; + const h2px = s.values.h2 * pxPerM; + const yA = yAground - h1px; + const yB = yBground - h2px; + + // horizon distances + const dH1 = Math.sqrt(2 * R_EARTH * s.values.h1) / 1000; + const dH2 = Math.sqrt(2 * R_EARTH * s.values.h2) / 1000; + const reachable = dH1 + dH2 > dKm; + + // straight-line ray between antennas (the chord) + ctx.save(); + ctx.strokeStyle = reachable ? COLORS.cyan : COLORS.red; + ctx.setLineDash(reachable ? [] : [6, 4]); + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(xA, yA); + ctx.lineTo(xB, yB); + ctx.stroke(); + ctx.restore(); + + drawAntenna(ctx, xA, yAground, h1px, COLORS.orange); + drawAntenna(ctx, xB, yBground, h2px, COLORS.orange); + + // mark bulge at center (peak of arc) + const peakY = bottom - bulgeM * pxPerM; + ctx.fillStyle = COLORS.yellow; + ctx.beginPath(); + ctx.arc(cx, peakY, 3, 0, Math.PI * 2); + ctx.fill(); + labelText(ctx, `bulge ${bulgeM.toFixed(1)} m`, cx, peakY - 8, COLORS.yellow, 'center'); + + s.setReadout('horizonA', `${dH1.toFixed(1)} km`); + s.setReadout('horizonB', `${dH2.toFixed(1)} km`); + s.setReadout('bulge', `${bulgeM.toFixed(1)} m`); + s.setReadout('status', reachable ? 'above horizon' : 'below horizon'); + + requestAnimationFrame(draw); + } + draw(); + } + + // ---------- Scene 3: Fresnel zones ---------- + function sceneFresnel() { + const s = scene('scene-fresnel', { + height: 320, + controls: [ + { key: 'freq', label: 'Frequency', min: Math.log10(50e6), max: Math.log10(10e9), value: Math.log10(2.4e9), step: 0.01, + format: v => { + const f = Math.pow(10, v); + return f >= 1e9 ? (f / 1e9).toFixed(2) + ' GHz' : (f / 1e6).toFixed(0) + ' MHz'; + } }, + { key: 'dist', label: 'Link distance', min: 2, max: 80, value: 20, unit: ' km', format: v => v.toFixed(1) + ' km' }, + { key: 'hill', label: 'Obstacle height', min: -50, max: 60, value: 0, unit: ' m', format: v => v.toFixed(0) + ' m' }, + ], + readout: [ + { key: 'f1', label: 'F1 radius at midpoint' }, + { key: 'clearance', label: '60% F1 clear?' }, + ], + caption: 'Radio waves don’t ride a pencil-thin line. They fill an ellipsoid between the antennas called the first Fresnel zone. Blocking even part of it bleeds signal. Higher frequencies have thinner ellipsoids, which is why 10 GHz behaves far worse over obstacles than 50 MHz.', + threeCol: true, + }); + if (!s) return; + + function draw() { + const { w, h } = s.getSize(); + const ctx = s.ctx; + clear(ctx, w, h); + const freq = Math.pow(10, s.values.freq); + const lambda = C / freq; + const dM = s.values.dist * 1000; + const margin = 40; + const yCenter = h / 2; + const xA = margin, xB = w - margin; + const px = (xB - xA) / dM; // px per meter horizontal + + // F1 radius at midpoint: sqrt(λ * d1 * d2 / (d1+d2)), d1=d2=d/2 -> sqrt(λ*d/4) + const F1Max = Math.sqrt(lambda * dM / 4); + // Vertical scale is based only on distance (using the lowest-frequency case at 50 MHz) + // so the ellipse actually shrinks/grows continuously across the full frequency slider. + const lambdaMax = C / 50e6; + const maxVisible = Math.sqrt(lambdaMax * dM / 4); + const pxPerV = (h / 2 - 30) / maxVisible; + + // draw ellipse (first Fresnel zone projection) + ctx.save(); + ctx.strokeStyle = COLORS.cyan; + ctx.fillStyle = 'rgba(86, 182, 194, 0.15)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + const N = 80; + for (let i = 0; i <= N; i++) { + const u = i / N; // 0..1 + const d1 = u * dM, d2 = dM - d1; + const r = d1 > 0 && d2 > 0 ? Math.sqrt(lambda * d1 * d2 / dM) : 0; + const x = xA + u * (xB - xA); + const y = yCenter - r * pxPerV; + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + for (let i = N; i >= 0; i--) { + const u = i / N; + const d1 = u * dM, d2 = dM - d1; + const r = d1 > 0 && d2 > 0 ? Math.sqrt(lambda * d1 * d2 / dM) : 0; + const x = xA + u * (xB - xA); + const y = yCenter + r * pxPerV; + ctx.lineTo(x, y); + } + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + ctx.restore(); + + // 60% zone + ctx.save(); + ctx.strokeStyle = COLORS.yellow; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + for (let i = 0; i <= N; i++) { + const u = i / N; + const d1 = u * dM, d2 = dM - d1; + const r = d1 > 0 && d2 > 0 ? 0.6 * Math.sqrt(lambda * d1 * d2 / dM) : 0; + const x = xA + u * (xB - xA); + const y = yCenter - r * pxPerV; + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.stroke(); + ctx.beginPath(); + for (let i = 0; i <= N; i++) { + const u = i / N; + const d1 = u * dM, d2 = dM - d1; + const r = d1 > 0 && d2 > 0 ? 0.6 * Math.sqrt(lambda * d1 * d2 / dM) : 0; + const x = xA + u * (xB - xA); + const y = yCenter + r * pxPerV; + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.stroke(); + ctx.restore(); + + // LOS ray + ctx.strokeStyle = COLORS.fg; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(xA, yCenter); ctx.lineTo(xB, yCenter); + ctx.stroke(); + + // antennas + ctx.fillStyle = COLORS.orange; + ctx.beginPath(); ctx.arc(xA, yCenter, 4, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(xB, yCenter, 4, 0, Math.PI * 2); ctx.fill(); + + // Hill rising at midpoint. A quadratic Bezier's point at t=0.5 is at + // (P0 + 2*P1 + P2) / 4, so to make the visible apex land exactly at hillTopY + // the control point y must be 2*hillTopY - hillBottomY. + const hillH = s.values.hill; + const hillTopY = yCenter - hillH * pxPerV; + const hillBottomY = h - 10; + const hx = (xA + xB) / 2; + const hw = 80; + const controlY = 2 * hillTopY - hillBottomY; + ctx.fillStyle = '#3a4150'; + ctx.strokeStyle = COLORS.dim; + ctx.beginPath(); + ctx.moveTo(hx - hw, hillBottomY); + ctx.quadraticCurveTo(hx, controlY, hx + hw, hillBottomY); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + + // Mark the 60% F1 floor at the hill's location (below LOS by 0.6*F1). + const floor60Y = yCenter + 0.6 * F1Max * pxPerV; + ctx.save(); + ctx.strokeStyle = COLORS.yellow; + ctx.setLineDash([2, 4]); + ctx.beginPath(); + ctx.moveTo(hx - 40, floor60Y); + ctx.lineTo(hx + 40, floor60Y); + ctx.stroke(); + ctx.restore(); + + // Clearance: distance from hill apex to the 60% floor. + // Positive when the hill is below the floor (path is clear enough). + const clearanceM = -hillH - 0.6 * F1Max; + const fractionOfF1 = (-hillH) / F1Max; // how far below LOS the hill sits, in F1 units + s.setReadout('f1', `${F1Max.toFixed(2)} m`); + let clearanceText; + if (clearanceM >= 0) { + clearanceText = `clear by ${clearanceM.toFixed(2)} m (${fractionOfF1.toFixed(2)} F1)`; + } else { + clearanceText = `short by ${(-clearanceM).toFixed(2)} m (${fractionOfF1.toFixed(2)} F1)`; + } + s.setReadout('clearance', clearanceText); + + requestAnimationFrame(draw); + } + draw(); + } + + // ---------- Scene 4: Knife-edge diffraction ---------- + // ITU-R P.526 approximation: + // J(v) = 6.9 + 20*log10( sqrt((v - 0.1)^2 + 1) + v - 0.1 ) for v > -0.78 + // J(v) = 0 otherwise + function J_knifeEdge(v) { + if (v <= -0.78) return 0; + return 6.9 + 20 * Math.log10(Math.sqrt(Math.pow(v - 0.1, 2) + 1) + v - 0.1); + } + + function sceneKnifeEdge() { + const s = scene('scene-knife', { + height: 340, + controls: [ + { key: 'h', label: 'Obstacle height above LOS', min: -30, max: 60, value: 0, unit: ' m', format: v => v.toFixed(1) + ' m' }, + { key: 'freq', label: 'Frequency', min: Math.log10(50e6), max: Math.log10(10e9), value: Math.log10(1.296e9), step: 0.01, + format: v => { + const f = Math.pow(10, v); + return f >= 1e9 ? (f / 1e9).toFixed(2) + ' GHz' : (f / 1e6).toFixed(0) + ' MHz'; + } }, + { key: 'dist', label: 'Link distance', min: 5, max: 100, value: 30, unit: ' km', format: v => v.toFixed(1) + ' km' }, + ], + readout: [ + { key: 'v', label: 'ν' }, + { key: 'loss', label: 'Diffraction loss' }, + ], + caption: 'A sharp ridge standing just above the line of sight doesn’t stop the signal dead. Some power diffracts into the shadow. The Fresnel-Kirchhoff parameter ν captures the geometry, and the resulting loss in dB follows the ITU-R P.526 curve shown on the right.', + threeCol: true, + }); + if (!s) return; + + function draw() { + const { w, h } = s.getSize(); + const ctx = s.ctx; + clear(ctx, w, h); + const freq = Math.pow(10, s.values.freq); + const lambda = C / freq; + const dM = s.values.dist * 1000; + const d1 = dM / 2, d2 = dM / 2; + + // Split canvas into diagram (left) and plot (right). + const splitX = w * 0.52; + + // ---- Left: path geometry ---- + const margin = 40; + const yLOS = h * 0.5; + const xA = margin, xB = splitX - margin; + const pxPerM = 2.5; + + const obsH = s.values.h; + const obsX = (xA + xB) / 2; + const obsTopY = yLOS - obsH * pxPerM; + const obsBottomY = h - 45; + + // Reference gridlines in the left panel (light) + ctx.save(); + ctx.strokeStyle = COLORS.grid; + ctx.globalAlpha = 0.4; + ctx.setLineDash([2, 4]); + for (let m = -20; m <= 60; m += 20) { + const y = yLOS - m * pxPerM; + if (y > 10 && y < obsBottomY) { + ctx.beginPath(); ctx.moveTo(xA, y); ctx.lineTo(xB, y); ctx.stroke(); + labelText(ctx, `${m > 0 ? '+' : ''}${m} m`, xA - 4, y, COLORS.dim, 'right', 'middle'); + } + } + ctx.restore(); + + // LOS line + ctx.strokeStyle = COLORS.dim; + ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(xA, yLOS); ctx.lineTo(xB, yLOS); ctx.stroke(); + labelText(ctx, 'line of sight', (xA + xB) / 2, yLOS - 6, COLORS.dim, 'center'); + + // knife edge + ctx.fillStyle = '#3a4150'; + ctx.strokeStyle = COLORS.dim; + ctx.beginPath(); + ctx.moveTo(obsX - 3, obsBottomY); + ctx.lineTo(obsX - 3, obsTopY); + ctx.lineTo(obsX, obsTopY - 8); + ctx.lineTo(obsX + 3, obsTopY); + ctx.lineTo(obsX + 3, obsBottomY); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + + // Height measure: arrow from LOS to obstacle tip showing h + if (Math.abs(obsH) > 0.5) { + ctx.strokeStyle = COLORS.yellow; + ctx.fillStyle = COLORS.yellow; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(obsX + 14, yLOS); + ctx.lineTo(obsX + 14, obsTopY); + ctx.stroke(); + // arrowheads + const dir = obsH > 0 ? -1 : 1; + ctx.beginPath(); + ctx.moveTo(obsX + 14, obsTopY); + ctx.lineTo(obsX + 11, obsTopY + 4 * dir); + ctx.lineTo(obsX + 17, obsTopY + 4 * dir); + ctx.closePath(); + ctx.fill(); + labelText(ctx, `h = ${obsH.toFixed(1)} m`, obsX + 20, (yLOS + obsTopY) / 2, COLORS.yellow, 'left', 'middle'); + } + + // Compute v + const v = obsH * Math.sqrt(2 * (d1 + d2) / (lambda * d1 * d2)); + const lossDb = J_knifeEdge(v); + const color = lossDb > 10 ? COLORS.red : lossDb > 3 ? COLORS.yellow : COLORS.cyan; + + // Ray — straight line along LOS, dashed if obstructed + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.setLineDash(obsH > 0 ? [6, 4] : []); + ctx.beginPath(); ctx.moveTo(xA, yLOS); ctx.lineTo(xB, yLOS); ctx.stroke(); + ctx.setLineDash([]); + + // Diffraction arcs over the edge tip (schematic) + if (obsH > -30) { + ctx.strokeStyle = color; + ctx.globalAlpha = 0.4; + for (let i = 1; i <= 3; i++) { + ctx.beginPath(); + ctx.arc(obsX, obsTopY - 8, i * 10, Math.PI * 1.1, Math.PI * 1.9); + ctx.stroke(); + } + ctx.globalAlpha = 1; + } + + drawAntenna(ctx, xA, yLOS, 0, COLORS.orange); + drawAntenna(ctx, xB, yLOS, 0, COLORS.orange); + labelText(ctx, 'A', xA, yLOS + 14, COLORS.orange, 'center'); + labelText(ctx, 'B', xB, yLOS + 14, COLORS.orange, 'center'); + + // Distance bracket along the bottom + const bracketY = obsBottomY + 18; + ctx.strokeStyle = COLORS.dim; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(xA, bracketY - 3); ctx.lineTo(xA, bracketY + 3); + ctx.moveTo(xA, bracketY); ctx.lineTo(xB, bracketY); + ctx.moveTo(xB, bracketY - 3); ctx.lineTo(xB, bracketY + 3); + ctx.stroke(); + labelText(ctx, `d = ${(dM / 1000).toFixed(1)} km (d₁ = d₂ = ${(dM / 2000).toFixed(1)} km)`, (xA + xB) / 2, bracketY + 14, COLORS.dim, 'center'); + + // ---- Right: J(v) plot with axes and annotations ---- + const pX = splitX + 40, pY = 15, pW = w - pX - 15, pH = h - 55; + const vMin = -3, vMax = 4; + const lossMin = -2, lossMax = 35; + const xOf = vv => pX + (vv - vMin) / (vMax - vMin) * pW; + const yOf = ll => pY + (1 - (ll - lossMin) / (lossMax - lossMin)) * pH; + + // Background shading: free space region (ν < -0.78) + ctx.fillStyle = 'rgba(152, 195, 121, 0.07)'; + ctx.fillRect(xOf(vMin), pY, xOf(-0.78) - xOf(vMin), pH); + ctx.fillStyle = 'rgba(224, 108, 117, 0.05)'; + ctx.fillRect(xOf(0), pY, xOf(vMax) - xOf(0), pH); + + // Grid with tick labels + ctx.strokeStyle = COLORS.grid; + ctx.lineWidth = 1; + for (let vv = -3; vv <= 4; vv++) { + ctx.beginPath(); ctx.moveTo(xOf(vv), pY); ctx.lineTo(xOf(vv), pY + pH); ctx.stroke(); + labelText(ctx, String(vv), xOf(vv), pY + pH + 12, COLORS.dim, 'center'); + } + for (let ll = 0; ll <= 30; ll += 10) { + ctx.beginPath(); ctx.moveTo(pX, yOf(ll)); ctx.lineTo(pX + pW, yOf(ll)); ctx.stroke(); + labelText(ctx, String(ll), pX - 4, yOf(ll), COLORS.dim, 'right', 'middle'); + } + // Frame + ctx.strokeStyle = COLORS.grid; + ctx.strokeRect(pX, pY, pW, pH); + + // Axis titles + labelText(ctx, 'ν (obstacle above LOS →)', pX + pW / 2, pY + pH + 28, COLORS.dim, 'center'); + ctx.save(); + ctx.translate(pX - 30, pY + pH / 2); + ctx.rotate(-Math.PI / 2); + labelText(ctx, 'diffraction loss (dB)', 0, 0, COLORS.dim, 'center'); + ctx.restore(); + + // Reference: grazing line at ν=0, 6 dB. + ctx.save(); + ctx.strokeStyle = COLORS.dim; + ctx.setLineDash([3, 3]); + ctx.beginPath(); ctx.moveTo(xOf(0), pY); ctx.lineTo(xOf(0), pY + pH); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(pX, yOf(6)); ctx.lineTo(pX + pW, yOf(6)); ctx.stroke(); + ctx.restore(); + labelText(ctx, 'grazing (ν=0, 6 dB)', xOf(0) + 4, yOf(6) - 4, COLORS.dim); + + // Region labels + labelText(ctx, 'free space', xOf(-2), pY + 14, COLORS.green); + labelText(ctx, 'deep shadow', xOf(3.2), pY + 14, COLORS.red); + + // J(v) curve + ctx.strokeStyle = COLORS.blue; + ctx.lineWidth = 2; + ctx.beginPath(); + for (let i = 0; i <= 200; i++) { + const vv = vMin + (vMax - vMin) * i / 200; + const ll = J_knifeEdge(vv); + const x = xOf(vv); + const y = yOf(ll); + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.stroke(); + labelText(ctx, 'J(ν) — ITU-R P.526', xOf(1.2), yOf(12), COLORS.blue); + + // Current point with readout annotation + const px = xOf(v), py = yOf(lossDb); + ctx.fillStyle = COLORS.yellow; + ctx.beginPath(); ctx.arc(px, py, 5, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = COLORS.yellow; + ctx.setLineDash([2, 3]); + ctx.beginPath(); + ctx.moveTo(px, py); ctx.lineTo(px, pY + pH); + ctx.moveTo(px, py); ctx.lineTo(pX, py); + ctx.stroke(); + ctx.setLineDash([]); + labelText(ctx, `ν = ${v.toFixed(2)}, ${lossDb.toFixed(1)} dB`, px + 8, py - 8, COLORS.yellow); + + s.setReadout('v', v.toFixed(2)); + s.setReadout('loss', `${lossDb.toFixed(1)} dB`); + + requestAnimationFrame(draw); + } + draw(); + } + + // ---------- Scene 5: Rounded obstacle ---------- + function sceneRounded() { + const s = scene('scene-round', { + height: 320, + controls: [ + { key: 'h', label: 'Hill height above LOS', min: -10, max: 50, value: 10, unit: ' m', format: v => v.toFixed(1) + ' m' }, + { key: 'radius', label: 'Hill radius of curvature', min: 20, max: 5000, value: 500, unit: ' m', format: v => v.toFixed(0) + ' m' }, + { key: 'freq', label: 'Frequency', min: Math.log10(50e6), max: Math.log10(10e9), value: Math.log10(1.296e9), step: 0.01, + format: v => { + const f = Math.pow(10, v); + return f >= 1e9 ? (f / 1e9).toFixed(2) + ' GHz' : (f / 1e6).toFixed(0) + ' MHz'; + } }, + ], + readout: [ + { key: 'knife', label: 'Knife-edge loss' }, + { key: 'extra', label: 'Rounding penalty' }, + { key: 'total', label: 'Total' }, + ], + caption: 'Real hills aren’t knife-edges. A rounded crest reflects more energy sideways and downward rather than diffracting cleanly around the top, adding extra loss on top of the knife-edge baseline.', + threeCol: true, + }); + if (!s) return; + + function draw() { + const { w, h } = s.getSize(); + const ctx = s.ctx; + clear(ctx, w, h); + const freq = Math.pow(10, s.values.freq); + const lambda = C / freq; + const dM = 30_000; + const d1 = dM / 2, d2 = dM / 2; + + const margin = 30; + const yLOS = h * 0.5; + const xA = margin, xB = w - margin; + const pxPerM = 3; + + ctx.strokeStyle = COLORS.grid; + ctx.beginPath(); ctx.moveTo(xA, yLOS); ctx.lineTo(xB, yLOS); ctx.stroke(); + + const obsH = s.values.h; + const radius = s.values.radius; + const obsX = (xA + xB) / 2; + const obsTopY = yLOS - obsH * pxPerM; + + // Draw rounded hill, approximate as a wide rounded arch whose curvature we show by width relative to scene + const visualRadiusPx = Math.min(300, radius * 0.08); + ctx.fillStyle = '#3a4150'; + ctx.strokeStyle = COLORS.dim; + ctx.beginPath(); + ctx.moveTo(obsX - 200, h - 10); + ctx.bezierCurveTo( + obsX - visualRadiusPx, h - 10, + obsX - visualRadiusPx, obsTopY, + obsX, obsTopY, + ); + ctx.bezierCurveTo( + obsX + visualRadiusPx, obsTopY, + obsX + visualRadiusPx, h - 10, + obsX + 200, h - 10, + ); + ctx.lineTo(obsX + 200, h); + ctx.lineTo(obsX - 200, h); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + + // Knife-edge loss + const v = obsH * Math.sqrt(2 * (d1 + d2) / (lambda * d1 * d2)); + const J = J_knifeEdge(v); + + // Rounded addition (simplified from ITU-R P.526, method 4 spherical earth): + // m = R*((d1+d2)/(d1*d2))^(1/3) * (π/λ)^(2/3) + // T(m) ≈ 7.2*sqrt(m) - (2 - 12.5*n)*m + 3.6*m^1.5 - 0.8*m^2 (with n ≈ v) + // Simplify: T ≈ 7.2*sqrt(m) with bounded growth. + const m = radius * Math.pow((d1 + d2) / (d1 * d2), 1 / 3) * Math.pow(Math.PI / lambda, 2 / 3); + let T = obsH > 0 ? 7.2 * Math.sqrt(m) : 0; + if (T > 30) T = 30; + const total = J + T; + + const color = total > 15 ? COLORS.red : total > 6 ? COLORS.yellow : COLORS.cyan; + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.setLineDash(obsH > 0 ? [6, 4] : []); + ctx.beginPath(); + ctx.moveTo(xA, yLOS); + ctx.lineTo(xB, yLOS); + ctx.stroke(); + ctx.setLineDash([]); + + drawAntenna(ctx, xA, yLOS, 0, COLORS.orange); + drawAntenna(ctx, xB, yLOS, 0, COLORS.orange); + + s.setReadout('knife', `${J.toFixed(1)} dB`); + s.setReadout('extra', `${T.toFixed(1)} dB`); + s.setReadout('total', `${total.toFixed(1)} dB`); + + requestAnimationFrame(draw); + } + draw(); + } + + // ---------- Scene 6: Refractivity profile ---------- + // Standard atmosphere: N = 315 * exp(-0.136 * h_km). dN/dh ≈ -40 N/km near surface. + function sceneRefractivity() { + const s = scene('scene-refractivity', { + height: 340, + controls: [ + { key: 'surface', label: 'Surface N-units (N_s)', min: 280, max: 420, value: 315, unit: '', format: v => v.toFixed(0) + ' N' }, + { key: 'slope', label: 'Lapse rate dN/dh', min: -200, max: 20, value: -40, unit: ' N/km', format: v => v.toFixed(0) + ' N/km' }, + ], + readout: [ + { key: 'regime', label: 'Regime' }, + { key: 'keff', label: 'Effective Earth factor k' }, + ], + caption: 'Refractivity N measures how much slower radio waves travel than in a vacuum, in parts per million. It falls off with altitude. The steeper that fall, the more rays curve downward, and at −157 N/km the ray curves as hard as the Earth itself.', + }); + if (!s) return; + + function draw() { + const { w, h } = s.getSize(); + const ctx = s.ctx; + clear(ctx, w, h); + + const margin = { l: 60, r: 20, t: 20, b: 40 }; + const pX = margin.l, pY = margin.t; + const pW = w - margin.l - margin.r; + const pH = h - margin.t - margin.b; + + ctx.strokeStyle = COLORS.grid; + ctx.strokeRect(pX, pY, pW, pH); + + // x = N (200..450), y = altitude km (0..5) + const nMin = 180, nMax = 440; + const altMax = 5; + const xOf = n => pX + (n - nMin) / (nMax - nMin) * pW; + const yOf = a => pY + (1 - a / altMax) * pH; + + // gridlines + ctx.strokeStyle = COLORS.grid; + ctx.lineWidth = 1; + for (let n = 200; n <= 440; n += 40) { + ctx.beginPath(); ctx.moveTo(xOf(n), pY); ctx.lineTo(xOf(n), pY + pH); ctx.stroke(); + labelText(ctx, String(n), xOf(n), pY + pH + 14, COLORS.dim, 'center'); + } + for (let a = 0; a <= 5; a++) { + ctx.beginPath(); ctx.moveTo(pX, yOf(a)); ctx.lineTo(pX + pW, yOf(a)); ctx.stroke(); + labelText(ctx, `${a} km`, pX - 6, yOf(a), COLORS.dim, 'right', 'middle'); + } + labelText(ctx, 'N-units', pX + pW / 2, pY + pH + 28, COLORS.dim, 'center'); + labelText(ctx, 'Altitude', pX - 50, pY - 5, COLORS.dim); + + // Standard atmosphere reference (dashed) + ctx.strokeStyle = COLORS.dim; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + for (let i = 0; i <= 50; i++) { + const a = altMax * i / 50; + const n = 315 * Math.exp(-0.136 * a); + const x = xOf(n), y = yOf(a); + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.stroke(); + ctx.setLineDash([]); + labelText(ctx, 'standard', xOf(315 * Math.exp(-0.136 * 4)), yOf(4) - 4, COLORS.dim); + + // User profile: linear with slope + const nSurface = s.values.surface; + const slope = s.values.slope; // per km + ctx.strokeStyle = COLORS.cyan; + ctx.lineWidth = 2.5; + ctx.beginPath(); + for (let i = 0; i <= 50; i++) { + const a = altMax * i / 50; + const n = nSurface + slope * a; + const x = xOf(n), y = yOf(a); + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.stroke(); + + // Threshold bands: super-refraction < -79, trapping < -157 + ctx.fillStyle = 'rgba(224, 108, 117, 0.08)'; + // annotate regime + let regime = 'standard refraction'; + if (slope > -40) regime = 'sub-refractive'; + if (slope <= -79) regime = 'super-refraction'; + if (slope <= -157) regime = 'TRAPPING (duct forms)'; + // k factor: 1/(1 + R_km * 1e-6 * dN/dh) with R_km = 6371 + const k = 1 / (1 + (R_EARTH / 1000) * 1e-6 * slope); + s.setReadout('regime', regime); + s.setReadout('keff', k.toFixed(2)); + + requestAnimationFrame(draw); + } + draw(); + } + + // ---------- Scene 7 / 8: Ray tracing + super-refraction ---------- + // Launch horizontal rays above a curved Earth; ray curvature from refractivity lapse. + // Using effective-earth approximation: in the k-factor frame, rays go straight and Earth is + // scaled by k. Equivalent. + function sceneRayTrace() { + const s = scene('scene-raytrace', { + height: 360, + controls: [ + { key: 'slope', label: 'dN/dh', min: -300, max: 0, value: -40, unit: ' N/km', format: v => v.toFixed(0) + ' N/km' }, + { key: 'height', label: 'Launch height', min: 2, max: 200, value: 20, unit: ' m', format: v => v.toFixed(0) + ' m' }, + { key: 'angle', label: 'Launch angle', min: -0.5, max: 0.5, value: 0, unit: '°', format: v => v.toFixed(2) + '°' }, + ], + readout: [ + { key: 'k', label: 'k (effective Earth)' }, + { key: 'regime', label: 'Regime' }, + ], + caption: 'A real atmosphere bends rays. The gentler the lapse, the less bend. Crank the lapse past −157 N/km and something dramatic happens: the ray curves tighter than the Earth does, and what was a horizon becomes an upward-bending surface that traps the signal.', + threeCol: true, + }); + if (!s) return; + + function draw() { + const { w, h } = s.getSize(); + const ctx = s.ctx; + clear(ctx, w, h); + + const slope = s.values.slope; + const Rkm = R_EARTH / 1000; + const k = 1 / (1 + Rkm * 1e-6 * slope); + // Effective-Earth radius. When k is negative (trapping), Reff flips and the + // scene should show an essentially flat-curving ground — clamp huge. + let Reff = k * R_EARTH; + if (k < 0 || !isFinite(k)) Reff = 1e12; + + const margin = { l: 60, r: 20, t: 25, b: 40 }; + const pW = w - margin.l - margin.r; + const pH = h - margin.t - margin.b; + + const distKm = 120; + const distM = distKm * 1000; + const maxDropM = 500; // vertical range below the launch-point tangent + const launchAltM = 80; // launch ground sits a bit below top to leave room for antenna + sky + + const pxPerM_x = pW / distM; + const pxPerM_y = pH / (maxDropM + launchAltM); + + const xLaunch = margin.l + 10; + const launchGroundY = margin.t + launchAltM * pxPerM_y; + + // Ground surface: drops with d²/(2·Reff) going right. Clipped to panel. + const bottomY = margin.t + pH; + const groundYatDx = dx_m => Math.min( + bottomY, + launchGroundY + (dx_m * dx_m) / (2 * Reff) * pxPerM_y, + ); + + // Ground fill (clipped curve) + ctx.fillStyle = '#2b3039'; + ctx.beginPath(); + const NN = 160; + for (let i = 0; i <= NN; i++) { + const dx_m = distM * i / NN; + const x = xLaunch + dx_m * pxPerM_x; + const y = groundYatDx(dx_m); + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.lineTo(margin.l + pW, bottomY); + ctx.lineTo(margin.l, bottomY); + ctx.closePath(); + ctx.fill(); + + // Ground line (same clipped curve, stroked) + ctx.strokeStyle = COLORS.grid; + ctx.lineWidth = 1.2; + ctx.beginPath(); + for (let i = 0; i <= NN; i++) { + const dx_m = distM * i / NN; + const x = xLaunch + dx_m * pxPerM_x; + const y = groundYatDx(dx_m); + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.stroke(); + + // Altitude gridlines above launch — skip any within 15 m of the antenna top + // so the tick labels don't collide with the TX label. + const launchTopAlt = s.values.height; + ctx.strokeStyle = COLORS.grid; + ctx.globalAlpha = 0.4; + ctx.setLineDash([2, 4]); + for (let alt = 50; alt <= 300; alt += 50) { + if (Math.abs(alt - launchTopAlt) < 15) continue; + const y = launchGroundY - alt * pxPerM_y; + if (y > margin.t + 2) { + ctx.beginPath(); ctx.moveTo(margin.l, y); ctx.lineTo(margin.l + pW, y); ctx.stroke(); + labelText(ctx, `${alt} m`, margin.l - 4, y, COLORS.dim, 'right', 'middle'); + } + } + // Distance ticks on the horizon line (not on the ground curve, which can be below panel) + for (let d = 20; d <= distKm; d += 20) { + const x = xLaunch + d * 1000 * pxPerM_x; + labelText(ctx, `${d} km`, x, margin.t + pH + 14, COLORS.dim, 'center'); + } + ctx.setLineDash([]); + ctx.globalAlpha = 1; + + // Tangent at launch (dashed), showing what a straight ray would do + ctx.strokeStyle = COLORS.dim; + ctx.setLineDash([3, 5]); + ctx.beginPath(); + ctx.moveTo(xLaunch, launchGroundY - s.values.height * pxPerM_y); + ctx.lineTo(margin.l + pW, launchGroundY - s.values.height * pxPerM_y); + ctx.stroke(); + ctx.setLineDash([]); + + // Ray: straight in effective-Earth frame. + const startX = xLaunch; + const startY = launchGroundY - s.values.height * pxPerM_y; + const angleRad = s.values.angle * Math.PI / 180; + const rayColor = slope <= -157 ? COLORS.red : slope <= -79 ? COLORS.yellow : COLORS.cyan; + ctx.strokeStyle = rayColor; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(startX, startY); + const stepM = 200; + let hit = null; + for (let dxm = stepM; dxm <= distM; dxm += stepM) { + const dym = Math.tan(angleRad) * dxm; + const x = startX + dxm * pxPerM_x; + const y = startY - dym * pxPerM_y; + const groundY = groundYatDx(dxm); + if (y >= groundY) { + // linear interpolate intersection between previous step and this step + const prevDxm = dxm - stepM; + const prevY = startY - Math.tan(angleRad) * prevDxm * pxPerM_y; + const prevGround = groundYatDx(prevDxm); + const t = (prevGround - prevY) / ((y - prevY) - (groundY - prevGround)); + const hitDxm = prevDxm + t * stepM; + const hitX = startX + hitDxm * pxPerM_x; + const hitY = groundYatDx(hitDxm); + ctx.lineTo(hitX, hitY); + hit = { x: hitX, y: hitY, dxm: hitDxm }; + break; + } + ctx.lineTo(x, y); + } + if (!hit) { + const x = margin.l + pW; + const dxm = distM; + const dym = Math.tan(angleRad) * dxm; + ctx.lineTo(x, startY - dym * pxPerM_y); + } + ctx.stroke(); + if (hit) { + ctx.fillStyle = rayColor; + ctx.beginPath(); ctx.arc(hit.x, hit.y, 5, 0, Math.PI * 2); ctx.fill(); + labelText(ctx, `${(hit.dxm / 1000).toFixed(1)} km`, hit.x, hit.y - 10, rayColor, 'center'); + } + + // Antenna + drawAntenna(ctx, xLaunch, launchGroundY, s.values.height * pxPerM_y, COLORS.orange); + labelText(ctx, 'TX', xLaunch + 8, launchGroundY - s.values.height * pxPerM_y - 6, COLORS.orange); + + // Legend + labelText(ctx, 'effective-Earth view (ground curves away at d²/2R)', margin.l + 4, margin.t + 14, COLORS.dim); + + // Regime readouts + let regime = 'standard'; + if (slope > -40) regime = 'sub-refractive'; + if (slope <= -79) regime = 'super-refractive'; + if (slope <= -157) regime = 'trapping'; + s.setReadout('k', k < 0 || !isFinite(k) ? '∞' : k.toFixed(2)); + s.setReadout('regime', regime); + + requestAnimationFrame(draw); + } + draw(); + } + + // ---------- Scene 9: Duct formation with multiple rays ---------- + function sceneDuct() { + const s = scene('scene-duct', { + height: 360, + controls: [ + { key: 'thickness', label: 'Duct thickness', min: 20, max: 500, value: 150, unit: ' m', format: v => v.toFixed(0) + ' m' }, + { key: 'strength', label: 'Duct strength (ΔN)', min: 5, max: 80, value: 30, unit: '', format: v => '−' + v.toFixed(0) + ' N' }, + { key: 'launch', label: 'Launch elevation angle', min: -1, max: 2, value: 0.2, step: 0.01, unit: '°', format: v => v.toFixed(2) + '°' }, + ], + readout: [ + { key: 'trapped', label: 'Trapped rays' }, + { key: 'critical', label: 'Critical angle' }, + ], + caption: 'A duct is a thin layer with steep downward refractivity. Rays launched within a narrow elevation window hit the top of the layer, bend back down, bounce off the ground or a lower layer, and repeat. The signal hops along inside the duct for hundreds of kilometers, far past the ordinary radio horizon.', + threeCol: true, + }); + if (!s) return; + + function draw() { + const { w, h } = s.getSize(); + const ctx = s.ctx; + clear(ctx, w, h); + + const margin = { l: 40, r: 15, t: 15, b: 30 }; + const pW = w - margin.l - margin.r; + const pH = h - margin.t - margin.b; + + const maxAltM = 800; + const distKm = 300; + const pxPerM_x = pW / (distKm * 1000); + const pxPerM_y = pH / maxAltM; + const groundY = margin.t + pH; + + const duct = s.values.thickness; + const dN = s.values.strength; + + // Ground + ctx.fillStyle = '#2b3039'; + ctx.fillRect(margin.l, groundY, pW, h - groundY); + ctx.strokeStyle = COLORS.grid; + ctx.beginPath(); ctx.moveTo(margin.l, groundY); ctx.lineTo(margin.l + pW, groundY); ctx.stroke(); + + // Duct layer highlight + ctx.fillStyle = 'rgba(86, 182, 194, 0.09)'; + ctx.fillRect(margin.l, groundY - duct * pxPerM_y, pW, duct * pxPerM_y); + ctx.strokeStyle = COLORS.cyan; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + ctx.moveTo(margin.l, groundY - duct * pxPerM_y); + ctx.lineTo(margin.l + pW, groundY - duct * pxPerM_y); + ctx.stroke(); + ctx.setLineDash([]); + labelText(ctx, `duct top (${duct} m)`, margin.l + pW - 6, groundY - duct * pxPerM_y - 4, COLORS.cyan, 'right'); + + // Within the duct, effective-earth ray curvature corresponds to dN/dh = -dN/thickness_km. + // Outside, standard atmosphere. + const lapseInside = -dN / (duct / 1000); // N/km + const lapseOutside = -40; + const insideCurvature = -lapseInside * 1e-9; // 1/m, positive = curves downward + const outsideCurvature = -lapseOutside * 1e-9; + // Net downward curvature = insideCurvature - earthCurvature (1/R). For trapping, insideCurvature > 1/R. + const earthCurvature = 1 / R_EARTH; + // Critical angle (approx, for a surface duct): α_c = sqrt(2*ΔN*1e-6) + const alphaCrit = Math.sqrt(Math.max(0, 2 * dN * 1e-6)); + const alphaCritDeg = alphaCrit * 180 / Math.PI; + + // Trace multiple rays from left side at various angles around launch angle. + const angles = []; + const launchDeg = s.values.launch; + for (let i = -3; i <= 3; i++) angles.push(launchDeg + i * 0.15); + + let trapped = 0; + angles.forEach(aDeg => { + const aRad = aDeg * Math.PI / 180; + // Ray in meters: y(x), starting at (0, 5m). + let xm = 0, ym = 5; + let vyx = Math.tan(aRad); // dy/dx + const stepXm = 500; + const isTrapped = Math.abs(aRad) < alphaCrit && dN > 0; + if (isTrapped) trapped++; + const color = isTrapped ? COLORS.green : COLORS.orange; + ctx.strokeStyle = color; + ctx.lineWidth = 1.5; + ctx.globalAlpha = 0.9; + ctx.beginPath(); + ctx.moveTo(margin.l + xm * pxPerM_x, groundY - ym * pxPerM_y); + while (xm < distKm * 1000) { + // Pick curvature based on current altitude vs duct top + const insideDuct = ym < duct; + const kappa = insideDuct + ? insideCurvature - earthCurvature + : outsideCurvature - earthCurvature; + // Update vy over step + // d²y/dx² = -kappa (flipping sign so positive kappa bends down). In effective-earth frame we already subtracted earthCurvature; the remaining bend is refraction-relative-to-earth. Use: dy/dx changes by -kappa * dx (trapping when insideCurvature > earthCurvature, i.e. kappa > 0 -> vy decreases -> ray bends down). + vyx += -kappa * stepXm; + ym += vyx * stepXm; + xm += stepXm; + if (ym < 0) { + // ground reflection + ym = -ym; + vyx = -vyx; + } + if (ym > maxAltM) { break; } + ctx.lineTo(margin.l + xm * pxPerM_x, groundY - ym * pxPerM_y); + } + ctx.stroke(); + ctx.globalAlpha = 1; + }); + + // antenna at origin + drawAntenna(ctx, margin.l, groundY, 12, COLORS.orange); + + s.setReadout('trapped', `${trapped} of ${angles.length}`); + s.setReadout('critical', `±${alphaCritDeg.toFixed(2)}°`); + + requestAnimationFrame(draw); + } + draw(); + } + + // ---------- Scene 10: Duct types ---------- + function sceneDuctTypes() { + const root = document.getElementById('scene-ducttypes'); + if (!root) return; + root.classList.add('nlos-scene'); + + const canvas = document.createElement('canvas'); + canvas.style.width = '100%'; + canvas.style.height = '320px'; + root.appendChild(canvas); + + const btnRow = document.createElement('div'); + btnRow.className = 'nlos-buttons'; + const types = [ + { key: 'surface', label: 'Surface duct', desc: 'Steep refractivity gradient begins right at the ground. Common over warm seas and after nocturnal cooling over land.' }, + { key: 'elevated', label: 'Elevated duct', desc: 'Trapped layer sits above the surface. Both endpoints of the link may need to be inside it, or close enough that their rays enter the layer.' }, + { key: 'evap', label: 'Evaporation duct', desc: 'Very thin (5–40 m) duct formed by the humidity gradient immediately above warm water. Dominates over-water microwave propagation.' }, + ]; + let active = 'surface'; + const buttons = {}; + types.forEach(t => { + const b = document.createElement('button'); + b.textContent = t.label; + b.setAttribute('aria-pressed', t.key === active ? 'true' : 'false'); + b.addEventListener('click', () => { + active = t.key; + Object.entries(buttons).forEach(([k, btn]) => btn.setAttribute('aria-pressed', k === active ? 'true' : 'false')); + cap.textContent = types.find(x => x.key === active).desc; + }); + buttons[t.key] = b; + btnRow.appendChild(b); + }); + root.appendChild(btnRow); + + const cap = document.createElement('div'); + cap.className = 'nlos-caption'; + cap.textContent = types.find(x => x.key === active).desc; + root.appendChild(cap); + + const { ctx, getSize } = setupCanvas(canvas); + + function draw() { + const { w, h } = getSize(); + clear(ctx, w, h); + + // Two stacked/side plots: refractivity profile (left) and ray paths (right). + const leftW = w * 0.40; + + // ---- Left panel: N(h) profile ---- + const pX = 70, pY = 30, pW = leftW - 85, pH = h - 60; + const altMax = 500; + const xMinN = 280, xMaxN = 400; + const xOfN = n => pX + (n - xMinN) / (xMaxN - xMinN) * pW; + const yOf = a => pY + (1 - a / altMax) * pH; + + // Panel title + labelText(ctx, 'Refractivity profile', pX, pY - 14, COLORS.fg); + + // baseline N(h) = 315*exp(-0.136*h_km) modified per duct type + function Nprofile(hM) { + const hk = hM / 1000; + const base = 315 * Math.exp(-0.136 * hk); + if (active === 'surface') { + if (hM < 100) return base - (100 - hM) * 1.0; + return base; + } + if (active === 'elevated') { + if (hM < 200) return base; + if (hM < 350) return base - (hM - 200) * 0.5; + return base - 75; + } + if (active === 'evap') { + if (hM < 30) return base - (30 - hM) * 0.8; + return base; + } + return base; + } + + // Duct band shading (profile) + ctx.fillStyle = 'rgba(86, 182, 194, 0.10)'; + if (active === 'surface') ctx.fillRect(pX, yOf(100), pW, pH - (yOf(100) - pY)); + if (active === 'elevated') ctx.fillRect(pX, yOf(350), pW, yOf(200) - yOf(350)); + if (active === 'evap') ctx.fillRect(pX, yOf(30), pW, pH - (yOf(30) - pY)); + + // Gridlines + altitude labels + ctx.strokeStyle = COLORS.grid; + for (let a = 0; a <= 500; a += 100) { + ctx.beginPath(); ctx.moveTo(pX, yOf(a)); ctx.lineTo(pX + pW, yOf(a)); ctx.stroke(); + labelText(ctx, `${a}`, pX - 6, yOf(a), COLORS.dim, 'right', 'middle'); + } + for (let n = xMinN; n <= xMaxN; n += 20) { + ctx.beginPath(); ctx.moveTo(xOfN(n), pY); ctx.lineTo(xOfN(n), pY + pH); ctx.stroke(); + labelText(ctx, `${n}`, xOfN(n), pY + pH + 12, COLORS.dim, 'center'); + } + ctx.strokeRect(pX, pY, pW, pH); + + // Axis titles + labelText(ctx, 'altitude (m)', pX - 45, pY + pH / 2 - 50, COLORS.dim, 'left'); + ctx.save(); + ctx.translate(pX - 46, pY + pH / 2); + ctx.rotate(-Math.PI / 2); + labelText(ctx, 'altitude (m)', 0, 0, COLORS.dim, 'center'); + ctx.restore(); + labelText(ctx, 'N-units (refractivity)', pX + pW / 2, pY + pH + 26, COLORS.dim, 'center'); + + // N(h) curve + ctx.strokeStyle = COLORS.cyan; + ctx.lineWidth = 2; + ctx.beginPath(); + for (let i = 0; i <= 80; i++) { + const a = altMax * i / 80; + const n = Nprofile(a); + const x = xOfN(n); + const y = yOf(a); + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.stroke(); + + // Annotation arrow pointing at the steep-gradient zone + let annotAltM, annotText; + if (active === 'surface') { annotAltM = 50; annotText = 'steep drop → traps signal below 100 m'; } + else if (active === 'elevated') { annotAltM = 275; annotText = 'steep drop in band → traps signal inside'; } + else { annotAltM = 15; annotText = 'steep drop in lowest ~30 m'; } + const annN = Nprofile(annotAltM); + const ax = xOfN(annN), ay = yOf(annotAltM); + ctx.strokeStyle = COLORS.yellow; + ctx.fillStyle = COLORS.yellow; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(ax + 30, ay); + ctx.lineTo(ax + 6, ay); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(ax + 6, ay); + ctx.lineTo(ax + 12, ay - 4); + ctx.lineTo(ax + 12, ay + 4); + ctx.closePath(); + ctx.fill(); + labelText(ctx, annotText, ax + 34, ay, COLORS.yellow, 'left', 'middle'); + + // ---- Right panel: ray paths ---- + const qX = leftW + 55, qY = 30, qW = w - qX - 15, qH = h - 60; + const distKm = 200; + const xrayOf = d => qX + d / distKm * qW; + const yrayOf = a => qY + (1 - a / altMax) * qH; + + labelText(ctx, 'Signal paths inside the duct', qX, qY - 14, COLORS.fg); + + // Duct band shading (ray panel) + ctx.fillStyle = 'rgba(86, 182, 194, 0.10)'; + if (active === 'surface') ctx.fillRect(qX, yrayOf(100), qW, qH - (yrayOf(100) - qY)); + if (active === 'elevated') ctx.fillRect(qX, yrayOf(350), qW, yrayOf(200) - yrayOf(350)); + if (active === 'evap') ctx.fillRect(qX, yrayOf(30), qW, qH - (yrayOf(30) - qY)); + + // Gridlines + tick labels + ctx.strokeStyle = COLORS.grid; + for (let a = 0; a <= 500; a += 100) { + ctx.beginPath(); ctx.moveTo(qX, yrayOf(a)); ctx.lineTo(qX + qW, yrayOf(a)); ctx.stroke(); + labelText(ctx, `${a}`, qX - 6, yrayOf(a), COLORS.dim, 'right', 'middle'); + } + for (let d = 0; d <= distKm; d += 50) { + ctx.beginPath(); ctx.moveTo(xrayOf(d), qY); ctx.lineTo(xrayOf(d), qY + qH); ctx.stroke(); + labelText(ctx, `${d}`, xrayOf(d), qY + qH + 12, COLORS.dim, 'center'); + } + ctx.strokeRect(qX, qY, qW, qH); + labelText(ctx, 'distance (km)', qX + qW / 2, qY + qH + 26, COLORS.dim, 'center'); + ctx.save(); + ctx.translate(qX - 46, qY + qH / 2); + ctx.rotate(-Math.PI / 2); + labelText(ctx, 'altitude (m)', 0, 0, COLORS.dim, 'center'); + ctx.restore(); + + // Ground + ctx.fillStyle = '#2b3039'; + ctx.fillRect(qX, yrayOf(0), qW, qY + qH - yrayOf(0)); + + // Duct-band labels + if (active === 'surface') labelText(ctx, 'duct region (ground to ~100 m)', qX + 8, yrayOf(100) - 4, COLORS.cyan); + if (active === 'elevated') labelText(ctx, 'duct region (200–350 m)', qX + 8, yrayOf(350) - 4, COLORS.cyan); + if (active === 'evap') labelText(ctx, 'duct region (sea surface to ~30 m)', qX + 8, yrayOf(30) - 4, COLORS.cyan); + + // Illustrative signal paths (schematic) + ctx.strokeStyle = COLORS.green; + ctx.lineWidth = 1.5; + if (active === 'surface') { + for (let i = 0; i < 4; i++) { + ctx.beginPath(); + const phase = i * 50; + for (let d = 0; d <= distKm; d++) { + const a = 10 + 80 * Math.abs(Math.sin((d + phase) * 0.06)); + const x = xrayOf(d), y = yrayOf(a); + if (d === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.stroke(); + } + } else if (active === 'elevated') { + for (let i = 0; i < 3; i++) { + ctx.beginPath(); + const phase = i * 80; + for (let d = 0; d <= distKm; d++) { + const a = 275 + 65 * Math.sin((d + phase) * 0.07); + const x = xrayOf(d), y = yrayOf(a); + if (d === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.stroke(); + } + } else { + for (let i = 0; i < 5; i++) { + ctx.beginPath(); + const phase = i * 30; + for (let d = 0; d <= distKm; d++) { + const a = 3 + 22 * Math.abs(Math.sin((d + phase) * 0.2)); + const x = xrayOf(d), y = yrayOf(a); + if (d === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.stroke(); + } + } + + // Legend at bottom-right of ray panel + const lgX = qX + qW - 160, lgY = qY + 4; + ctx.fillStyle = 'rgba(40, 44, 52, 0.85)'; + ctx.fillRect(lgX, lgY, 154, 26); + ctx.strokeStyle = COLORS.grid; + ctx.strokeRect(lgX, lgY, 154, 26); + ctx.strokeStyle = COLORS.green; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(lgX + 8, lgY + 13); ctx.lineTo(lgX + 28, lgY + 13); + ctx.stroke(); + labelText(ctx, 'trapped signal path', lgX + 34, lgY + 13, COLORS.fg, 'left', 'middle'); + + requestAnimationFrame(draw); + } + draw(); + } + + // ---------- Scene 11: Real-data path ---------- + function sceneReal() { + const root = document.getElementById('scene-real'); + if (!root) return; + root.classList.add('nlos-scene'); + const canvas = document.createElement('canvas'); + canvas.style.width = '100%'; + canvas.style.height = '300px'; + root.appendChild(canvas); + const cap = document.createElement('div'); + cap.className = 'nlos-caption'; + cap.textContent = 'A stylized rendering of a 10 GHz path from my QTH to the NTMS beacon on the Texas Woman’s University dorms. No line-of-sight exists on the terrain profile, yet the path works on ducting days. The rendered profile is schematic; real-time forecasts live at prop.w5isp.com.'; + root.appendChild(cap); + + const { ctx, getSize } = setupCanvas(canvas); + + // Real SRTM 30 m elevation samples along the actual 65 km great-circle path + // from the W5ISP QTH (33.2462°N, -96.4265°W) to the W5HN/B beacon on the + // TWU dorms (33.2294°N, -97.1276°W). Values in meters MSL. + const realElev = [ + 181, 181, 174, 183, 192, 210, 200, 188, 178, 197, + 220, 213, 208, 201, 221, 229, 217, 189, 185, 171, + 181, 185, 172, 184, 190, 182, 161, 176, 183, 193, 240 + ]; + const pathKm = 65; + + // Interpolate to 101 smooth points while preserving the peaks/valleys. + const terrainRaw = []; + for (let i = 0; i <= 100; i++) { + const t = i / 100 * (realElev.length - 1); + const i0 = Math.floor(t); + const i1 = Math.min(i0 + 1, realElev.length - 1); + const f = t - i0; + terrainRaw.push(realElev[i0] * (1 - f) + realElev[i1] * f); + } + + // Add Earth curvature on top of the real elevations. A path-profile diagram + // typically uses the k = 4/3 effective Earth so the mid-path bulge corresponds + // to what a straight ray (under standard atmosphere) actually sees. + const kEff = 4 / 3; + const Reff = kEff * R_EARTH; + const pathM = pathKm * 1000; + const terrain = []; + for (let i = 0; i <= 100; i++) { + const d = i / 100 * pathM; // meters along path from source + const bulge = (d * (pathM - d)) / (2 * Reff); + terrain.push(terrainRaw[i] + bulge); + } + + // Mast heights (approx, from the prop.w5isp.com path URL: 30 ft src, 280 ft dst). + const hA = 9; // ~30 ft source mast + const hB = 85; // ~280 ft TWU dorm tower + + // Work out display range based on curved profile. + const minElev = Math.min(...terrain); + const maxFeature = Math.max(...terrain) + hB; + const baseline = minElev - 20; + const altMax = (maxFeature - baseline) + 120; + const maxBulge = (pathM / 2) * (pathM / 2) / (2 * Reff); + + function draw(t) { + const { w, h } = getSize(); + clear(ctx, w, h); + const margin = { l: 46, r: 15, t: 18, b: 32 }; + const pW = w - margin.l - margin.r; + const pH = h - margin.t - margin.b; + const xOf = i => margin.l + i / 100 * pW; + // Elevations are passed in as real meters; shift by baseline. + const yOf = a => margin.t + (1 - (a - baseline) / altMax) * pH; + + // Altitude axis ticks (every 50 m of real elevation) + ctx.strokeStyle = COLORS.grid; + ctx.globalAlpha = 0.5; + for (let e = Math.ceil(baseline / 50) * 50; e < baseline + altMax; e += 50) { + ctx.setLineDash([2, 4]); + ctx.beginPath(); ctx.moveTo(margin.l, yOf(e)); ctx.lineTo(margin.l + pW, yOf(e)); ctx.stroke(); + ctx.setLineDash([]); + labelText(ctx, `${e} m`, margin.l - 4, yOf(e), COLORS.dim, 'right', 'middle'); + } + // Distance ticks (every 10 km) + for (let d = 0; d <= pathKm; d += 10) { + const x = margin.l + d / pathKm * pW; + labelText(ctx, `${d}`, x, margin.t + pH + 14, COLORS.dim, 'center'); + } + labelText(ctx, 'distance (km)', margin.l + pW / 2, margin.t + pH + 26, COLORS.dim, 'center'); + ctx.globalAlpha = 1; + + // Elevated duct band (real elevation range, e.g. 330–410 m MSL) + const ductBottom = 330, ductTop = 410; + ctx.fillStyle = 'rgba(86, 182, 194, 0.09)'; + ctx.fillRect(margin.l, yOf(ductTop), pW, yOf(ductBottom) - yOf(ductTop)); + ctx.strokeStyle = COLORS.cyan; + ctx.setLineDash([4, 4]); + ctx.beginPath(); ctx.moveTo(margin.l, yOf(ductTop)); ctx.lineTo(margin.l + pW, yOf(ductTop)); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(margin.l, yOf(ductBottom)); ctx.lineTo(margin.l + pW, yOf(ductBottom)); ctx.stroke(); + ctx.setLineDash([]); + labelText(ctx, 'elevated duct', margin.l + 8, yOf(ductTop) + 14, COLORS.cyan); + + // Earth-curvature annotation near the middle + labelText(ctx, `Earth bulge at midpoint: ${maxBulge.toFixed(0)} m (k = 4/3)`, + margin.l + pW / 2, margin.t + pH - 6, COLORS.dim, 'center'); + + // Terrain fill + ctx.fillStyle = '#2b3039'; + ctx.beginPath(); + ctx.moveTo(margin.l, yOf(terrain[0])); + for (let i = 1; i <= 100; i++) ctx.lineTo(xOf(i), yOf(terrain[i])); + ctx.lineTo(margin.l + pW, margin.t + pH); + ctx.lineTo(margin.l, margin.t + pH); + ctx.closePath(); + ctx.fill(); + ctx.strokeStyle = COLORS.grid; + ctx.beginPath(); + ctx.moveTo(margin.l, yOf(terrain[0])); + for (let i = 1; i <= 100; i++) ctx.lineTo(xOf(i), yOf(terrain[i])); + ctx.stroke(); + + // Straight LOS (blocked by terrain) + const elevA = terrain[0] + hA; + const elevB = terrain[100] + hB; + const yA = yOf(elevA); + const yB = yOf(elevB); + ctx.strokeStyle = COLORS.red; + ctx.lineWidth = 1.8; + ctx.setLineDash([6, 4]); + ctx.beginPath(); ctx.moveTo(xOf(0), yA); ctx.lineTo(xOf(100), yB); ctx.stroke(); + ctx.setLineDash([]); + labelText(ctx, 'straight LOS: blocked by terrain', xOf(50), (yA + yB) / 2 - 6, COLORS.red, 'center'); + + // Ducted path: climb into the duct, bounce inside, descend to RX + const ductMid = (ductBottom + ductTop) / 2; + ctx.strokeStyle = COLORS.green; + ctx.lineWidth = 2; + ctx.beginPath(); + const phase = (t || 0) * 0.0008; + for (let i = 0; i <= 100; i++) { + let a; + if (i < 12) { + a = elevA + (ductMid - elevA) * (i / 12); + } else if (i > 88) { + a = elevB + (ductMid - elevB) * ((100 - i) / 12); + } else { + a = ductMid + 28 * Math.sin(i * 0.4 + phase); + } + const x = xOf(i), y = yOf(a); + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.stroke(); + + // Antennas — tower height shown in meters (scaled to screen) + const hPxA = hA / altMax * pH; + const hPxB = hB / altMax * pH; + drawAntenna(ctx, xOf(0), yOf(terrain[0]), hPxA, COLORS.orange); + drawAntenna(ctx, xOf(100), yOf(terrain[100]), hPxB, COLORS.orange); + labelText(ctx, 'My QTH', xOf(0) + 4, yOf(elevA) - 8, COLORS.orange); + labelText(ctx, 'NTMS beacon', xOf(100) - 4, yOf(elevB) - 8, COLORS.orange, 'right'); + + requestAnimationFrame(draw); + } + requestAnimationFrame(draw); + } + + // ---------- boot ---------- + function boot() { + // Tag the article wrapper so CSS vars apply to descendants. + const host = document.querySelector('.nlos, [data-nlos]'); + if (host) host.classList.add('nlos'); + + sceneLOS(); + sceneEarthCurve(); + sceneFresnel(); + sceneKnifeEdge(); + sceneRounded(); + sceneRefractivity(); + sceneRayTrace(); + sceneDuct(); + sceneDuctTypes(); + sceneReal(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot); + } else { + boot(); + } +})(); diff --git a/templates/base.html b/templates/base.html index 630fa2d..3e5c535 100644 --- a/templates/base.html +++ b/templates/base.html @@ -76,7 +76,6 @@