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.
This commit is contained in:
Graham McIntire 2026-04-22 16:26:54 -05:00
parent 0a5ddee7d8
commit a96d43cfbe
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 33 additions and 23 deletions

View file

@ -140,7 +140,9 @@ A few things I didn't expect going in, now painfully clear from the data.
<div id="scene-band-time"></div>
**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.
<div id="scene-pwat"></div>

View file

@ -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 (200350 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 (200350 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';