Active Oct 2026

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, SKY and 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.mjs snaps 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.py confirms 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.mjs finds 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.mjs samples 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. --fix refuses 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 from file:// 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, no npm install needed.
  • Testing: node --test (84) and Python unittest (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.