diff --git a/IDEAS.md b/IDEAS.md index 46b2485..d01d686 100644 --- a/IDEAS.md +++ b/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 diff --git a/content/blog/why-signals-bend.md b/content/blog/why-signals-bend.md index 9b8d9d1..494f28d 100644 --- a/content/blog/why-signals-bend.md +++ b/content/blog/why-signals-bend.md @@ -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
-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.
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.
-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.
-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.
-Figuring out whether that duct is there on any given day is the whole point of . 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 . 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. + +
+ +**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. + +
+ +**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. + +
+ +**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. + +
+ +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. + +
+ +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 . 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 index 622ff52..1249ed1 100644 --- a/static/css/nlos.css +++ b/static/css/nlos.css @@ -1,28 +1,33 @@ -/* Styles scoped to the NLOS propagation post. Only loaded when the post's - 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); } diff --git a/static/css/scene.css b/static/css/scene.css index 6d08441..c1801d8 100644 --- a/static/css/scene.css +++ b/static/css/scene.css @@ -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; } diff --git a/static/js/nlos.js b/static/js/nlos.js index 6340cfa..ed8cee5 100644 --- a/static/js/nlos.js +++ b/static/js/nlos.js @@ -4,17 +4,18 @@ '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', + bg: '#1a1d24', + panel: '#242932', + grid: '#2d323d', + dim: '#8189a0', + fg: '#d8dce4', + blue: '#78b5f3', + cyan: '#6dc5d3', + green: '#9dca83', + orange: '#d6a86a', + red: '#e58089', + yellow: '#e2c37d', + magenta: '#c78de0', }; // Real Earth radius in meters. @@ -32,6 +33,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(); const ro = new ResizeObserver(fit); @@ -297,7 +300,7 @@ { 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.', + 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; @@ -417,7 +420,7 @@ { 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.', + 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; @@ -580,7 +583,7 @@ { 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.', + 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; @@ -803,7 +806,7 @@ { 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.', + 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; @@ -891,16 +894,18 @@ // 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, + height: 360, 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' }, + { key: 'slope', label: 'Lapse rate dN/dh', min: -300, 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' }, + { key: 'dmdh', label: 'dM/dh' }, ], - 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.', + caption: 'Two ways of drawing the same atmosphere. N-units fall off with altitude in any real atmosphere. Modified refractivity M = N + 0.157*h (h in meters) folds Earth\'s curvature in, so a ray in a medium with dM/dh = 0 travels parallel to the surface and a layer with dM/dh < 0 is a duct. Crank the slope past −157 N/km and watch the M curve flip its slope.', + threeCol: true, }); if (!s) return; @@ -909,73 +914,130 @@ 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; + // Split into two panels: N profile on the left, M profile on the right. + // Extra top margin so the N(h) / M(h) panel titles sit ABOVE the frame. + const margin = { l: 55, r: 24, t: 44, b: 40 }; + const gap = 44; + const panelW = (w - margin.l - margin.r - gap) / 2; + const pY = margin.t; const pH = h - margin.t - margin.b; + const altMax = 5; // km - 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; + // M range: at h=0 M=N, at h=altMax M=N+altMax*157. With N starting at 280-420, + // M ranges roughly 280 to 1200. Start the axis near the ground value so the + // panel isn't mostly empty left space. + const mMin = 260, mMax = 1260; + + const leftX = margin.l; + const rightX = margin.l + panelW + gap; + const yOf = a => pY + (1 - a / altMax) * pH; + const xOfN = n => leftX + (n - nMin) / (nMax - nMin) * panelW; + const xOfM = m => rightX + (m - mMin) / (mMax - mMin) * panelW; - // 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(); + const slope = s.values.slope; + const dMdh_per_km = slope + 157; + + function drawPanel(x0, xScale, xMin, xMax, axisTitle, panelTitle, userFn, stdFn, ductShade, cornerText, cornerColor) { + // Panel title ABOVE the frame so it never collides with the 5 km tick. + labelText(ctx, panelTitle, x0, pY - 10, COLORS.cyan); + if (cornerText) { + labelText(ctx, cornerText, x0 + panelW, pY - 10, cornerColor, 'right'); + } + + // Duct shading sits beneath everything else in the panel. + if (ductShade) { + ctx.fillStyle = 'rgba(109, 197, 211, 0.12)'; + ctx.fillRect(x0, pY, panelW, pH); + } + + ctx.strokeStyle = COLORS.grid; + ctx.strokeRect(x0, pY, panelW, pH); + + // x-axis gridlines and tick labels. Skip the first and last so they + // never visually run into the neighboring panel's ticks. + const steps = 6; + const xStep = (xMax - xMin) / steps; + for (let i = 0; i <= steps; i++) { + const v = xMin + i * xStep; + const xv = xScale(v); + if (i > 0 && i < steps) { + ctx.beginPath(); ctx.moveTo(xv, pY); ctx.lineTo(xv, pY + pH); ctx.stroke(); + labelText(ctx, String(Math.round(v)), xv, pY + pH + 14, COLORS.dim, 'center'); + } + } + // y-axis + for (let a = 0; a <= 5; a++) { + ctx.beginPath(); ctx.moveTo(x0, yOf(a)); ctx.lineTo(x0 + panelW, yOf(a)); ctx.stroke(); + // keep top and bottom ticks inset so 5 km doesn't clip at panel edge + const ty = a === 5 ? yOf(a) + 6 : a === 0 ? yOf(a) - 6 : yOf(a); + labelText(ctx, `${a} km`, x0 - 6, ty, COLORS.dim, 'right', 'middle'); + } + labelText(ctx, axisTitle, x0 + panelW / 2, pY + pH + 28, COLORS.dim, 'center'); + + // Standard 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 xv = xScale(stdFn(a)); + const yv = yOf(a); + if (i === 0) ctx.moveTo(xv, yv); else ctx.lineTo(xv, yv); + } + ctx.stroke(); + ctx.setLineDash([]); + + // User profile + ctx.strokeStyle = COLORS.cyan; + ctx.lineWidth = 2.5; + ctx.beginPath(); + for (let i = 0; i <= 50; i++) { + const a = altMax * i / 50; + const xv = xScale(userFn(a)); + const yv = yOf(a); + if (i === 0) ctx.moveTo(xv, yv); else ctx.lineTo(xv, yv); + } + ctx.stroke(); + } + + // LEFT: N profile + drawPanel( + leftX, + xOfN, + nMin, nMax, + 'N-units', + 'N(h)', + a => nSurface + slope * a, + a => 315 * Math.exp(-0.136 * a), + ); + + // RIGHT: M profile. Ducting shaded when user's dM/dh is negative. + const corner = dMdh_per_km < 0 ? 'dM/dh < 0 (duct)' : 'dM/dh > 0 (no duct)'; + const cornerColor = dMdh_per_km < 0 ? COLORS.green : COLORS.dim; + drawPanel( + rightX, + xOfM, + mMin, mMax, + 'M-units (M = N + 0.157*h)', + 'M(h)', + a => (nSurface + slope * a) + 157 * a, + a => 315 * Math.exp(-0.136 * a) + 157 * a, + dMdh_per_km < 0, + corner, + cornerColor, + ); - // 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 + if (slope <= -157) regime = 'trapping (duct)'; const k = 1 / (1 + (R_EARTH / 1000) * 1e-6 * slope); s.setReadout('regime', regime); - s.setReadout('keff', k.toFixed(2)); + s.setReadout('keff', k < 0 || !isFinite(k) ? '∞' : k.toFixed(2)); + s.setReadout('dmdh', `${dMdh_per_km.toFixed(0)} M/km`); requestAnimationFrame(draw); } @@ -1003,6 +1065,14 @@ }); if (!s) return; + // Ray-persistence state: capture the current ray whenever a slider value + // changes and fade it out over TRAIL_LIFE seconds. Lets the reader see + // the family of trajectories as they drag dN/dh around. + const TRAIL_LIFE = 2.5; + const MAX_TRAILS = 40; + const trails = []; + let lastVals = null; + function draw() { const { w, h } = s.getSize(); const ctx = s.ctx; @@ -1011,8 +1081,8 @@ 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. + // Effective-Earth radius. When k is negative (trapping), Reff flips and + // the scene should show an essentially flat-curving ground, so clamp huge. let Reff = k * R_EARTH; if (k < 0 || !isFinite(k)) Reff = 1e12; @@ -1022,8 +1092,8 @@ 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 maxDropM = 500; + const launchAltM = 80; const pxPerM_x = pW / distM; const pxPerM_y = pH / (maxDropM + launchAltM); @@ -1096,15 +1166,12 @@ ctx.stroke(); ctx.setLineDash([]); - // Ray: straight in effective-Earth frame. + // Compute the ray as a list of points. 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 rayPts = [{ x: startX, y: startY }]; const stepM = 200; let hit = null; for (let dxm = stepM; dxm <= distM; dxm += stepM) { @@ -1113,7 +1180,6 @@ 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); @@ -1121,18 +1187,50 @@ const hitDxm = prevDxm + t * stepM; const hitX = startX + hitDxm * pxPerM_x; const hitY = groundYatDx(hitDxm); - ctx.lineTo(hitX, hitY); + rayPts.push({ x: hitX, y: hitY }); hit = { x: hitX, y: hitY, dxm: hitDxm }; break; } - ctx.lineTo(x, y); + rayPts.push({ 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); + rayPts.push({ x: margin.l + pW, y: startY - Math.tan(angleRad) * distM * pxPerM_y }); } + + // Capture a new trail whenever any slider value changed since last frame. + const nowMs = performance.now(); + const cur = `${s.values.slope}|${s.values.height}|${s.values.angle}`; + if (lastVals !== null && cur !== lastVals) { + trails.push({ points: rayPts.map(p => ({ x: p.x, y: p.y })), color: rayColor, capturedAt: nowMs }); + while (trails.length > MAX_TRAILS) trails.shift(); + } + lastVals = cur; + + // Draw fading trails first. + for (let i = trails.length - 1; i >= 0; i--) { + const tr = trails[i]; + const age = (nowMs - tr.capturedAt) / 1000; + if (age > TRAIL_LIFE) { trails.splice(i, 1); continue; } + const alpha = (1 - age / TRAIL_LIFE) * 0.55; + ctx.save(); + ctx.globalAlpha = alpha; + ctx.strokeStyle = tr.color; + ctx.lineWidth = 1.2; + ctx.beginPath(); + tr.points.forEach((p, j) => { + if (j === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y); + }); + ctx.stroke(); + ctx.restore(); + } + + // Current ray on top. + ctx.strokeStyle = rayColor; + ctx.lineWidth = 2; + ctx.beginPath(); + rayPts.forEach((p, j) => { + if (j === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y); + }); ctx.stroke(); if (hit) { ctx.fillStyle = rayColor; @@ -1145,7 +1243,7 @@ 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); + 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); // Regime readouts let regime = 'standard'; @@ -1226,50 +1324,85 @@ 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. + // Trace multiple rays, storing their point lists for later animation. const angles = []; const launchDeg = s.values.launch; for (let i = -3; i <= 3; i++) angles.push(launchDeg + i * 0.15); let trapped = 0; + const rayData = []; // per-ray: { points: [{x,y,s}], color, trapped } 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 + let vyx = Math.tan(aRad); 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); + const pts = [{ + x: margin.l + xm * pxPerM_x, + y: groundY - ym * pxPerM_y, + s: 0, // arc length proxy, used for glint placement + }]; + let sAcc = 0; 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). + const kappa = insideDuct ? insideCurvature - earthCurvature : outsideCurvature - earthCurvature; 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); + if (ym < 0) { ym = -ym; vyx = -vyx; } + if (ym > maxAltM) break; + const px = margin.l + xm * pxPerM_x; + const py = groundY - ym * pxPerM_y; + const last = pts[pts.length - 1]; + sAcc += Math.hypot(px - last.x, py - last.y); + pts.push({ x: px, y: py, s: sAcc }); } - ctx.stroke(); - ctx.globalAlpha = 1; + rayData.push({ points: pts, color, trapped: isTrapped, totalS: sAcc }); }); - // antenna at origin + // Draw the ray paths. + rayData.forEach(r => { + ctx.strokeStyle = r.color; + ctx.lineWidth = 1.5; + ctx.globalAlpha = 0.9; + ctx.beginPath(); + r.points.forEach((p, i) => { + if (i === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y); + }); + ctx.stroke(); + }); + ctx.globalAlpha = 1; + + // Animated glints traveling along each trapped ray. + const CYCLE = 4.0; + const now = (performance.now() / 1000) % CYCLE; + const tNorm = now / CYCLE; + rayData.forEach((r, idx) => { + if (!r.trapped || r.totalS <= 0) return; + const targetS = (tNorm + idx * 0.12) % 1 * r.totalS; + // find the segment that contains targetS + for (let i = 1; i < r.points.length; i++) { + const p0 = r.points[i - 1], p1 = r.points[i]; + if (targetS >= p0.s && targetS <= p1.s) { + const f = (targetS - p0.s) / (p1.s - p0.s + 1e-6); + const gx = p0.x + (p1.x - p0.x) * f; + const gy = p0.y + (p1.y - p0.y) * f; + ctx.save(); + ctx.fillStyle = COLORS.green; + ctx.shadowColor = COLORS.green; + ctx.shadowBlur = 12; + ctx.beginPath(); + ctx.arc(gx, gy, 3.5, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + break; + } + } + }); + + // Antenna at origin drawAntenna(ctx, margin.l, groundY, 12, COLORS.orange); s.setReadout('trapped', `${trapped} of ${angles.length}`); @@ -1294,9 +1427,9 @@ 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.' }, + { key: 'surface', label: 'Radiation duct', desc: 'Forms at night under clear calm skies as the ground radiates heat to space faster than the air above it cools. 50-300 m deep, gradients −100 to −300 N/km. Peaks in the hour before dawn, erodes soon after sunrise. The dominant 10 GHz DX mechanism inland.' }, + { key: 'elevated', label: 'Subsidence duct', desc: 'Elevated layer under the axis of a high-pressure ridge, where sinking air warms and dries. Sits 500-2000 m up and can stay there for days. Both ends of the link usually need to be inside or aimed into the layer.' }, + { key: 'evap', label: 'Evaporation duct', desc: 'Very thin (5-40 m) duct formed by the humidity gradient right above warm water. There nearly 24/7 over the ocean. Irrelevant inland but dominates over-water microwave paths.' }, ]; let active = 'surface'; const buttons = {}; @@ -1528,7 +1661,7 @@ 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.'; + 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); @@ -1679,6 +1812,549 @@ requestAnimationFrame(draw); } + // ---------- Scene: what the data says ---------- + function sceneData() { + const root = document.getElementById('scene-data'); + if (!root) return; + root.classList.add('nlos-scene'); + const canvas = document.createElement('canvas'); + canvas.style.width = '100%'; + canvas.style.height = '210px'; + root.appendChild(canvas); + const cap = document.createElement('div'); + cap.className = 'nlos-caption'; + cap.textContent = 'Median 10 GHz contact distance, bucketed three different ways. Surface pressure (lower works better than the folklore suggests), boundary-layer depth (shallow wins), and the yes/no ducting flag from HRRR (almost no signal). Each bar animates to full length on load.'; + root.appendChild(cap); + + const { ctx, getSize } = setupCanvas(canvas); + const startWall = performance.now(); + + // Real numbers from the project dataset. + const panels = [ + { + title: 'Surface pressure', + unit: 'km median distance', + bars: [ + { label: '< 970 mb', value: 243, color: COLORS.green }, + { label: '970-1000', value: 190, color: COLORS.cyan }, + { label: '1000-1020', value: 150, color: COLORS.yellow }, + { label: '> 1020 mb', value: 103, color: COLORS.orange }, + ], + max: 260, + }, + { + title: 'Boundary layer height (HPBL)', + unit: 'km median distance', + bars: [ + { label: '< 200 m', value: 230, color: COLORS.green }, + { label: '200-500', value: 195, color: COLORS.cyan }, + { label: '500-2000', value: 150, color: COLORS.yellow }, + { label: '> 2000 m', value: 100, color: COLORS.orange }, + ], + max: 260, + }, + { + title: 'Binary duct flag', + unit: 'km median distance', + bars: [ + { label: 'duct: YES', value: 189, color: COLORS.cyan }, + { label: 'duct: NO', value: 192, color: COLORS.cyan }, + ], + max: 260, + note: 'statistically identical', + }, + ]; + + function draw() { + const { w, h } = getSize(); + clear(ctx, w, h); + + const margin = { l: 20, r: 20, t: 25, b: 30 }; + const pW = w - margin.l - margin.r; + const pH = h - margin.t - margin.b; + const gap = 20; + const colW = (pW - gap * (panels.length - 1)) / panels.length; + + const now = (performance.now() - startWall) / 1000; + const animT = Math.min(1, now / 1.2); // 1.2 second reveal + + panels.forEach((panel, ci) => { + const x0 = margin.l + ci * (colW + gap); + labelText(ctx, panel.title, x0, margin.t + 10, COLORS.fg); + labelText(ctx, panel.unit, x0, margin.t + 24, COLORS.dim); + + const chartTop = margin.t + 38; + const chartBot = margin.t + pH - 10; + const chartH = chartBot - chartTop; + const barCount = panel.bars.length; + const barH = Math.min(26, (chartH - 6 * (barCount - 1)) / barCount); + const labelW = 70; + + panel.bars.forEach((b, bi) => { + const by = chartTop + bi * (barH + 8); + // label on the left + labelText(ctx, b.label, x0 + labelW - 4, by + barH / 2, COLORS.dim, 'right', 'middle'); + // bar + const fullW = colW - labelW - 40; + const w_actual = (b.value / panel.max) * fullW * animT; + ctx.fillStyle = b.color; + ctx.globalAlpha = 0.85; + ctx.beginPath(); + // rounded rect for nicer look + const r = Math.min(4, barH / 2); + ctx.moveTo(x0 + labelW, by + r); + ctx.arcTo(x0 + labelW, by, x0 + labelW + r, by, r); + ctx.lineTo(x0 + labelW + w_actual - r, by); + ctx.arcTo(x0 + labelW + w_actual, by, x0 + labelW + w_actual, by + r, r); + ctx.lineTo(x0 + labelW + w_actual, by + barH - r); + ctx.arcTo(x0 + labelW + w_actual, by + barH, x0 + labelW + w_actual - r, by + barH, r); + ctx.lineTo(x0 + labelW + r, by + barH); + ctx.arcTo(x0 + labelW, by + barH, x0 + labelW, by + barH - r, r); + ctx.closePath(); + ctx.fill(); + ctx.globalAlpha = 1; + // value label at end + if (animT > 0.5) { + ctx.globalAlpha = Math.min(1, (animT - 0.5) * 3); + labelText(ctx, `${b.value} km`, x0 + labelW + w_actual + 6, by + barH / 2, COLORS.fg, 'left', 'middle'); + ctx.globalAlpha = 1; + } + }); + + if (panel.note && animT > 0.9) { + // Put the note right below the last bar so it doesn't float in empty space. + const lastBarBottom = chartTop + barCount * (barH + 8); + labelText(ctx, panel.note, x0 + labelW, lastBarBottom + 10, COLORS.dim); + } + }); + + requestAnimationFrame(draw); + } + requestAnimationFrame(draw); + } + + // ---------- Scene: time-of-day x band heatmap ---------- + function sceneBandTime() { + const root = document.getElementById('scene-band-time'); + if (!root) return; + root.classList.add('nlos-scene'); + const canvas = document.createElement('canvas'); + canvas.style.width = '100%'; + canvas.style.height = '260px'; + root.appendChild(canvas); + const cap = document.createElement('div'); + cap.className = 'nlos-caption'; + cap.textContent = 'Percent bonus to median contact distance during the dawn window vs the daily mean, per band. 10 GHz barely cares what time it is. 75 GHz cares more than anything else.'; + root.appendChild(cap); + + const { ctx, getSize } = setupCanvas(canvas); + const startWall = performance.now(); + + // Dawn-window enhancement by band, from the project\'s correlation runs. + const bands = [ + { label: '10 GHz', enh: 4 }, + { label: '24 GHz', enh: 28 }, + { label: '47 GHz', enh: 36 }, + { label: '75 GHz', enh: 360 }, + ]; + const maxEnh = 400; + + function draw() { + const { w, h } = getSize(); + clear(ctx, w, h); + + const margin = { l: 80, r: 60, t: 25, b: 30 }; + const pW = w - margin.l - margin.r; + const pH = h - margin.t - margin.b; + + const now = (performance.now() - startWall) / 1000; + const animT = Math.min(1, now / 1.5); + + labelText(ctx, 'Dawn-window enhancement over daily mean', margin.l, margin.t - 6, COLORS.fg); + + const rowH = pH / bands.length; + bands.forEach((b, i) => { + const y = margin.t + i * rowH; + labelText(ctx, b.label, margin.l - 6, y + rowH / 2, COLORS.dim, 'right', 'middle'); + // bar uses log-ish scale so small values are still visible + const t = Math.pow(b.enh / maxEnh, 0.45); + const bw = t * pW * animT; + // color ramps from cyan (low) to magenta (high) + const lerp = b.enh / maxEnh; + const color = lerp < 0.1 ? COLORS.cyan : + lerp < 0.3 ? COLORS.green : + lerp < 0.6 ? COLORS.yellow : + lerp < 0.85 ? COLORS.orange : COLORS.magenta; + ctx.fillStyle = color; + ctx.globalAlpha = 0.85; + const r = Math.min(4, rowH * 0.35); + const bh = rowH * 0.7; + const by = y + (rowH - bh) / 2; + ctx.beginPath(); + ctx.moveTo(margin.l, by + r); + ctx.arcTo(margin.l, by, margin.l + r, by, r); + ctx.lineTo(margin.l + bw - r, by); + ctx.arcTo(margin.l + bw, by, margin.l + bw, by + r, r); + ctx.lineTo(margin.l + bw, by + bh - r); + ctx.arcTo(margin.l + bw, by + bh, margin.l + bw - r, by + bh, r); + ctx.lineTo(margin.l + r, by + bh); + ctx.arcTo(margin.l, by + bh, margin.l, by + bh - r, r); + ctx.closePath(); + ctx.fill(); + ctx.globalAlpha = 1; + if (animT > 0.6) { + ctx.globalAlpha = Math.min(1, (animT - 0.6) * 3); + labelText(ctx, `+${b.enh}%`, margin.l + bw + 6, by + bh / 2, COLORS.fg, 'left', 'middle'); + ctx.globalAlpha = 1; + } + }); + + requestAnimationFrame(draw); + } + requestAnimationFrame(draw); + } + + // ---------- Scene: humidity sweet spot (PWAT vs distance) ---------- + function scenePwat() { + const root = document.getElementById('scene-pwat'); + if (!root) return; + root.classList.add('nlos-scene'); + const canvas = document.createElement('canvas'); + canvas.style.width = '100%'; + canvas.style.height = '320px'; + root.appendChild(canvas); + + // Drag state + let pwat = 25; + + const caption = document.createElement('div'); + caption.className = 'nlos-caption'; + caption.textContent = 'Median contact distance vs precipitable water. The 10 GHz curve has a clear hump around 25 mm (not too dry, not too wet). The 24 GHz curve is monotonically worse as humidity climbs, because the atmospheric absorption line at 22.235 GHz eats more signal the more water there is in the column. Drag the cursor to move the readout.'; + root.appendChild(caption); + + const { ctx, getSize } = setupCanvas(canvas); + const startWall = performance.now(); + + // Simplified model of the data shape: 10 GHz peaks near 25 mm, 24 GHz falls off. + function dist10(p) { + // Gaussian hump centered at 25 mm, plus a drift + const peak = 225, base = 140; + const sigma = 18; + return base + (peak - base) * Math.exp(-Math.pow((p - 25) / sigma, 2)); + } + function dist24(p) { + // Decreasing with PWAT, saturating + return 160 - 70 * (1 - Math.exp(-p / 18)); + } + + canvas.addEventListener('pointerdown', handle); + canvas.addEventListener('pointermove', e => { if (e.buttons) handle(e); }); + function handle(e) { + const { w } = getSize(); + const margin = { l: 60, r: 30 }; + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const pW = w - margin.l - margin.r; + const pMin = 0, pMax = 60; + const xFrac = Math.max(0, Math.min(1, (x - margin.l) / pW)); + pwat = pMin + xFrac * (pMax - pMin); + } + + function draw() { + const { w, h } = getSize(); + clear(ctx, w, h); + + const margin = { l: 60, r: 30, t: 40, b: 40 }; + const pW = w - margin.l - margin.r; + const pH = h - margin.t - margin.b; + + const pMin = 0, pMax = 60; + const dMin = 0, dMax = 260; + const xOf = p => margin.l + (p - pMin) / (pMax - pMin) * pW; + const yOf = d => margin.t + (1 - (d - dMin) / (dMax - dMin)) * pH; + + // gridlines + ctx.strokeStyle = COLORS.grid; + ctx.lineWidth = 1; + for (let p = 0; p <= 60; p += 10) { + const x = xOf(p); + ctx.beginPath(); ctx.moveTo(x, margin.t); ctx.lineTo(x, margin.t + pH); ctx.stroke(); + labelText(ctx, `${p}`, x, margin.t + pH + 14, COLORS.dim, 'center'); + } + for (let d = 0; d <= 260; d += 50) { + const y = yOf(d); + ctx.beginPath(); ctx.moveTo(margin.l, y); ctx.lineTo(margin.l + pW, y); ctx.stroke(); + labelText(ctx, `${d}`, margin.l - 6, y, COLORS.dim, 'right', 'middle'); + } + ctx.strokeStyle = COLORS.grid; + ctx.strokeRect(margin.l, margin.t, pW, pH); + labelText(ctx, 'precipitable water (mm)', margin.l + pW / 2, margin.t + pH + 28, COLORS.dim, 'center'); + ctx.save(); + ctx.translate(margin.l - 44, margin.t + pH / 2); + ctx.rotate(-Math.PI / 2); + labelText(ctx, 'median distance (km)', 0, 0, COLORS.dim, 'center'); + ctx.restore(); + + // Animate reveal + const elapsed = (performance.now() - startWall) / 1000; + const animT = Math.min(1, elapsed / 1.5); + + function drawCurve(fn, color, label, labelAt) { + ctx.strokeStyle = color; + ctx.lineWidth = 2.5; + ctx.beginPath(); + const N = 120; + const endI = Math.floor(N * animT); + for (let i = 0; i <= endI; i++) { + const p = pMin + (pMax - pMin) * i / N; + const x = xOf(p), y = yOf(fn(p)); + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.stroke(); + if (animT >= 0.95) { + labelText(ctx, label, xOf(labelAt), yOf(fn(labelAt)) - 8, color); + } + } + drawCurve(dist10, COLORS.cyan, '10 GHz', 25); + drawCurve(dist24, COLORS.orange, '24 GHz', 45); + + // Current-point cursor + const cursorX = xOf(pwat); + ctx.strokeStyle = COLORS.yellow; + ctx.setLineDash([3, 4]); + ctx.beginPath(); ctx.moveTo(cursorX, margin.t); ctx.lineTo(cursorX, margin.t + pH); ctx.stroke(); + ctx.setLineDash([]); + const y10 = yOf(dist10(pwat)); + const y24 = yOf(dist24(pwat)); + ctx.fillStyle = COLORS.cyan; + ctx.beginPath(); ctx.arc(cursorX, y10, 5, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = COLORS.orange; + ctx.beginPath(); ctx.arc(cursorX, y24, 5, 0, Math.PI * 2); ctx.fill(); + + labelText(ctx, `PWAT ${pwat.toFixed(0)} mm`, cursorX + 8, margin.t + 14, COLORS.yellow); + labelText(ctx, `10 GHz: ${Math.round(dist10(pwat))} km`, cursorX + 8, y10 + 14, COLORS.cyan); + labelText(ctx, `24 GHz: ${Math.round(dist24(pwat))} km`, cursorX + 8, y24 - 8, COLORS.orange); + + requestAnimationFrame(draw); + } + requestAnimationFrame(draw); + } + + // ---------- Scene: seasonal ducting probability ---------- + function sceneSeasonal() { + const root = document.getElementById('scene-seasonal'); + 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 cap = document.createElement('div'); + cap.className = 'nlos-caption'; + cap.textContent = 'Fraction of HRRR profiles containing any duct, by month. The spring minimum (March near 11%) and the summer plateau (June and July near 75%) are the two features that surprise people who assume winter is the dead season.'; + root.appendChild(cap); + + const { ctx, getSize } = setupCanvas(canvas); + const startWall = performance.now(); + + // Monthly ducting-probability data (percent). + const months = [ + { name: 'Jan', pct: 20 }, + { name: 'Feb', pct: 16 }, + { name: 'Mar', pct: 11 }, + { name: 'Apr', pct: 24 }, + { name: 'May', pct: 48 }, + { name: 'Jun', pct: 69 }, + { name: 'Jul', pct: 77 }, + { name: 'Aug', pct: 64 }, + { name: 'Sep', pct: 41 }, + { name: 'Oct', pct: 26 }, + { name: 'Nov', pct: 18 }, + { name: 'Dec', pct: 12 }, + ]; + + function seasonColor(i) { + // Winter cyan, spring green, summer yellow/orange, fall amber + if (i <= 1 || i === 11) return COLORS.cyan; + if (i <= 3) return COLORS.green; + if (i <= 7) return COLORS.yellow; + return COLORS.orange; + } + + function draw() { + const { w, h } = getSize(); + clear(ctx, w, h); + + const margin = { l: 50, r: 24, t: 35, b: 40 }; + const pW = w - margin.l - margin.r; + const pH = h - margin.t - margin.b; + + labelText(ctx, 'monthly ducting probability (% of HRRR profiles)', margin.l, margin.t - 10, COLORS.fg); + + const gap = 6; + const barW = (pW - gap * (months.length - 1)) / months.length; + const maxPct = 85; + + // y ticks + ctx.strokeStyle = COLORS.grid; + for (let p = 0; p <= 80; p += 20) { + const y = margin.t + (1 - p / maxPct) * pH; + ctx.beginPath(); ctx.moveTo(margin.l, y); ctx.lineTo(margin.l + pW, y); ctx.stroke(); + labelText(ctx, `${p}%`, margin.l - 6, y, COLORS.dim, 'right', 'middle'); + } + + const elapsed = (performance.now() - startWall) / 1000; + const animT = Math.min(1, elapsed / 1.6); + + months.forEach((m, i) => { + const x = margin.l + i * (barW + gap); + const targetH = (m.pct / maxPct) * pH * animT; + const y = margin.t + pH - targetH; + const col = seasonColor(i); + ctx.fillStyle = col; + ctx.globalAlpha = 0.9; + const r = Math.min(5, barW / 3); + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + barW, y, x + barW, y + r, r); + ctx.lineTo(x + barW, margin.t + pH); + ctx.lineTo(x, margin.t + pH); + ctx.lineTo(x, y + r); + ctx.arcTo(x, y, x + r, y, r); + ctx.closePath(); + ctx.fill(); + ctx.globalAlpha = 1; + labelText(ctx, m.name, x + barW / 2, margin.t + pH + 14, COLORS.dim, 'center'); + if (animT > 0.7) { + ctx.globalAlpha = Math.min(1, (animT - 0.7) * 3); + labelText(ctx, `${m.pct}`, x + barW / 2, y - 6, COLORS.fg, 'center'); + ctx.globalAlpha = 1; + } + }); + + requestAnimationFrame(draw); + } + requestAnimationFrame(draw); + } + + // ---------- Scene: the 10 GHz link budget ---------- + function sceneBudget() { + const root = document.getElementById('scene-budget'); + if (!root) return; + root.classList.add('nlos-scene'); + const canvas = document.createElement('canvas'); + canvas.style.width = '100%'; + canvas.style.height = '340px'; + root.appendChild(canvas); + const cap = document.createElement('div'); + cap.className = 'nlos-caption'; + cap.textContent = 'Budget for a 200 km 10 GHz contact. Free space loss sets the starting depth. Diffraction against terrain adds 36 dB on average. Troposcatter, which is always present, recovers ~20 dB. A duct layered on top adds up to another 14 dB. Station gain and transmit EIRP close the rest. Labels animate in sequence to show the accumulation.'; + root.appendChild(cap); + + const { ctx, getSize } = setupCanvas(canvas); + const startWall = performance.now(); + + // Rough 200 km 10 GHz budget: TX 40 dBm, 30 dBi dish each end, NF 3 dB, + // 2 kHz CW bandwidth. Troposcatter recovers a chunk of the diffraction + // loss; duct enhancement is a bonus on top when the atmosphere cooperates. + const segments = [ + { label: 'Free-space path loss', dB: 158, color: COLORS.red, role: 'loss' }, + { label: 'Diffraction (terrain)', dB: 36, color: COLORS.orange, role: 'loss' }, + { label: 'Troposcatter recovery', dB: -20, color: COLORS.cyan, role: 'gain' }, + { label: 'Duct enhancement', dB: -14, color: COLORS.green, role: 'gain' }, + { label: 'Station (TX EIRP + RX gain)', dB: -168, color: COLORS.blue, role: 'gain' }, + ]; + + function draw() { + const { w, h } = getSize(); + clear(ctx, w, h); + + // Leave enough right margin for the end-of-bar value labels (up to + // "+158 dB" on the free-space row), and enough left margin for the + // row labels ("Station (TX EIRP + RX gain)"). + const margin = { l: 210, r: 80, t: 30, b: 60 }; + const pW = w - margin.l - margin.r; + const pH = h - margin.t - margin.b; + + labelText(ctx, '10 GHz link budget (200 km path, ducting day)', margin.l - 200, margin.t - 12, COLORS.fg); + + const elapsed = (performance.now() - startWall) / 1000; + + const rowH = pH / segments.length; + const maxAbs = 180; // dB. Slightly larger than the largest segment (168) so labels fit. + + let accumulated = 0; + segments.forEach((seg, i) => { + const y = margin.t + i * rowH; + const by = y + rowH * 0.15; + const bh = rowH * 0.55; + + const rowStart = i * 0.4; + const rowAnim = Math.max(0, Math.min(1, (elapsed - rowStart) / 0.7)); + if (rowAnim <= 0) { accumulated += seg.dB; return; } + + labelText(ctx, seg.label, margin.l - 10, y + rowH / 2, COLORS.fg, 'right', 'middle'); + const sign = seg.role === 'loss' ? '+' : '−'; + const mag = Math.abs(seg.dB); + const barLen = (mag / maxAbs) * pW * rowAnim; + + ctx.fillStyle = seg.color; + ctx.globalAlpha = 0.9; + const r = 5; + ctx.beginPath(); + ctx.moveTo(margin.l + r, by); + ctx.arcTo(margin.l + barLen, by, margin.l + barLen, by + r, r); + ctx.lineTo(margin.l + barLen, by + bh - r); + ctx.arcTo(margin.l + barLen, by + bh, margin.l + barLen - r, by + bh, r); + ctx.lineTo(margin.l + r, by + bh); + ctx.arcTo(margin.l, by + bh, margin.l, by + bh - r, r); + ctx.lineTo(margin.l, by + r); + ctx.arcTo(margin.l, by, margin.l + r, by, r); + ctx.closePath(); + ctx.fill(); + ctx.globalAlpha = 1; + + if (rowAnim >= 0.95) { + labelText(ctx, `${sign}${mag} dB`, margin.l + barLen + 8, by + bh / 2, COLORS.fg, 'left', 'middle'); + } + accumulated += seg.dB; + }); + + // Separator line + centered result box below the bars. + const allDone = elapsed > 0.4 * segments.length + 0.7; + if (allDone) { + const totalAnim = Math.min(1, (elapsed - (0.4 * segments.length + 0.7)) / 0.6); + ctx.globalAlpha = totalAnim; + + const sepY = margin.t + pH + 8; + ctx.strokeStyle = COLORS.grid; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(margin.l, sepY); + ctx.lineTo(margin.l + pW, sepY); + ctx.stroke(); + + const marginDb = -accumulated; // positive = closes + const ok = marginDb >= 0; + const labelLeft = ok ? 'Result:' : 'Result:'; + const valueLabel = ok ? `+${marginDb.toFixed(0)} dB` : `${marginDb.toFixed(0)} dB`; + const statusLabel = ok ? 'contact closes' : 'contact fails'; + + const resultY = sepY + 22; + // Keep the result aligned flush with the left of the bars row, so it + // visually lines up under the bar column. + labelText(ctx, labelLeft, margin.l, resultY, COLORS.dim, 'left', 'middle'); + labelText(ctx, valueLabel, margin.l + 56, resultY, ok ? COLORS.green : COLORS.red, 'left', 'middle', 14); + labelText(ctx, statusLabel, margin.l + 120, resultY, ok ? COLORS.green : COLORS.red, 'left', 'middle'); + + ctx.globalAlpha = 1; + } + + requestAnimationFrame(draw); + } + requestAnimationFrame(draw); + } + // ---------- boot ---------- function boot() { // Tag the article wrapper so CSS vars apply to descendants. @@ -1695,6 +2371,11 @@ sceneDuct(); sceneDuctTypes(); sceneReal(); + sceneData(); + sceneBandTime(); + scenePwat(); + sceneSeasonal(); + sceneBudget(); } if (document.readyState === 'loading') { diff --git a/static/js/noise.js b/static/js/noise.js index b3786ca..fb69acb 100644 --- a/static/js/noise.js +++ b/static/js/noise.js @@ -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); diff --git a/static/js/tcpudp.js b/static/js/tcpudp.js index 75ba241..9877db1 100644 --- a/static/js/tcpudp.js +++ b/static/js/tcpudp.js @@ -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); diff --git a/static/js/transistor.js b/static/js/transistor.js index 796bb56..607cf00 100644 --- a/static/js/transistor.js +++ b/static/js/transistor.js @@ -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); diff --git a/templates/index.html b/templates/index.html index 5048ad1..8428356 100644 --- a/templates/index.html +++ b/templates/index.html @@ -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 %} diff --git a/templates/section.html b/templates/section.html index fec03c7..121977b 100644 --- a/templates/section.html +++ b/templates/section.html @@ -8,10 +8,13 @@ {{ section.content | safe }}
    + {# 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 %}