Modernize why-signals-bend with project findings, polish scenes
Rewrite the NLOS post to reflect what microwaveprop has learned: M-refractivity and dM/dh as the actual duct discriminant, the four duct taxonomy with radiation ducts as the North Texas mechanism, binary duct detection as a coin flip, low pressure beating high, shallow boundary layer winning, PWAT sweet spot, seasonal pattern (spring worst, summer best), troposcatter as the floor, and real numbers for the 65 km QTH-to-W5HN/B path. New interactive scenes: N vs M side-by-side refractivity profile, pressure/HPBL/binary-duct bar chart, band-vs-dawn enhancement, PWAT sweet spot with draggable cursor, monthly ducting probability, and a 10 GHz link-budget decomposition showing how the 36 dB diffraction gap gets closed. Polish pass across all scenes: softer palette, rounded stroke caps/joins, prettier slider styling in scene.css and nlos.css, signal-pulse animations on NF cascade / single-stage / LNA placement, trail persistence on the ray-trace scene, animated glints along trapped rays in the duct scene, and animated TCP sequence diagrams. Add local-serve preview mode so future-dated posts show up on zola serve but stay hidden on the deployed build.
This commit is contained in:
parent
4e42bcf97f
commit
2097dbc018
10 changed files with 1331 additions and 287 deletions
1
IDEAS.md
1
IDEAS.md
|
|
@ -37,6 +37,7 @@ Working scratch list. Same format as the NLOS post: vanilla JS, 2D canvas, scene
|
|||
- **Garbage collection, three flavors.** Same heap, same allocation pattern, three collectors running in parallel panes: mark-sweep, copying, generational. Watch pause times and fragmentation diverge. Slider for allocation rate to find where each collector falls over.
|
||||
- **Branch prediction history.** A loop with a data-dependent branch. Toggle between always-taken, two-bit saturating counter, and a small perceptron predictor. Mispredict rate and pipeline bubbles rendered as wasted clock ticks.
|
||||
- **A load balancer at work.** Incoming requests, a pool of backends with varying response times. Switch between round robin, least connections, and consistent hashing. Kill a backend and watch the failure modes differ.
|
||||
- **TCP in the weeds (follow-up to how-tcp-and-udp-work).** MSS negotiation, Path MTU Discovery, segmentation of a 10 KB write into 1460-byte segments, fast retransmit and SACK, delayed/accumulated ACKs, Nagle's algorithm, the PSH and URG flags, connection teardown with FIN/FIN-ACK and the TIME-WAIT state, keepalives and persist timers. One scene per mechanism with a small packet diagram each. Written for the reader who already understands the basics.
|
||||
|
||||
## Ham radio
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ 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](https://en.wikipedia.org/wiki/Fresnel_zone), and you want about 60% of that zone clear for the path to behave like free space.
|
||||
One thing helps NLOS along a little even in the geometry-only model. Radio energy doesn't ride a pencil-thin ray. It fills an ellipsoid between the antennas called the first [Fresnel zone](https://en.wikipedia.org/wiki/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.
|
||||
|
||||
|
|
@ -66,7 +66,7 @@ where *n* is the refractive index. At sea level N is usually around 315. It fall
|
|||
|
||||
<div id="scene-refractivity"></div>
|
||||
|
||||
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.
|
||||
The dashed line is the international standard atmosphere. Drag the slope and the regime label changes with it. At VHF and UHF, useful tropo propagation starts happening as soon as you leave standard. At 10 GHz and above it takes a much steeper gradient to do useful work, and the gradient also has to sit on a *thick enough* inversion to trap the wavelength. Many of the atmospheres that ducting-detection code flags as "ducting" are only strong enough to carry 1 to 5 GHz, and the shape the slider has to make for a reliable 24 GHz contact is rare enough that you can count the nights per year it happens.
|
||||
|
||||
## Rays follow the gradient
|
||||
|
||||
|
|
@ -74,41 +74,85 @@ A horizontal ray in a medium whose refractive index falls with altitude bends do
|
|||
|
||||
The slider below is labeled **dN/dh**, which reads as "the change in N per unit of altitude." It's just the slope of the refractivity curve from the previous scene, in N-units per kilometer. If N drops from 315 at the surface to 275 at 1 km up, dN/dh is −40 N/km. That's the international standard atmosphere, and it's also the zero point for everything that follows. More negative numbers mean refractivity is falling *faster* with altitude, which is what bends rays harder:
|
||||
|
||||
- **around −40 N/km**, standard. k ≈ 4/3, mildly bent rays, ordinary radio horizon.
|
||||
- **around −40 N/km**, standard. k ~ 4/3, mildly bent rays, ordinary radio horizon.
|
||||
- **below −79 N/km**, super-refraction. Rays bend meaningfully more than normal. A lot of VHF/UHF tropo propagation lives here.
|
||||
- **below −157 N/km**, trapping. Rays curve tighter than the Earth itself curves away. The ray can't climb out anymore, and the atmosphere starts behaving less like an atmosphere and more like a waveguide.
|
||||
- **below −157 N/km**, the asymptotic trapping boundary. Rays curve tighter than the Earth itself curves away. Textbooks draw the line here. In practice a usable duct needs the gradient to be stronger still. The actual step from "rarely ducting" to "almost always ducting" in real atmospheric soundings sits closer to −200 to −300 N/km, because the duct also has to be thick enough to hold the wavelength you care about.
|
||||
|
||||
<div id="scene-raytrace"></div>
|
||||
|
||||
Drag dN/dh through those thresholds and watch the traced ray. Standard air, ray exits into space eventually. Super-refractive air, the ray stays close to the surface far longer. Trapping air, the ray never gets out at all, which is where the next scene picks up.
|
||||
|
||||
There's a second way to write the same thing that propagation engineers use, called the **modified refractivity**:
|
||||
|
||||
> M = N + 0.157 * h
|
||||
|
||||
where h is altitude in meters. The 0.157 term is chosen so that M flattens Earth's curvature into the math: a ray in a medium with `dM/dh = 0` travels parallel to Earth's surface, and a layer where `dM/dh < 0` is, by definition, a duct. It's the same statement as "dN/dh below −157 N/km" said in nicer coordinates, and most duct papers use it because the condition becomes a single sign check rather than a threshold to remember.
|
||||
|
||||
## 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.
|
||||
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*](https://en.wikipedia.org/wiki/Atmospheric_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.
|
||||
|
||||
<div id="scene-duct"></div>
|
||||
|
||||
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 the way the terrain profile says it should, which is to say, not at all. The atmosphere is happy to support this arrangement, briefly, before rearranging itself into something less interesting.
|
||||
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, *and* the duct has to be thick enough in meters to actually trap whatever wavelength you're running. When all four line up, your 10 GHz rig works a contact 400 km away. When any one of them is off, the path behaves the way the terrain profile says it should, which is to say, not at all. The atmosphere is happy to support this arrangement, briefly, before rearranging itself into something less interesting.
|
||||
|
||||
The reframing buried in that list is the one I keep coming back to. "Is there a duct, yes or no" is a surprisingly bad predictor of whether a 10 GHz contact will work. In the contact logs we've collected, the median distance on days with a duct and the median on days without one are almost identical. What actually correlates with distance is the *magnitude* of the gradient, the *thickness* of the trapped layer, and how well that thickness matches the wavelength. The same inversion that supports 1 GHz beautifully might do nothing at 24 GHz because it's a few meters too thin.
|
||||
|
||||
## Flavors of duct
|
||||
|
||||
Three of these show up in the wild, all the same physics, different ways the atmosphere sets them up.
|
||||
Four show up in the wild, same physics, different mechanisms for setting them up.
|
||||
|
||||
<div id="scene-ducttypes"></div>
|
||||
|
||||
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.
|
||||
**Radiation (nocturnal) ducts** are the ones that matter inland. Clear calm sky, ground radiates to space faster than the air can cool, an inversion forms 50 to 300 meters thick, gradient runs anywhere from −100 to −300 N/km. Forms one to three hours after sunset, peaks right before dawn, collapses an hour or two after sunrise when mechanical mixing chews it up. The dataset says about 70% of June-July pre-dawn mornings in Texas have one. Almost every inland 10 GHz DX contact you will ever make is riding a radiation duct.
|
||||
|
||||
**Subsidence (elevated) ducts** sit aloft under the ridge axis of a high-pressure system. Sinking air warms and dries, producing a sharp temperature rise and dewpoint drop at 800 to 900 hPa. The trapped layer sits 500 to 2000 meters up and can stay there for days. Both ends of the link usually need to be inside it, or aimed high enough to couple into it. This is the "an elevated duct parks over North Texas" situation people mean when they say a good weekend is forecast.
|
||||
|
||||
**Advection ducts** form when warm dry air moves over a cooler surface. Coastal and marine. Gulf Coast operators know these well. Not a mechanism inland North Texas sees.
|
||||
|
||||
**Evaporation ducts** exist almost continuously in the first 5 to 40 meters over warm oceans, driven by the humidity gradient at the sea surface. Every over-water microwave link above a few GHz lives inside one. Also irrelevant for anyone operating from Oklahoma.
|
||||
|
||||
## 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 is unambiguously in the way. On the right mornings the path works anyway, because an elevated duct parks over North Texas and both ends get into it.
|
||||
This is the 65 km path I've been staring at: my QTH at 205 m above sea level to the W5HN/B beacon on top of the TWU dorms in Denton. 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 first Fresnel zone at 10 GHz has a midspan radius of about 22 m. The terrain is unambiguously in the way. On the right days the path works anyway.
|
||||
|
||||
<div id="scene-real"></div>
|
||||
|
||||
Figuring out whether that duct is there on any given day is the whole point of <https://prop.w5isp.com>. The app pulls [HRRR](https://rapidrefresh.noaa.gov/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.
|
||||
Figuring out whether it's going to work on any given day is the whole point of <https://prop.w5isp.com>. The app pulls [HRRR](https://rapidrefresh.noaa.gov/hrrr/) at its 50 native hybrid-sigma levels (not the standard 13 pressure levels, which are spaced about 250 m apart and completely miss the 50 to 100 m thin ducts we care about), corrects against ASOS surface observations and twice-daily radiosonde profiles, and computes the refractivity gradient along every tracked path. It also polls seven commercial 11, 24, and 68 GHz microwave links around Princeton TX every five minutes as an inverse sensor, because the same refractivity anomaly that helps beyond-LOS paths causes multipath fades on LOS ones. My QTH happens to be one endpoint of that network, so the commercial-link telemetry becomes a direct real-time signal on whether the atmosphere over this specific path is doing anything interesting.
|
||||
|
||||
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 people who've been operating these bands for forty years, which is both a bit humbling and a strong argument against ever finishing this project. The more verified contacts we log with good atmospheric data behind them, the better the model gets.
|
||||
A few things I didn't expect going in, now painfully clear from the data.
|
||||
|
||||
<div id="scene-data"></div>
|
||||
|
||||
**The path is almost always blocked.** 97 percent of logged amateur microwave contact paths are terrain-blocked at an average diffraction loss of 36 dB. Beyond-LOS is not the exceptional case, it is the default case. Clear LOS paths are short and rare. The long contacts that show up in contest logs are all riding atmospheric enhancement of some kind.
|
||||
|
||||
**Binary duct detection is a coin flip.** The median contact distance when HRRR says "there is a duct" is statistically identical to when it says "there is not." What separates a working 10 GHz morning from a silent one is the *strength* of the gradient (how far past −200 N/km it gets), the *thickness* of the layer in meters, and whether that thickness matches the wavelength. Ducting is a shape, not a switch.
|
||||
|
||||
**Low pressure beats high pressure.** The folklore says high pressure is a ham microwave operator's friend. The data says otherwise: on 10 GHz, contacts made under 970 mb average 243 km; over 1020 mb average 103 km. Subsidence ducts under a ridge are real and matter, but on average the ridge itself is not actually the best time to be on the radio.
|
||||
|
||||
**Shallow boundary layer beats deep.** HPBL (planetary boundary layer height) under 200 m averages 230 km; over 2000 m averages 100 km. A calm cool morning with a shallow boundary layer is what you want.
|
||||
|
||||
**The useful window is centered on sunrise, not midnight.** Radiation ducts are strongest right before dawn and for an hour or two after. The contacts cluster in a window about an hour and a half either side of sunrise, and that's where the app focuses its scoring.
|
||||
|
||||
**Pre-dawn matters more the higher you go.** At 10 GHz, time-of-day enhancement is about +4% over the daily median. At 24 GHz, +28%. At 75 GHz, +360%. If you are pointing a 3 mm dish at someone, you are almost certainly doing it before breakfast.
|
||||
|
||||
<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.
|
||||
|
||||
<div id="scene-pwat"></div>
|
||||
|
||||
**Spring is the worst season. Summer is the best.** I went into this thinking winter would be the dead period, but March and December turn out to be the two worst months of the year, and June and July are by far the best. The dominant Texas radiation-duct season runs May through August.
|
||||
|
||||
<div id="scene-seasonal"></div>
|
||||
|
||||
Troposcatter is the floor under all of this. The average blocked path is 36 dB down from free space, and realistic duct enhancement caps out around 14 dB. The paths still close anyway. The gap gets made up by forward-scattered energy bouncing off small-scale refractive irregularities in the troposphere, which is present all the time. Ducting is the spike on top of the troposcatter baseline, not the only mechanism in play.
|
||||
|
||||
<div id="scene-budget"></div>
|
||||
|
||||
The part that keeps me working on it is how often the path surprises me. People who have been operating these bands for forty years still show up on the post-contact mailing lists asking each other how a contact made it through, which is both humbling and a strong argument against ever finishing this project. The more verified contacts we log with atmospheric data behind them, the better the model gets.
|
||||
|
||||
If you run 50 MHz or up, submit your contacts or beacons at <https://prop.w5isp.com>. A continuous, known-distance path is worth a lot more to the model than a pile of contest contacts.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +1,33 @@
|
|||
/* Styles scoped to the NLOS propagation post. Only loaded when the post's
|
||||
<link> tag is on the page. */
|
||||
/* Styles for the NLOS propagation post, polished. */
|
||||
|
||||
.nlos {
|
||||
--nlos-bg: #1f232b;
|
||||
--nlos-bg: #1a1d24;
|
||||
--nlos-bg-2: #232831;
|
||||
--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-fg: #d8dce4;
|
||||
--nlos-dim: #8189a0;
|
||||
--nlos-grid: #2d323d;
|
||||
--nlos-grid-strong: #3a414e;
|
||||
--nlos-blue: #78b5f3;
|
||||
--nlos-cyan: #6dc5d3;
|
||||
--nlos-green: #9dca83;
|
||||
--nlos-orange: #d6a86a;
|
||||
--nlos-red: #e58089;
|
||||
--nlos-yellow: #e2c37d;
|
||||
--nlos-magenta: #c78de0;
|
||||
--nlos-shadow: 0 10px 30px rgba(0, 0, 0, 0.4), 0 2px 6px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.nlos-scene {
|
||||
margin: 2.5rem 0;
|
||||
background: var(--nlos-bg);
|
||||
margin: 2.75rem 0;
|
||||
background:
|
||||
radial-gradient(ellipse at top left, rgba(120, 181, 243, 0.035) 0%, transparent 55%),
|
||||
radial-gradient(ellipse at bottom right, rgba(199, 141, 224, 0.025) 0%, transparent 55%),
|
||||
linear-gradient(180deg, var(--nlos-bg) 0%, var(--nlos-bg-2) 100%);
|
||||
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);
|
||||
border-radius: 10px;
|
||||
padding: 0.85rem 0.85rem 1.1rem;
|
||||
box-shadow: var(--nlos-shadow);
|
||||
}
|
||||
|
||||
.nlos-scene canvas {
|
||||
|
|
@ -34,32 +39,22 @@
|
|||
background: transparent;
|
||||
}
|
||||
|
||||
.nlos-scene canvas:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.nlos-scene canvas:active { cursor: grabbing; }
|
||||
|
||||
.nlos-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
gap: 0.65rem 0.9rem;
|
||||
margin-top: 0.85rem;
|
||||
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-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 { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
|
||||
.nlos-control label {
|
||||
color: var(--nlos-dim);
|
||||
|
|
@ -67,65 +62,122 @@
|
|||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.nlos-control label .nlos-value {
|
||||
color: var(--nlos-fg);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nlos-control input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
accent-color: var(--nlos-cyan);
|
||||
height: 20px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nlos-control input[type="range"]::-webkit-slider-runnable-track {
|
||||
height: 4px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(90deg, var(--nlos-grid-strong), var(--nlos-grid));
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.nlos-control input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--nlos-cyan);
|
||||
border: 2px solid var(--nlos-bg);
|
||||
margin-top: -5px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
|
||||
transition: box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.nlos-control input[type="range"]:hover::-webkit-slider-thumb,
|
||||
.nlos-control input[type="range"]:focus::-webkit-slider-thumb {
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5), 0 0 0 6px rgba(109, 197, 211, 0.12);
|
||||
}
|
||||
|
||||
.nlos-control input[type="range"]::-moz-range-track {
|
||||
height: 4px;
|
||||
border-radius: 4px;
|
||||
background: var(--nlos-grid-strong);
|
||||
}
|
||||
.nlos-control input[type="range"]::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--nlos-cyan);
|
||||
border: 2px solid var(--nlos-bg);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
|
||||
transition: box-shadow 160ms ease;
|
||||
}
|
||||
.nlos-control input[type="range"]:focus { outline: none; }
|
||||
|
||||
.nlos-scene .nlos-caption {
|
||||
margin-top: 0.5rem;
|
||||
margin-top: 0.65rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--nlos-dim);
|
||||
line-height: 1.45;
|
||||
line-height: 1.55;
|
||||
max-width: 62em;
|
||||
}
|
||||
|
||||
.nlos-scene .nlos-readout {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem 1.5rem;
|
||||
padding: 0.5rem 0;
|
||||
margin-top: 0.25rem;
|
||||
gap: 0.5rem 1.4rem;
|
||||
padding: 0.45rem 0.1rem 0.3rem;
|
||||
margin-top: 0.15rem;
|
||||
color: var(--nlos-fg);
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.82rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.01em;
|
||||
border-bottom: 1px dashed var(--nlos-grid);
|
||||
}
|
||||
|
||||
.nlos-scene .nlos-readout .nlos-readout-item span:first-child {
|
||||
color: var(--nlos-dim);
|
||||
margin-right: 0.35rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.nlos-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
border: 1px solid var(--nlos-grid-strong);
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 160ms ease, border-color 160ms ease, color 160ms ease;
|
||||
}
|
||||
|
||||
.nlos-buttons button:hover {
|
||||
border-color: var(--nlos-cyan);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nlos-buttons button[aria-pressed="true"] {
|
||||
background: var(--nlos-cyan);
|
||||
color: #111;
|
||||
color: #131720;
|
||||
border-color: var(--nlos-cyan);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,33 @@
|
|||
/* Generic interactive-scene styles. Scoped to posts that opt in by
|
||||
wrapping content in `.scene-host` and by linking this stylesheet
|
||||
from the post's markdown. */
|
||||
/* Scene styles, polished. Only loaded by posts that link this stylesheet. */
|
||||
|
||||
.scene-host {
|
||||
--s-bg: #1f232b;
|
||||
--s-bg: #1a1d24;
|
||||
--s-bg-2: #232831;
|
||||
--s-panel: #242932;
|
||||
--s-fg: #d4d8df;
|
||||
--s-dim: #7d8594;
|
||||
--s-grid: #323844;
|
||||
--s-blue: #61afef;
|
||||
--s-cyan: #56b6c2;
|
||||
--s-green: #98c379;
|
||||
--s-orange: #d19a66;
|
||||
--s-red: #e06c75;
|
||||
--s-yellow: #e5c07b;
|
||||
--s-magenta: #c678dd;
|
||||
--s-fg: #d8dce4;
|
||||
--s-dim: #8189a0;
|
||||
--s-grid: #2d323d;
|
||||
--s-grid-strong: #3a414e;
|
||||
--s-blue: #78b5f3;
|
||||
--s-cyan: #6dc5d3;
|
||||
--s-green: #9dca83;
|
||||
--s-amber: #d6a86a;
|
||||
--s-red: #e58089;
|
||||
--s-yellow: #e2c37d;
|
||||
--s-magenta: #c78de0;
|
||||
--s-shadow: 0 10px 30px rgba(0, 0, 0, 0.4), 0 2px 6px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.scene-box {
|
||||
margin: 2.5rem 0;
|
||||
background: var(--s-bg);
|
||||
margin: 2.75rem 0;
|
||||
background:
|
||||
radial-gradient(ellipse at top left, rgba(120, 181, 243, 0.035) 0%, transparent 55%),
|
||||
radial-gradient(ellipse at bottom right, rgba(199, 141, 224, 0.025) 0%, transparent 55%),
|
||||
linear-gradient(180deg, var(--s-bg) 0%, var(--s-bg-2) 100%);
|
||||
border: 1px solid var(--s-grid);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem 0.75rem 1rem;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35);
|
||||
border-radius: 10px;
|
||||
padding: 0.85rem 0.85rem 1.1rem;
|
||||
box-shadow: var(--s-shadow);
|
||||
}
|
||||
|
||||
.scene-box canvas {
|
||||
|
|
@ -33,6 +37,7 @@
|
|||
touch-action: none;
|
||||
cursor: grab;
|
||||
background: transparent;
|
||||
image-rendering: auto;
|
||||
}
|
||||
|
||||
.scene-box canvas:active { cursor: grabbing; }
|
||||
|
|
@ -40,8 +45,8 @@
|
|||
.scene-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
gap: 0.65rem 0.9rem;
|
||||
margin-top: 0.85rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
|
|
@ -50,7 +55,7 @@
|
|||
.scene-controls.scene-controls--three { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
|
||||
.scene-control { display: flex; flex-direction: column; gap: 0.2rem; }
|
||||
.scene-control { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
|
||||
.scene-control label {
|
||||
color: var(--s-dim);
|
||||
|
|
@ -58,37 +63,93 @@
|
|||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.scene-control label .scene-value {
|
||||
color: var(--s-fg);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Prettier slider. Custom track and thumb on both webkit and firefox. */
|
||||
.scene-control input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
accent-color: var(--s-cyan);
|
||||
height: 20px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.scene-control input[type="range"]::-webkit-slider-runnable-track {
|
||||
height: 4px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(90deg, var(--s-grid-strong), var(--s-grid));
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.scene-control input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--s-cyan);
|
||||
border: 2px solid var(--s-bg);
|
||||
margin-top: -5px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5), 0 0 0 0 rgba(109, 197, 211, 0);
|
||||
transition: box-shadow 160ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.scene-control input[type="range"]:hover::-webkit-slider-thumb,
|
||||
.scene-control input[type="range"]:focus::-webkit-slider-thumb {
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5), 0 0 0 6px rgba(109, 197, 211, 0.12);
|
||||
}
|
||||
|
||||
.scene-control input[type="range"]::-moz-range-track {
|
||||
height: 4px;
|
||||
border-radius: 4px;
|
||||
background: var(--s-grid-strong);
|
||||
}
|
||||
.scene-control input[type="range"]::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--s-cyan);
|
||||
border: 2px solid var(--s-bg);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
|
||||
transition: box-shadow 160ms ease;
|
||||
}
|
||||
.scene-control input[type="range"]:focus { outline: none; }
|
||||
|
||||
.scene-box .scene-caption {
|
||||
margin-top: 0.5rem;
|
||||
margin-top: 0.65rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--s-dim);
|
||||
line-height: 1.45;
|
||||
line-height: 1.55;
|
||||
max-width: 62em;
|
||||
}
|
||||
|
||||
.scene-box .scene-readout {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem 1.5rem;
|
||||
padding: 0.5rem 0;
|
||||
margin-top: 0.25rem;
|
||||
gap: 0.5rem 1.4rem;
|
||||
padding: 0.45rem 0.1rem 0.3rem;
|
||||
margin-top: 0.15rem;
|
||||
color: var(--s-fg);
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.82rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.01em;
|
||||
border-bottom: 1px dashed var(--s-grid);
|
||||
}
|
||||
|
||||
.scene-box .scene-readout .scene-readout-item span:first-child {
|
||||
color: var(--s-dim);
|
||||
margin-right: 0.35rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -3,18 +3,18 @@
|
|||
'use strict';
|
||||
|
||||
const C = {
|
||||
bg: '#1f232b',
|
||||
bg: '#1a1d24',
|
||||
panel: '#242932',
|
||||
fg: '#d4d8df',
|
||||
dim: '#5c6370',
|
||||
grid: '#323844',
|
||||
blue: '#61afef',
|
||||
cyan: '#56b6c2',
|
||||
green: '#98c379',
|
||||
orange: '#d19a66',
|
||||
red: '#e06c75',
|
||||
yellow: '#e5c07b',
|
||||
magenta: '#c678dd',
|
||||
fg: '#d8dce4',
|
||||
dim: '#8189a0',
|
||||
grid: '#2d323d',
|
||||
blue: '#78b5f3',
|
||||
cyan: '#6dc5d3',
|
||||
green: '#9dca83',
|
||||
orange: '#d6a86a',
|
||||
red: '#e58089',
|
||||
yellow: '#e2c37d',
|
||||
magenta: '#c78de0',
|
||||
};
|
||||
|
||||
// ----- framework (same shape as other posts) -----
|
||||
|
|
@ -26,6 +26,8 @@
|
|||
canvas.width = Math.round(rect.width * dpr);
|
||||
canvas.height = Math.round(rect.height * dpr);
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
};
|
||||
fit();
|
||||
new ResizeObserver(fit).observe(canvas);
|
||||
|
|
@ -265,6 +267,12 @@
|
|||
});
|
||||
if (!s) return;
|
||||
|
||||
const startWall = performance.now();
|
||||
function rnd(seed) {
|
||||
const x = Math.sin(seed * 12.9898) * 43758.5453;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const { w, h } = s.getSize();
|
||||
const ctx = s.ctx;
|
||||
|
|
@ -274,19 +282,19 @@
|
|||
const pW = w - margin.l - margin.r;
|
||||
const pH = h - margin.t - margin.b;
|
||||
|
||||
// block in the center
|
||||
const ampW = 140, ampH = 80;
|
||||
const ampX = margin.l + (pW - ampW) / 2;
|
||||
const ampY = margin.t + (pH - ampH) / 2;
|
||||
const wireY = ampY + ampH / 2;
|
||||
const wireStartX = margin.l + 10;
|
||||
const wireEndX = margin.l + pW - 10;
|
||||
|
||||
// draw connecting wire
|
||||
// connecting wire
|
||||
ctx.strokeStyle = C.dim;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(margin.l + 10, ampY + ampH / 2);
|
||||
ctx.lineTo(ampX, ampY + ampH / 2);
|
||||
ctx.moveTo(ampX + ampW, ampY + ampH / 2);
|
||||
ctx.lineTo(margin.l + pW - 10, ampY + ampH / 2);
|
||||
ctx.moveTo(wireStartX, wireY); ctx.lineTo(ampX, wireY);
|
||||
ctx.moveTo(ampX + ampW, wireY); ctx.lineTo(wireEndX, wireY);
|
||||
ctx.stroke();
|
||||
|
||||
// amplifier triangle
|
||||
|
|
@ -295,38 +303,94 @@
|
|||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ampX, ampY);
|
||||
ctx.lineTo(ampX + ampW, ampY + ampH / 2);
|
||||
ctx.lineTo(ampX + ampW, wireY);
|
||||
ctx.lineTo(ampX, ampY + ampH);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
text(ctx, `G = ${s.values.gain.toFixed(1)} dB`, ampX + ampW / 3, ampY + ampH / 2 - 6, C.fg, 'center');
|
||||
text(ctx, `NF = ${s.values.nf.toFixed(1)} dB`, ampX + ampW / 3, ampY + ampH / 2 + 10, C.yellow, 'center');
|
||||
text(ctx, `G = ${s.values.gain.toFixed(1)} dB`, ampX + ampW / 3, wireY - 6, C.fg, 'center');
|
||||
text(ctx, `NF = ${s.values.nf.toFixed(1)} dB`, ampX + ampW / 3, wireY + 10, C.yellow, 'center');
|
||||
|
||||
// Input/output bar visualizations
|
||||
const inSNR = s.values.inSnr;
|
||||
const gain = s.values.gain;
|
||||
const nf = s.values.nf;
|
||||
const outSNR = inSNR - nf;
|
||||
|
||||
const barH = 40;
|
||||
const barTop = ampY - barH - 10;
|
||||
const barBottom = barTop + barH;
|
||||
// Input side
|
||||
drawSignalNoiseBars(ctx, margin.l + 10, barTop, 80, barH, inSNR, 'input', C.green);
|
||||
// Output side
|
||||
drawSignalNoiseBars(ctx, margin.l + pW - 90, barTop, 80, barH, outSNR, 'output', outSNR > 0 ? C.green : C.red);
|
||||
|
||||
// Wires from each bar down to the main signal wire (no text in the way now)
|
||||
ctx.strokeStyle = C.dim;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(margin.l + 50, barBottom);
|
||||
ctx.lineTo(margin.l + 50, ampY + ampH / 2);
|
||||
ctx.lineTo(margin.l + 50, wireY);
|
||||
ctx.moveTo(margin.l + pW - 50, barBottom);
|
||||
ctx.lineTo(margin.l + pW - 50, ampY + ampH / 2);
|
||||
ctx.lineTo(margin.l + pW - 50, wireY);
|
||||
ctx.stroke();
|
||||
|
||||
// ---- Animated signal pulse + noise halo ----
|
||||
const CYCLE = 3.5;
|
||||
const now = ((performance.now() - startWall) / 1000) % CYCLE;
|
||||
const tNorm = Math.min(1, now / (CYCLE * 0.85));
|
||||
const packetX = wireStartX + tNorm * (wireEndX - wireStartX);
|
||||
|
||||
// Noise halo grows linearly through the amp. Map position to "noise level":
|
||||
// before amp: input-noise baseline
|
||||
// inside amp: baseline rising to (baseline * 10^(nf/10))
|
||||
// after amp: output noise (baseline + amp noise), amplified.
|
||||
const baseNoise = 4; // px of halo for pure input noise
|
||||
let halo = baseNoise;
|
||||
if (packetX < ampX) halo = baseNoise;
|
||||
else if (packetX < ampX + ampW) {
|
||||
const frac = (packetX - ampX) / ampW;
|
||||
halo = baseNoise + (baseNoise * (dB2lin(nf) - 1)) * frac;
|
||||
} else {
|
||||
halo = baseNoise + (baseNoise * (dB2lin(nf) - 1));
|
||||
}
|
||||
|
||||
// Jittering noise halo
|
||||
const tMs = performance.now();
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const ang = 2 * Math.PI * rnd(i + Math.floor(tMs / 80));
|
||||
const r = halo * (0.4 + rnd(i + 200 + Math.floor(tMs / 60)));
|
||||
const px = packetX + Math.cos(ang) * r;
|
||||
const py = wireY + Math.sin(ang) * r;
|
||||
ctx.fillStyle = C.cyan;
|
||||
ctx.globalAlpha = 0.2 + 0.4 * rnd(i + 50);
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, 1.3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Extra burst of internal noise emerging from the amp's apex while the
|
||||
// packet is passing through it.
|
||||
if (packetX >= ampX && packetX < ampX + ampW) {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const apex = { x: ampX + ampW, y: wireY };
|
||||
const off = 6 + rnd(i + tMs / 30) * 18;
|
||||
const ang = (rnd(i + 400 + tMs / 20) - 0.5) * 0.8;
|
||||
ctx.fillStyle = C.yellow;
|
||||
ctx.globalAlpha = 0.35;
|
||||
ctx.beginPath();
|
||||
ctx.arc(apex.x + Math.cos(ang) * off - 10, apex.y + Math.sin(ang) * off, 1.4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
// The signal itself — bright dot with a glow, brighter after the amp.
|
||||
const signalPost = packetX > ampX + ampW;
|
||||
ctx.fillStyle = C.green;
|
||||
ctx.shadowColor = C.green;
|
||||
ctx.shadowBlur = signalPost ? 14 : 8;
|
||||
ctx.beginPath();
|
||||
ctx.arc(packetX, wireY, signalPost ? 5.5 : 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
s.setReadout('outSnr', `${outSNR.toFixed(1)} dB`);
|
||||
s.setReadout('loss', `${nf.toFixed(2)} dB (= NF)`);
|
||||
|
||||
|
|
@ -403,6 +467,14 @@
|
|||
return stages;
|
||||
}
|
||||
|
||||
const startWall = performance.now();
|
||||
|
||||
// Deterministic-ish noise for the halo jitter
|
||||
function rn(seed) {
|
||||
const x = Math.sin(seed * 12.9898) * 43758.5453;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const { w, h } = s.getSize();
|
||||
const ctx = s.ctx;
|
||||
|
|
@ -411,48 +483,49 @@
|
|||
const stages = currentStages();
|
||||
const margin = { l: 20, r: 20, t: 40, b: 30 };
|
||||
const pW = w - margin.l - margin.r;
|
||||
const pH = h - margin.t - margin.b;
|
||||
|
||||
// Chain row: boxes for each stage
|
||||
const boxW = Math.min(150, (pW - (stages.length + 1) * 14) / Math.max(1, stages.length));
|
||||
const boxH = 70;
|
||||
const rowY = margin.t + 10;
|
||||
const wireY = rowY + boxH / 2;
|
||||
|
||||
let x = margin.l;
|
||||
|
||||
// antenna symbol at the left
|
||||
// antenna
|
||||
ctx.strokeStyle = C.fg;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + 6, rowY + boxH / 2);
|
||||
ctx.lineTo(x + 6, rowY + boxH / 2 - 16);
|
||||
ctx.moveTo(x, rowY + boxH / 2 - 16);
|
||||
ctx.lineTo(x + 12, rowY + boxH / 2 - 16);
|
||||
ctx.moveTo(x + 6, wireY);
|
||||
ctx.lineTo(x + 6, wireY - 16);
|
||||
ctx.moveTo(x, wireY - 16);
|
||||
ctx.lineTo(x + 12, wireY - 16);
|
||||
ctx.stroke();
|
||||
text(ctx, 'ant', x + 6, rowY + boxH / 2 + 18, C.dim, 'center');
|
||||
text(ctx, 'ant', x + 6, wireY + 18, C.dim, 'center');
|
||||
x += 28;
|
||||
|
||||
// cascade compute
|
||||
const cascade = cascadeNF(stages);
|
||||
const chainStartX = x;
|
||||
|
||||
const cascade = cascadeNF(stages);
|
||||
const cumAtStage = [];
|
||||
{
|
||||
let F = 1, Gcum = 1;
|
||||
stages.forEach((st, i) => {
|
||||
stages.forEach(st => {
|
||||
const Fi = dB2lin(st.nfDb);
|
||||
F += i === 0 ? (Fi - 1) : (Fi - 1) / Gcum;
|
||||
F += cumAtStage.length === 0 ? (Fi - 1) : (Fi - 1) / Gcum;
|
||||
Gcum *= dB2lin(st.gainDb);
|
||||
cumAtStage.push({ F, G: Gcum, nfDb: lin2dB(F), gainDbCum: lin2dB(Gcum) });
|
||||
});
|
||||
}
|
||||
|
||||
// Track each block's x range for the animated packet.
|
||||
const blockRanges = [];
|
||||
stages.forEach((st, i) => {
|
||||
// connecting wire
|
||||
ctx.strokeStyle = C.dim;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x - 14, rowY + boxH / 2);
|
||||
ctx.lineTo(x, rowY + boxH / 2);
|
||||
ctx.moveTo(x - 14, wireY);
|
||||
ctx.lineTo(x, wireY);
|
||||
ctx.stroke();
|
||||
|
||||
// box
|
||||
|
|
@ -465,19 +538,20 @@
|
|||
text(ctx, `G = ${st.gainDb.toFixed(1)} dB`, x + boxW / 2, rowY + 34, C.dim, 'center');
|
||||
text(ctx, `NF = ${st.nfDb.toFixed(1)} dB`, x + boxW / 2, rowY + 52, C.yellow, 'center');
|
||||
|
||||
// cumulative NF below this stage
|
||||
const cum = cumAtStage[i];
|
||||
text(ctx, `cum NF: ${cum.nfDb.toFixed(2)} dB`, x + boxW / 2, rowY + boxH + 18, C.green, 'center');
|
||||
text(ctx, `cum G: ${cum.gainDbCum.toFixed(1)} dB`, x + boxW / 2, rowY + boxH + 34, C.dim, 'center');
|
||||
|
||||
blockRanges.push({ x0: x, x1: x + boxW, stage: i });
|
||||
x += boxW + 14;
|
||||
});
|
||||
const chainEndX = x - 14;
|
||||
|
||||
if (stages.length === 0) {
|
||||
text(ctx, '(add stages via the sliders below)', margin.l + pW / 2, rowY + boxH / 2, C.dim, 'center');
|
||||
text(ctx, '(add stages via the sliders below)', margin.l + pW / 2, wireY, C.dim, 'center');
|
||||
}
|
||||
|
||||
// Contribution bar chart (how much of the final noise each stage adds)
|
||||
// Contribution bar chart
|
||||
if (stages.length > 0) {
|
||||
const totalAdd = cascade.contributions.reduce((a, b) => a + b.add, 0);
|
||||
const chartX = margin.l, chartY = rowY + boxH + 60;
|
||||
|
|
@ -501,7 +575,61 @@
|
|||
text(ctx, 'share of output noise contributed by each stage', chartX, chartY - 6, C.dim);
|
||||
}
|
||||
|
||||
// Friis formula readout
|
||||
// ---- Animated signal + noise halo ----
|
||||
if (stages.length > 0) {
|
||||
const CYCLE = 4.5;
|
||||
const now = ((performance.now() - startWall) / 1000) % CYCLE;
|
||||
const tNorm = Math.min(1, now / (CYCLE * 0.85)); // spend the last 15% of the cycle paused at end
|
||||
const packetX = chainStartX + tNorm * (chainEndX - chainStartX);
|
||||
|
||||
// Cumulative F at the packet's current x: find which stages the packet
|
||||
// has fully passed through (strict less-than on x1) plus any partial
|
||||
// progression through the current block.
|
||||
let cumIndex = -1;
|
||||
blockRanges.forEach(br => { if (packetX >= br.x1) cumIndex = br.stage; });
|
||||
const inBlock = blockRanges.find(br => packetX >= br.x0 && packetX < br.x1);
|
||||
let F_here = cumIndex >= 0 ? cumAtStage[cumIndex].F : 1;
|
||||
if (inBlock) {
|
||||
// smooth interpolation of F across the current block
|
||||
const frac = (packetX - inBlock.x0) / (inBlock.x1 - inBlock.x0);
|
||||
const Fbefore = inBlock.stage === 0 ? 1 : cumAtStage[inBlock.stage - 1].F;
|
||||
const Fafter = cumAtStage[inBlock.stage].F;
|
||||
F_here = Fbefore + (Fafter - Fbefore) * frac;
|
||||
}
|
||||
|
||||
// Halo radius scales with the noise contributed so far (log-ish to keep small and large F both visible)
|
||||
const haloR = 6 + 14 * Math.log10(Math.max(1.01, F_here));
|
||||
|
||||
// Draw noise halo as a jittering cyan cloud.
|
||||
const tMs = performance.now();
|
||||
ctx.save();
|
||||
for (let i = 0; i < 28; i++) {
|
||||
const ang = 2 * Math.PI * rn(i + Math.floor(tMs / 80));
|
||||
const r = haloR * (0.5 + rn(i + 100 + Math.floor(tMs / 60)));
|
||||
const px = packetX + Math.cos(ang) * r;
|
||||
const py = wireY + Math.sin(ang) * r * 0.7;
|
||||
ctx.fillStyle = C.cyan;
|
||||
ctx.globalAlpha = 0.25 + 0.4 * rn(i + 50);
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, 1.4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// The signal pulse itself — a bright yellow dot
|
||||
ctx.fillStyle = C.yellow;
|
||||
ctx.shadowColor = C.yellow;
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.beginPath();
|
||||
ctx.arc(packetX, wireY, 4.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Running readout of NF at the packet's current position
|
||||
text(ctx, `NF at packet: ${lin2dB(F_here).toFixed(2)} dB`, packetX, wireY - haloR - 8, C.yellow, 'center');
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
s.setReadout('total', stages.length ? `${cascade.nfDb.toFixed(2)} dB` : 'n/a');
|
||||
s.setReadout('gain', stages.length ? `${lin2dB(cascade.Gcum).toFixed(1)} dB` : 'n/a');
|
||||
|
||||
|
|
@ -529,6 +657,12 @@
|
|||
});
|
||||
if (!s) return;
|
||||
|
||||
const startWall = performance.now();
|
||||
function rnd(seed) {
|
||||
const x = Math.sin(seed * 12.9898) * 43758.5453;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const { w, h } = s.getSize();
|
||||
const ctx = s.ctx;
|
||||
|
|
@ -548,11 +682,15 @@
|
|||
const resA = cascadeNF(chainA);
|
||||
const resB = cascadeNF(chainB);
|
||||
|
||||
// Draw two rows of chains with their NF numbers
|
||||
const rowA_y = margin.t + 20;
|
||||
const rowB_y = margin.t + pH - 60;
|
||||
drawChain(ctx, margin.l, rowA_y, pW, 'LNA at the antenna', chainA, resA.nfDb, C.green);
|
||||
drawChain(ctx, margin.l, rowB_y, pW, 'LNA at the radio', chainB, resB.nfDb, C.red);
|
||||
|
||||
const CYCLE = 4.5;
|
||||
const now = ((performance.now() - startWall) / 1000) % CYCLE;
|
||||
const tNorm = Math.min(1, now / (CYCLE * 0.85));
|
||||
|
||||
drawChain(ctx, margin.l, rowA_y, pW, 'LNA at the antenna', chainA, resA, C.green, tNorm);
|
||||
drawChain(ctx, margin.l, rowB_y, pW, 'LNA at the radio', chainB, resB, C.red, tNorm);
|
||||
|
||||
s.setReadout('atTop', `NF ${resA.nfDb.toFixed(2)} dB`);
|
||||
s.setReadout('atRadio', `NF ${resB.nfDb.toFixed(2)} dB`);
|
||||
|
|
@ -561,26 +699,29 @@
|
|||
requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
function drawChain(ctx, x0, y0, totalW, label, stages, nfDb, nfColor) {
|
||||
function drawChain(ctx, x0, y0, totalW, label, stages, cascade, nfColor, tNorm) {
|
||||
text(ctx, label, x0, y0 - 6, C.fg);
|
||||
const boxW = (totalW - 80 - (stages.length - 1) * 10) / stages.length;
|
||||
const boxH = 48;
|
||||
const wireY = y0 + boxH / 2;
|
||||
let x = x0;
|
||||
// antenna
|
||||
ctx.strokeStyle = C.fg;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + 6, y0 + boxH / 2);
|
||||
ctx.lineTo(x + 6, y0 + boxH / 2 - 14);
|
||||
ctx.moveTo(x, y0 + boxH / 2 - 14);
|
||||
ctx.lineTo(x + 12, y0 + boxH / 2 - 14);
|
||||
ctx.moveTo(x + 6, wireY);
|
||||
ctx.lineTo(x + 6, wireY - 14);
|
||||
ctx.moveTo(x, wireY - 14);
|
||||
ctx.lineTo(x + 12, wireY - 14);
|
||||
ctx.stroke();
|
||||
x += 26;
|
||||
|
||||
stages.forEach((st) => {
|
||||
const chainStartX = x;
|
||||
const blockRanges = [];
|
||||
stages.forEach((st, i) => {
|
||||
ctx.strokeStyle = C.dim;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x - 10, y0 + boxH / 2); ctx.lineTo(x, y0 + boxH / 2);
|
||||
ctx.moveTo(x - 10, wireY); ctx.lineTo(x, wireY);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = C.panel;
|
||||
ctx.strokeStyle = st.gainDb < 0 ? C.orange : C.cyan;
|
||||
|
|
@ -588,12 +729,62 @@
|
|||
ctx.strokeRect(x, y0, boxW, boxH);
|
||||
text(ctx, st.name, x + boxW / 2, y0 + 18, C.fg, 'center');
|
||||
text(ctx, `G=${st.gainDb.toFixed(1)} NF=${st.nfDb.toFixed(1)}`, x + boxW / 2, y0 + 36, C.dim, 'center');
|
||||
blockRanges.push({ x0: x, x1: x + boxW, stage: i });
|
||||
x += boxW + 10;
|
||||
});
|
||||
const chainEndX = x - 10;
|
||||
|
||||
// Cumulative F at each stage boundary
|
||||
const cumF = [];
|
||||
{
|
||||
let F = 1, Gcum = 1;
|
||||
stages.forEach(st => {
|
||||
const Fi = dB2lin(st.nfDb);
|
||||
F += cumF.length === 0 ? (Fi - 1) : (Fi - 1) / Gcum;
|
||||
Gcum *= dB2lin(st.gainDb);
|
||||
cumF.push(F);
|
||||
});
|
||||
}
|
||||
|
||||
// Animated signal
|
||||
const packetX = chainStartX + tNorm * (chainEndX - chainStartX);
|
||||
let F_here = 1;
|
||||
const inBlock = blockRanges.find(br => packetX >= br.x0 && packetX < br.x1);
|
||||
if (inBlock) {
|
||||
const frac = (packetX - inBlock.x0) / (inBlock.x1 - inBlock.x0);
|
||||
const Fbefore = inBlock.stage === 0 ? 1 : cumF[inBlock.stage - 1];
|
||||
const Fafter = cumF[inBlock.stage];
|
||||
F_here = Fbefore + (Fafter - Fbefore) * frac;
|
||||
} else {
|
||||
let idx = -1;
|
||||
blockRanges.forEach(br => { if (packetX >= br.x1) idx = br.stage; });
|
||||
F_here = idx >= 0 ? cumF[idx] : 1;
|
||||
}
|
||||
const haloR = 4 + 11 * Math.log10(Math.max(1.01, F_here));
|
||||
|
||||
const tMs = performance.now();
|
||||
for (let i = 0; i < 22; i++) {
|
||||
const ang = 2 * Math.PI * rnd(i + Math.floor(tMs / 80) + label.length);
|
||||
const r = haloR * (0.5 + rnd(i + 200 + Math.floor(tMs / 60) + label.length));
|
||||
const px = packetX + Math.cos(ang) * r;
|
||||
const py = wireY + Math.sin(ang) * r * 0.7;
|
||||
ctx.fillStyle = C.cyan;
|
||||
ctx.globalAlpha = 0.22 + 0.38 * rnd(i + 50);
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, 1.2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = C.yellow;
|
||||
ctx.shadowColor = C.yellow;
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.beginPath();
|
||||
ctx.arc(packetX, wireY, 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// output NF label
|
||||
ctx.fillStyle = nfColor;
|
||||
text(ctx, `Total NF: ${nfDb.toFixed(2)} dB`, x0 + totalW, y0 + boxH + 18, nfColor, 'right');
|
||||
text(ctx, `Total NF: ${cascade.nfDb.toFixed(2)} dB`, x0 + totalW, y0 + boxH + 18, nfColor, 'right');
|
||||
}
|
||||
|
||||
requestAnimationFrame(draw);
|
||||
|
|
|
|||
|
|
@ -4,18 +4,18 @@
|
|||
'use strict';
|
||||
|
||||
const C = {
|
||||
bg: '#1f232b',
|
||||
bg: '#1a1d24',
|
||||
panel: '#242932',
|
||||
fg: '#d4d8df',
|
||||
dim: '#5c6370',
|
||||
grid: '#323844',
|
||||
blue: '#61afef',
|
||||
cyan: '#56b6c2',
|
||||
green: '#98c379',
|
||||
orange: '#d19a66',
|
||||
red: '#e06c75',
|
||||
yellow: '#e5c07b',
|
||||
magenta: '#c678dd',
|
||||
fg: '#d8dce4',
|
||||
dim: '#8189a0',
|
||||
grid: '#2d323d',
|
||||
blue: '#78b5f3',
|
||||
cyan: '#6dc5d3',
|
||||
green: '#9dca83',
|
||||
orange: '#d6a86a',
|
||||
red: '#e58089',
|
||||
yellow: '#e2c37d',
|
||||
magenta: '#c78de0',
|
||||
};
|
||||
|
||||
// ----- framework (same shape as transistor/nlos scenes) -----
|
||||
|
|
@ -27,6 +27,8 @@
|
|||
canvas.width = Math.round(rect.width * dpr);
|
||||
canvas.height = Math.round(rect.height * dpr);
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
};
|
||||
fit();
|
||||
new ResizeObserver(fit).observe(canvas);
|
||||
|
|
|
|||
|
|
@ -4,18 +4,18 @@
|
|||
'use strict';
|
||||
|
||||
const C = {
|
||||
bg: '#1f232b',
|
||||
bg: '#1a1d24',
|
||||
panel: '#242932',
|
||||
fg: '#d4d8df',
|
||||
dim: '#5c6370',
|
||||
grid: '#323844',
|
||||
blue: '#61afef',
|
||||
cyan: '#56b6c2',
|
||||
green: '#98c379',
|
||||
orange: '#d19a66',
|
||||
red: '#e06c75',
|
||||
yellow: '#e5c07b',
|
||||
magenta: '#c678dd',
|
||||
fg: '#d8dce4',
|
||||
dim: '#8189a0',
|
||||
grid: '#2d323d',
|
||||
blue: '#78b5f3',
|
||||
cyan: '#6dc5d3',
|
||||
green: '#9dca83',
|
||||
orange: '#d6a86a',
|
||||
red: '#e58089',
|
||||
yellow: '#e2c37d',
|
||||
magenta: '#c78de0',
|
||||
};
|
||||
|
||||
// ----- device model -----
|
||||
|
|
@ -40,6 +40,8 @@
|
|||
canvas.width = Math.round(rect.width * dpr);
|
||||
canvas.height = Math.round(rect.height * dpr);
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
};
|
||||
fit();
|
||||
new ResizeObserver(fit).observe(canvas);
|
||||
|
|
|
|||
|
|
@ -25,14 +25,21 @@
|
|||
{% endif %}
|
||||
|
||||
{# Filter out future-dated posts so they appear automatically on their publish date
|
||||
(combined with a daily scheduled build in .forgejo/workflows/deploy.yml). #}
|
||||
(combined with a daily scheduled build in .forgejo/workflows/deploy.yml).
|
||||
Local `zola serve` swaps the base_url to 127.0.0.1; when that's the case
|
||||
we skip the filter and show every post so scheduled drafts are previewable. #}
|
||||
{% set is_local = "127.0.0.1" in config.base_url or "localhost" in config.base_url %}
|
||||
{% set current_ts = now(timestamp=true) %}
|
||||
{% set_global published_pages = [] %}
|
||||
{% set blog_section = get_section(path="blog/_index.md") %}
|
||||
{% for page in blog_section.pages %}
|
||||
{% set page_ts = page.date | date(format="%s") | int %}
|
||||
{% if page_ts <= current_ts %}
|
||||
{% if is_local %}
|
||||
{% set_global published_pages = published_pages | concat(with=page) %}
|
||||
{% else %}
|
||||
{% set page_ts = page.date | date(format="%s") | int %}
|
||||
{% if page_ts <= current_ts %}
|
||||
{% set_global published_pages = published_pages | concat(with=page) %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,10 +8,13 @@
|
|||
{{ section.content | safe }}
|
||||
</div>
|
||||
<ul class="blog">
|
||||
{# Skip the future-date filter when running locally (zola serve swaps
|
||||
base_url to 127.0.0.1), so scheduled posts are previewable in dev. #}
|
||||
{% set is_local = "127.0.0.1" in config.base_url or "localhost" in config.base_url %}
|
||||
{% set current_ts = now(timestamp=true) %}
|
||||
{% for page in paginator.pages %}
|
||||
{% set page_ts = page.date | date(format="%s") | int %}
|
||||
{% if page_ts <= current_ts %}
|
||||
{% if is_local or page_ts <= current_ts %}
|
||||
<li>
|
||||
<article>
|
||||
<h2><a href="{{ page.permalink }}">{{ page.title }}</a></h2>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue