Total solar eclipse, 12 August 2026 · Catalonia / Aragón / Valencia
On 12 August 2026 the Moon’s umbra crosses northern Spain. From the latitude of Barcelona totality happens with the Sun 3–7° above the horizon, at azimuth ≈285° (WNW), roughly twenty minutes before sunset.
That is the whole problem in one sentence. At 5° altitude a ridge only has to be 90 m high at 1 km to erase the event. Every other eclipse map you have seen is a map of the sky. This is a map of the ground, because at this altitude the ground is what decides.
The output is a single number per 50 m pixel, the Eclipse Visibility Index, plus every ingredient that went into it, so you can disagree with the weighting and re-run it in your browser without re-running anything on a server.
The Moon’s shadow is described in the classical Besselian frame: a plane through the centre of the Earth, perpendicular to the shadow axis, with the axis piercing it at \((x(t), y(t))\). The IAU/NASA canon publishes, for each eclipse, polynomials in \(t\) (hours from a reference instant \(t_0\)) for
\[x,\; y,\; d,\; \mu,\; l_1,\; l_2,\; \tan f_1,\; \tan f_2\]
where \(d, \mu\) are the declination and hour angle of the shadow axis, and \(l_1, l_2\) are the radii of the penumbral and umbral cones on the fundamental plane.
For an observer at \((\varphi, \lambda, h)\) we form the observer’s coordinates in that same frame, \((\xi, \eta, \zeta)\), and the umbral radius at the observer’s distance along the axis:
\[L_2' = l_2 - \zeta \tan f_2\]
The observer is inside totality when their distance from the axis is smaller than that radius:
\[m^2 = (\xi - x)^2 + (\eta - y)^2 \;\le\; L_2'^2\]
Solving \(m(t) = |L_2'(t)|\) for the two roots gives second contact C2 and third contact C3; totality lasts \(\Delta t = t_{C3} - t_{C2}\). We use \(\Delta T = 69.1\) s.
Self-check. Maximum duration on the central line inside our area is 138 s, and the central line crosses at 41.066 N, 1.529 W at 18:30:51 UT. NASA’s canon says 02m18s. The path drawn on the map and the heatmap under it come from the same elements, so they cannot disagree with each other — only with reality, and they don’t.
What we keep per pixel: the Sun’s altitude and azimuth at mid-totality, its altitude at C2 and at C3, and the duration.
These fields are astronomically smooth — the Sun’s altitude changes by about 0.007° per km — so they are computed on a stride-64 subgrid and bilinearly upsampled. That is not an approximation you can see.
For every pixel we need one number: how high does the terrain rise, in the direction the Sun will be?
Take the Copernicus GLO-30 DEM (30 m). From each pixel, march outward along the Sun’s azimuth, sampling the DEM at increasing distance \(s\), and take the maximum apparent elevation angle, correcting for Earth curvature and standard refraction:
\[\theta_{\text{hor}} \;=\; \max_{150\,\text{m} \le s \le 30\,\text{km}} \arctan\!\left(\frac{z(s) - z_0 - h_{\text{eye}} - \dfrac{s^2}{2R_{\text{eff}}}}{s}\right)\]
with \(h_{\text{eye}} = 1.7\) m and \(R_{\text{eff}} = \tfrac{7}{6}R_\oplus\) (the standard refraction fudge). The \(s^2/2R_{\text{eff}}\) term is the drop of the Earth’s surface: at 30 km it is 60 m, which at 30 km subtends 0.11° — a fifth of the solar disc. It is not optional.
Three things about this that cost an iteration each:
NaN, not zero.We also keep the distance to the ridge that set the maximum. A 3° horizon 800 m away is a completely different proposition from a 3° horizon 25 km away: you can walk around the first one.
The Sun’s disc is 0.53° across. For the whole disc (and the inner corona with it) to be above the skyline, the disc’s centre must clear the horizon by half of that:
\[\theta_{\text{req}} = \theta_{\text{hor}} + \tau_{\text{clear}}, \qquad \tau_{\text{clear}}^{\;\text{default}} = 0.27°\]
But the Sun is setting during totality. It is higher at C2 than at C3, by
\[\Delta_{\text{drop}} = \alpha_{C2} - \alpha_{C3} \quad (\text{up to } 0.31° \text{ here})\]
which is comparable to the threshold itself — so “visible” is not a yes/no at these altitudes, it is a fraction of the event. Approximating the altitude as falling linearly from C2 to C3, the fraction of totality during which the whole disc is clear is
\[f_{\text{vis}} \;=\; \operatorname{clamp}_{[0,1]} \left(\frac{\alpha_{C2} - \theta_{\text{req}}}{\Delta_{\text{drop}}}\right), \qquad \alpha_{C2} = \alpha_{\text{mid}} + \tfrac{1}{2}\Delta_{\text{drop}}\]
\(f_{\text{vis}} = 1\) means you see all of totality. \(f_{\text{vis}} = 0.4\) means the Sun sets behind the ridge 40 % of the way through — you get the diamond ring and then nothing. \(f_{\text{vis}} = 0\) means the ridge wins.
This is the hard gate. Pixels with \(f_{\text{vis}} \approx 0\) are painted flat dark red and excluded from the colour ramp entirely. They are not scored zero — scoring them zero put ~30 % of the map at exactly the bottom of the ramp, where they consumed a third of the available colour saying nothing. The ramp’s whole dynamic range is spent discriminating between places you could actually go.
Passing the gate is necessary, not sufficient. Three quality factors:
\[\boxed{\;\text{EVI} \;=\; f_{\text{vis}} \;\cdot\; \underbrace{\sqrt{\frac{\Delta t}{120\,\text{s}}}}_{\text{duration}} \;\cdot\; \underbrace{e^{-\tau X(\alpha)}}_{\text{extinction}} \;\cdot\; \underbrace{\operatorname{clamp}_{[0,1]}\!\left(\frac{\alpha - \theta_{\text{hor}}}{\sigma}\right)}_{Q_{\text{margin}}}\;}\]
Duration, \(\sqrt{\Delta t / 120\,\text{s}}\) — square root, not linear: the difference between 40 s and 80 s of totality is enormous; between 95 s and 100 s it is nothing. 120 s is a reference, not a cap.
Extinction, \(e^{-\tau X}\) — you are looking through a lot of atmosphere. The airmass at altitude \(\alpha\) (Kasten–Young):
\[X(\alpha) = \frac{1}{\sin\alpha + 0.50572\,(\alpha + 6.07995°)^{-1.6364}}\]
At 3° that is \(X \approx 12\); at 7°, \(X \approx 8\). With \(\tau = 0.20\) (a clear inland summer day) the corona is dimmed by \(e^{-2.4} = 0.09\) versus \(e^{-1.6} = 0.20\) — a factor of two in brightness between the bottom and the top of our altitude range. This term is what makes the model prefer the west and the high ground, and setting \(\tau = 0\) is the way to see how much of the ranking it is driving.
Margin, \(Q_{\text{margin}}\) — the anti-knife-edge term. A site that clears the ridge by 0.05° passes the gate and is, in reality, a coin toss: a 30 m DEM does not know about the treeline, the barn, or the fact that refraction is unstable this close to the horizon. Quality ramps linearly from 0 at the ridge to 1 at \(\sigma = 1.5°\) above it. It penalises knife-edge sites without excluding them, which is the honest treatment of a model error you can bound but not fix.
| symbol | UI | default | it means |
|---|---|---|---|
| \(\tau_{\text{clear}}\) | Clearance required | 0.27° | half the solar disc. Raise it to buy headroom for trees, buildings and horizon haze the DEM cannot see. |
| \(\sigma\) | Margin saturates at | 1.5° | how far above the ridge before a site stops being a gamble. |
| \(\tau\) | Extinction τ | 0.20 | atmospheric opacity. 0.2 clear, 0.4 hazy, 0 = ignore altitude. |
None of these three is a measurement. They are judgement calls, which is exactly why they are sliders and not constants baked into a raster on a server. Move them and watch the map. If your conclusion survives the whole range, it is a real conclusion.
The Generalitat de Catalunya / IEEC map
(eclipsi2026.cat) is the one most people in Catalonia are
looking at. It is binary: a place either sees the
eclipse or it does not.
Overlay it (checkbox IEEC / Generalitat verdict) and the disagreements are systematic, not random: their map says “no” across broad areas we score as marginal-but-usable. Reverse- engineering the boundary, their map behaves as if it demands roughly 1.0° of clearance rather than the geometric 0.27°.
That is a defensible choice — it is buying exactly the margin that \(Q_{\text{margin}}\) models, but as a hard cut instead of a penalty. Drag Clearance required to ~1.0 and the two maps converge, which is a much stronger statement than either map alone: the difference between them is one number, and it is a matter of taste, not of physics.
The popup reports both verdicts for any pixel you click, and flags the disagreement.
Besselian elements Copernicus GLO-30 DEM
(NASA/IAU canon, ΔT=69.1s) (30 m, stitched over the AOI)
│ │
│ besselian.py │ horizon.py
│ contacts, altitude, │ ray-march to 30 km at
│ azimuth, duration │ az 283 / 285 / 287
│ │ + curvature + refraction
▼ ▼
alt_mid, alt_c2, alt_c3, horizon_deg, occluder_km
az_mid, duration_s (interpolated to each pixel's
(stride-64 + bilinear) own sun azimuth)
│ │
└──────────────┬───────────────┘
│ score.py · export_cogs.py
▼
┌──────────────────────────┐
│ evi.tif 5-band int16 │ ← the model's INPUTS, not its output
│ EPSG:3857, 50 m, COG │ ~190 MB, overviews, range-requested
└──────────────────────────┘
│
│ cog:// + HTTP range requests
▼
┌──────────────────────────┐
│ your browser │
│ setColorFunction() │ ← EVI evaluated PER PIXEL, PER FRAME
│ = the equation above │ which is why the knobs are live
└──────────────────────────┘
Nothing is computed on the server. The server is nginx
handing out byte ranges of a static file; every colour you see was
computed on your own machine, from the components, with the weights
currently on the sliders.
evi.tif is raw int16 — no GDAL
scale/offset tags, deliberately (see cogspec.py): the
library’s setColorFunction is handed the raw tile
while locationValues applies tags, so a tagged file would
colour the map from 529 while the popup said
5.29°. Instead there are no tags, and one JS decoder that
both paths call.
| band | field | ×scale | means |
|---|---|---|---|
| 1 | horizon_deg |
100 | terrain horizon angle at that pixel’s own sun azimuth |
| 2 | sun_alt_deg |
100 | Sun altitude at mid-totality |
| 3 | alt_drop_deg |
1000 | \(\Delta_{\text{drop}}\), the C2→C3 fall |
| 4 | duration_s |
10 | totality duration (0 = outside the path) |
| 5 | occluder_km |
100 | distance to the ridge that set the horizon |
Elevation is not in there: it is the DEM, which is already shipped for the hillshade.
The browser needs to know, at an arbitrary click,
when totality happens there and which
way the Sun will be — to aim the PeakFinder and Street View
links. Neither earns a band. Both are smooth to the point of dullness
across the area, so score.sun_fit() fits each as a
quadratic surface in \((\lambda,
\varphi)\) at export time and stats.json carries six
coefficients apiece:
\[ t_{\max}(\lambda,\varphi) \;=\; c_0 + c_1\,\delta\lambda + c_2\,\delta\varphi + c_3\,\delta\lambda^2 + c_4\,\delta\lambda\,\delta\varphi + c_5\,\delta\varphi^2 \]
Residual over the whole area: 0.004 s for the time, 0.0001° for the azimuth — i.e. exact, for six numbers. This is not a decoration. Mid-totality moves 5.5 minutes from one side of the area to the other, which is 1.4° of solar azimuth — nearly three solar diameters. Point PeakFinder with an area-wide constant and it draws the Sun in the wrong place, which is the one question you opened PeakFinder to ask.
The elevation contours are computed on your GPU, from the DEM tiles
already streaming for the hillshade, by a MapLibre
color-relief layer: a colour ramp that is transparent
everywhere except in a thin band of elevations around each
multiple of the interval. No isoline extraction, no vector tiles, no
extra bytes. The honest cost is the one every DEM-derived contour pays —
a line defined as a band of elevations is thin where the ground is steep
and fat where it is flat, and on a cliff it can thin to nothing. That is
why stepped bands are offered alongside lines:
terracing cannot disappear on you, and “is there a ridge between me and
the Sun” is a question terracing answers.
Stated plainly, because a map that looks this confident should tell you where it is lying: