From a96d43cfbe4042d3f7b6e16bc73933e95285a13d Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 22 Apr 2026 16:26:54 -0500 Subject: [PATCH] Add pill backgrounds to on-graph labels and explain PWAT Applies the dark rounded-rect pillLabel treatment to labels that overlay graph content across nlos.js scenes (knife-edge, earth curve, LOS, ray trace, duct, duct types, real terrain). Fixes the "deep shadow" label truncating off the right edge of the J(v) plot by right-aligning it to the plot frame. The readout annotation now flips to the left side when the cursor nears the right edge. Adds a plain-language explanation of PWAT (precipitable water) in why-signals-bend.md so readers don't have to already know the term. --- content/blog/why-signals-bend.md | 4 ++- static/js/nlos.js | 52 ++++++++++++++++++-------------- 2 files changed, 33 insertions(+), 23 deletions(-) diff --git a/content/blog/why-signals-bend.md b/content/blog/why-signals-bend.md index 6d44d7e..49e77e6 100644 --- a/content/blog/why-signals-bend.md +++ b/content/blog/why-signals-bend.md @@ -140,7 +140,9 @@ A few things I didn't expect going in, now painfully clear from the data.
-**Humidity is a trade.** At 10 GHz, 20 to 30 mm of precipitable water is the sweet spot. Too dry and the surface refractivity stays low, too wet and the column destabilizes. At 24 GHz, drier is always better, because the 22.235 GHz water vapor line happily eats whatever signal the atmosphere just delivered. +**Humidity is a trade.** Precipitable water, or PWAT, is the total amount of water vapor in a column of atmosphere above you, expressed as the depth you would get if you condensed all of it into liquid and let it puddle on the ground. A PWAT of 25 mm means that if every water molecule above your antenna fell out of the sky at once, you would be standing in an inch of water. It is the number forecasters use to describe how wet the air is in bulk, and for microwave propagation it is the variable that matters, because the refractivity gradient that bends signals depends on water vapor more than any other single thing. + +At 10 GHz, 20 to 30 mm of PWAT is the sweet spot. Too dry and the surface refractivity stays low, too wet and the column destabilizes. At 24 GHz, drier is always better, because the 22.235 GHz water vapor line happily eats whatever signal the atmosphere just delivered.
diff --git a/static/js/nlos.js b/static/js/nlos.js index ff9ceec..3ee8f1d 100644 --- a/static/js/nlos.js +++ b/static/js/nlos.js @@ -302,8 +302,8 @@ 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'); + pillLabel(ctx, 'A', xA, groundY - h1px - 12, COLORS.orange, 'center', 'middle'); + pillLabel(ctx, 'B', xB, groundY - h2px - 12, COLORS.orange, 'center', 'middle'); s.setReadout('status', blocked ? 'BLOCKED' : 'CLEAR'); s.setReadout('clearance', `${clearanceM.toFixed(1)} m`); @@ -419,7 +419,7 @@ 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'); + pillLabel(ctx, `bulge ${bulgeM.toFixed(1)} m`, cx, peakY - 10, COLORS.yellow, 'center', 'middle'); s.setReadout('horizonA', `${dH1.toFixed(1)} km`); s.setReadout('horizonB', `${dH2.toFixed(1)} km`); @@ -657,7 +657,7 @@ 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'); + pillLabel(ctx, 'line of sight', (xA + xB) / 2, yLOS - 10, COLORS.dim, 'center', 'middle'); // knife edge ctx.fillStyle = '#3a4150'; @@ -689,7 +689,7 @@ 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'); + pillLabel(ctx, `h = ${obsH.toFixed(1)} m`, obsX + 20, (yLOS + obsTopY) / 2, COLORS.yellow, 'left', 'middle'); } // Compute v @@ -718,8 +718,8 @@ 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'); + pillLabel(ctx, 'A', xA, yLOS + 18, COLORS.orange, 'center', 'middle'); + pillLabel(ctx, 'B', xB, yLOS + 18, COLORS.orange, 'center', 'middle'); // Distance bracket along the bottom const bracketY = obsBottomY + 18; @@ -775,11 +775,11 @@ 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); + pillLabel(ctx, 'grazing (ν=0, 6 dB)', xOf(0) + 6, yOf(6) - 10, COLORS.dim, 'left', 'middle'); // Region labels - labelText(ctx, 'free space', xOf(-2), pY + 14, COLORS.green); - labelText(ctx, 'deep shadow', xOf(3.2), pY + 14, COLORS.red); + pillLabel(ctx, 'free space', pX + 6, pY + 14, COLORS.green, 'left', 'middle'); + pillLabel(ctx, 'deep shadow', pX + pW - 6, pY + 14, COLORS.red, 'right', 'middle'); // J(v) curve ctx.strokeStyle = COLORS.blue; @@ -793,7 +793,7 @@ 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); + pillLabel(ctx, 'J(ν) — ITU-R P.526', xOf(1.2), yOf(12), COLORS.blue, 'left', 'middle'); // Current point with readout annotation const px = xOf(v), py = yOf(lossDb); @@ -806,7 +806,15 @@ 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); + { + const rtext = `ν = ${v.toFixed(2)}, ${lossDb.toFixed(1)} dB`; + ctx.save(); + ctx.font = '12px system-ui, sans-serif'; + const rw = ctx.measureText(rtext).width + 16; + ctx.restore(); + const rightSide = px + 8 + rw < pX + pW; + pillLabel(ctx, rtext, rightSide ? px + 8 : px - 8, py - 10, COLORS.yellow, rightSide ? 'left' : 'right', 'middle'); + } s.setReadout('v', v.toFixed(2)); s.setReadout('loss', `${lossDb.toFixed(1)} dB`); @@ -1263,12 +1271,12 @@ 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'); + pillLabel(ctx, `${(hit.dxm / 1000).toFixed(1)} km`, hit.x, hit.y - 12, rayColor, 'center', 'middle'); } // 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); + pillLabel(ctx, 'TX', xLaunch + 8, launchGroundY - s.values.height * pxPerM_y - 10, COLORS.orange, 'left', 'middle'); // Legend labelText(ctx, 'effective-Earth view (ground curves at d²/2R). Past rays fade as you move sliders.', margin.l + 4, margin.t + 14, COLORS.dim); @@ -1338,7 +1346,7 @@ 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'); + pillLabel(ctx, `duct top (${duct} m)`, margin.l + pW - 6, groundY - duct * pxPerM_y - 8, COLORS.cyan, 'right', 'middle'); // Within the duct, effective-earth ray curvature corresponds to dN/dh = -dN/thickness_km. // Outside, standard atmosphere. @@ -1579,7 +1587,7 @@ ctx.lineTo(ax + 12, ay + 4); ctx.closePath(); ctx.fill(); - labelText(ctx, annotText, ax + 34, ay, COLORS.yellow, 'left', 'middle'); + pillLabel(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; @@ -1618,9 +1626,9 @@ 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); + if (active === 'surface') pillLabel(ctx, 'duct region (ground to ~100 m)', qX + 8, yrayOf(100) - 10, COLORS.cyan, 'left', 'middle'); + if (active === 'elevated') pillLabel(ctx, 'duct region (200–350 m)', qX + 8, yrayOf(350) - 10, COLORS.cyan, 'left', 'middle'); + if (active === 'evap') pillLabel(ctx, 'duct region (sea surface to ~30 m)', qX + 8, yrayOf(30) - 10, COLORS.cyan, 'left', 'middle'); // Illustrative signal paths (schematic) ctx.strokeStyle = COLORS.green; @@ -1774,11 +1782,11 @@ 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); + pillLabel(ctx, 'elevated duct', margin.l + 8, yOf(ductTop) + 14, COLORS.cyan, 'left', 'middle'); // 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'); + pillLabel(ctx, `Earth bulge at midpoint: ${maxBulge.toFixed(0)} m (k = 4/3)`, + margin.l + pW / 2, margin.t + pH - 10, COLORS.dim, 'center', 'middle'); // Terrain fill ctx.fillStyle = '#2b3039';