Blue Ridge Skyline (Mountain Stargazing Night Planner)
Role: Solo, end-to-end: astronomy math, terrain and lidar pipeline, data validation, front end, docs.
Tech Stack: Vanilla JS in one index.html, Leaflet 1.9.4, Python build tools (rasterio, numpy, laspy), Node build and check tools
Live: spskelly.github.io/dark-sky
Repo: GitHub
Overview
Blue Ridge Skyline is a one-page planner for stargazing nights in the western North Carolina mountains. It answers the three questions I have before a night out: when is the moon out of the way, will the sky be clear, and where do I drive.
I wanted to look at the stars, and I knew there were plenty of good places within a reasonable drive, but I didnât have time to check every one in person. So I built a preview: where the sun, the moon and the Milky Way would be from a given spot on a given night. Around here that isnât enough, because very few spots have a flat horizon, and the Milky Way core sits low in the south all summer, right where a ridge or a tree line blocks it. Then I realized TALON, my land analysis project for the same mountains, already had the 1 m elevation data, the 2017 lidar, and hard-won lessons about fetching both. I ported that over, and the preview started drawing the real ridge line, the trees, and any tower within 200 m of where youâd stand. Iâve since taken it out on several nights to check that the view looks the way the page drew it and the moon comes up where it said. (Some of this will probably make its way back into TALON.)
The page is a single index.html with no build step, framework, backend or account. Everything expensive is computed ahead of time by Python and Node tools and written into generated blocks inside that file, so the calendar, spots, horizons and sky renderer all work offline. Itâs about 7.4 MB raw, mostly embedded terrain and canopy data (671 KB gzipped, measured 2026-09-20), backed by about 9,400 lines of tooling and 231 unit tests (84 node --test, 147 Python unittest) plus a browser check that drives the real page in Chrome.
Engineering Details
1. One File, Everything Precomputed
A planner for places with no cell signal shouldnât depend on five APIs being up when you open it.
- Each generator owns a marked block in
index.html(PARKWAY,OVERLOOKS,HORIZONS,SKYand their overlook versions) and rewrites only what sits between its markers. Every one has a--dry-run, and the ones that hit a network can replay saved responses instead of asking somebody elseâs server again. - The parkway line is fetched once from OpenStreetMap, simplified to about 120 m, and embedded, so it âcannot break because somebody elseâs API moved.â
- Anything off the critical path fails visibly: the light pollution layer switches itself off and says so, and a blocked map falls back to the spot list with a note.
- The link preview shows tonightâs actual moon. A daily GitHub Actions run renders it in headless Chromium with the pageâs own moon functions, so the card canât disagree with the calendar.
2. Drawing the Real Skyline
The sky viewer is the reason the project exists, and itâs only worth anything if the ridge it draws is the one youâll be standing under.
- Terrain comes from USGS 3DEP: 1/3-arc-second data inside 5 km and a 1-arc-second far field out to 100 km, with Earth curvature and refraction folded in. Rays start 50 m out from a 1.8 m standing eye.
- The first version was bare earth only. On 2026-09-17 I tested whether trees mattered at two viewpoints with a 1 m canopy model. Craggy Pinnacle didnât change, because it stands above its trees. Mount Pisgah campgroundâs south horizon, drawn as open sky, was actually blocked up to 78.1 degrees by a broadcast tower 16 to 23 m away. The page had been confidently wrong about the one direction that matters most for the Milky Way core.
- The same test showed the whole effect sat within 150 m of the viewpoint, which cut a proposed 1.5 to 2 km canopy radius to a few hundred metres, about twenty times less data. The shipped layer draws trees and structures within 200 m from the 2017 NC Phase 5 lidar.
- The lidar side came over from TALON: the same public USGS EPT mirror of the 2017 acquisition, and TALONâs measured lesson that eight interleaved writers ran a USB disk at 26 MB/s against 88 MB/s sequential, which is why the canopy fetcher uses a single writer with write-then-rename checkpoints. The tools themselves are new, since TALON works a county at a time and this needs a few hundred metres around each spot.
- On 2026-09-18 I compared one Parkway overlookâs skyline with Street View. It matched except for straight lines slicing through the trees, caused by how canopy gaps were drawn. That evening the trees became solid with each gap cut out as a hole, with tests for a gap that wraps through north.
The limits are on the page, not hidden. The 2017 lidar was flown leaf-off and is nine growing seasons old, so every tree line is labelled as a floor. Nine sites have no lidar coverage and say âtrees not modelled.â At cliff-edge viewpoints, near ridges can read up to about 2.7 degrees high; the fix is known and not done yet.
3. Checking the Astronomy Against the Book
Moon phases come from Jean Meeusâs Astronomical Algorithms, and tools/test-astro.mjs checks them against his published worked examples in 11 tests. Sidereal time lands within 9.1e-8 degrees, and the Sunâs position within 2.7e-3 degrees of the full VSOP87 answer. The Moonâs distance is off by 46 km (0.013%) from truncating the series, a known approximation written down as one. The lit side of the Moon is checked against the Sunâs direction to within 3 degrees, because a flipped sign there is invisible until someone notices the moon lit from the wrong side. And since the site is about the Eastern evening wherever the viewer is, one test reruns everything under TZ=Asia/Tokyo and expects the same answers.
4. Pins That Have to Be Right
A beautiful skyline drawn from the wrong coordinate is worse than none, because it looks completely plausible.
check-spots.mjssnaps roadside pins onto the parkway centerline, but leaves a summit up a side road alone, because its milepost is the turnoff, not the spot. It also runs the milepost check in the snippet below.check_alignment.pyconfirms every horizon was raycast from the coordinate the page uses now. All 38 spots check in under a second.- OpenStreetMap maps the same pull-off twice more often than youâd think. The overlook dedupe took three versions; the first compared names exactly and merged nothing, missing pairs like âView Hominy Valleyâ and âHominy Valley (MP 404.2)â. 116 overlooks ship.
5. Reading Somebody Elseâs Map
The light pollution overlay is D. Lorenzâs world atlas, a static tile set he republishes under a new folder every few years, which breaks every old tile link.
find-lp-tiles.mjsfinds where the tiles live now, and believes nothing until it gets real images at three zooms over two places far apart.- To put a sky reading on each spot card,
build-skyglow.mjssamples the atlas at every spot and in a ring out to 50 km, so a note can say which way the sky is worst. The PNG decoder uses nothing but Nodeâs own zlib, tested against every color type and bit depth. - Each tile ships its own palette order, so everything keys on the color itself, pinned by a transect from Mount Mitchell into Asheville.
--fixrefuses to write if a color canât be placed, because a wrong band on a card is worse than no card.
The license for the rendered tiles is an open question, and ATTRIBUTION.md says so.
Build Log
215 commits over 11 active days, from git log:
- 2026-09-12 (67 commits): the first working version: moon calendar, spots, the parkway line, overlooks, and the light pollution overlay.
- 2026-09-16: the horizon raycast, astronomy module, star catalogue and panorama renderer, the day it stopped being a flat-horizon planner.
- 2026-09-17 (77 commits, the busiest day): terrain tiles, the sky viewer, every overlook raycast, and the canopy test, through to a canopy block in the page.
- 2026-09-18 (49 commits): canopy in the cards and sky viewer, and the Street View fix.
- 2026-09-19 to 09-21: the rebuilt viewer and the name Blue Ridge Skyline, the full-day scrubber, offline map layers, mobile navigation and the installable app.
Code Snippet: The Milepost Check
Condensed from tools/check-spots.mjs
// two spots ON ONE ROAD cannot be farther apart in a straight line than the
// difference in their mileposts. where they are, a coordinate is wrong.
//
// only points actually on the road can be compared this way: a summit up a
// side road is legitimately farther from its neighbour than its milepost
// suggests, because its milepost is the turning, not the spot.
const ON_ROAD = 250;
function consistency(list, label) {
const pk = list.filter(s => s.mp && nearestOnParkway(s).d < ON_ROAD)
.sort((a, b) => a.mp - b.mp);
const bad = [];
for (let i = 0; i < pk.length - 1; i++) {
const a = pk[i], b = pk[i + 1];
const dmp = b.mp - a.mp;
if (!dmp) continue;
const ratio = dist(a, b) / (dmp * 1609.34);
if (ratio > 1.02) bad.push(`${a.name} / ${b.name}: ${(dist(a, b) / 1000).toFixed(2)} km apart but ${dmp.toFixed(1)} milepost miles`);
}
return bad.length;
}
CLI Interface
python -m http.server 8000 # the site is one file
node tools/check-tabs.mjs # browser checks against the real page
python tools/build_horizons.py --dry-run # terrain horizons into index.html
python tools/build_canopy.py --dry-run # lidar trees and structures
node tools/build-overlooks.mjs --replay # re-filter the last OSM response, no network
node tools/build-skyglow.mjs --fix # sample light pollution at every spot
python tools/check_alignment.py --quiet # does each skyline still belong to its pin?
Features
- Moon phase calendar - true phase times from Meeus, drawn at 9pm when youâd actually be looking up.
- Dark-sky windows - a 3, 5 or 7 day band around each new moon for the next year, with weekend nights called out.
- Hourly sky forecast - cloud cover, temperature, wind and dew point from sunset to sunrise via Open-Meteo.
- 38 curated spots, 116 parkway overlooks - from the Cherohala Skyway to Doughton Park, ranked by estimated drive time from your home point.
- The sky from anywhere - a sky viewer that draws the ridge, trees, structures, moon, Milky Way and 1,046 stars from any spot on a chosen night.
- Measured sky line on every card - an atlas reading and the direction the sky is worst, kept apart from my hand-written note.
- Installable offline app - a hand-rolled service worker precaches the page (1.0 MB gzipped, measured 2026-09-21), keeps the last forecasts, and offers an optional 6.2 MB map bundle for nights with no signal.
Tech Stack
- Front end: vanilla JS, HTML and CSS in one
index.html, so it opens fromfile://with no signal. - Map: Leaflet 1.9.4, with USGS and OpenTopoMap tiles and D. Lorenzâs light pollution atlas.
- Data sources: USGS 3DEP elevation, the 2017 NC Phase 5 lidar via USGS EPT (sourced the way TALON sources it), OpenStreetMap, Open-Meteo.
- Build tools: Python (
rasterio,numpy,laspy) for raycasting and canopy; plain Node scripts for everything else, nonpm installneeded. - Testing:
node --test(84) and Pythonunittest(147), plus a browser check with every outside host stubbed. - CI: one GitHub Actions workflow that renders the moon card and deploys to GitHub Pages. Tests run locally.