A faithful, working recreation of the Weatherscan cable weather channel — the original 2000s IntelliStar frontend, driven by a modern backend that needs no weather API key.
The thing that makes this project worth running is not the backend in this repository. It is the frontend, and that is not ours.
Jessecar96 built the original simulator: the slide engine, the Frutiger and Interstate typography, the sprite icon sets, the gradients and wipes and curved dividers, the 4:3 scaling, the music and narration playback. The buffbears fork carried it further with additional slide packages and refinements. Between them they reverse-engineered the look and cadence of a channel that went off the air in 2022, down to details like the pressure-trend arrow and the way "Today" disappears from the forecast after 3pm.
Getting that right is the hard, unglamorous, deeply researched part. This fork
did none of it. Every pixel you see is theirs. webroot/ is substantially
their code, and the changes made to it here were kept deliberately surgical.
What this fork does is make it run again.
The upstream project depended on the weather.com API. That API stopped issuing
keys, which left the simulator unable to fetch data at all — a working frontend
with nothing behind it. A previous attempt in this repository wrote adapters for
OpenWeatherMap but never connected them: the compatibility bridge patched jQuery
inside a DOMContentLoaded handler, while the app fired its first requests
during script evaluation. Measured in a browser, the patch installed at t=330ms
and the last request had already left at t=321ms. Every call raced past it and
went to the dead host.
This fork replaces the data layer entirely.
A real backend. server/ is a zero-dependency Node HTTP server that serves
the frontend and answers its data calls. API keys stay server-side. Responses
are cached and shared across every viewer, so one fetch serves all screens and a
display left running for weeks stays inside every provider's free allowance.
No weather API key required. Open-Meteo is the default source — no signup, no card, no per-day quota. OpenWeatherMap and Home Assistant are supported as alternatives.
Home Assistant as a first-class source. If you already have a weather integration or a personal weather station feeding HA, the display reads your own observations. It routes by distance — your home from HA, everywhere else from a fallback — and converts from whatever units your instance declares. See HOME_ASSISTANT.md.
The dead slides came back. Nine weather.com endpoints had no replacement at all. Almanac, the whole Health package, both Airport slides, alert detail text, and the conditions ticker were blank or throwing. All of them work now, several from better sources than before:
| Slide | Now sourced from |
|---|---|
| Severe weather bulletins | National Weather Service — its event names are exactly the vocabulary the IntelliStar's warning-priority table was built from, and full bulletin text arrives inline |
| Airport conditions | Live METAR observations, not a gridded forecast interpolated to the runway |
| Almanac normals & records | 30 years of ERA5 reanalysis, computed per location |
| Moon phases | Computed locally (Meeus ch. 49) instead of a third-party API |
| Satellite | NASA GIBS GOES-East — RainViewer's free infrared feed returns zero frames |
| Nearby cities & ticker | A bundled 7,556-city GeoNames index; these are spatial and attribute queries no free name-geocoder can answer |
| Lower ticker | Your own RSS/Atom/JSON feeds (see below) |
Correctness fixes. Timestamps now carry the location's UTC offset — the
previous adapter accepted a timezone and returned toISOString(), shifting every
hourly column, sunrise and sunset by the viewer's offset. Day and night dayparts
are derived separately from hourly data instead of repeating the daytime
condition after dark. Today's daypart nulls after 3pm the way weather.com did, so
the frontend's index-shifting still works. Tornado maps to its own icon instead
of falling through to "N/A". Radar tiles use the path RainViewer returns rather
than a reconstructed timestamp URL, which serves degraded tiles.
Frontend bugs fixed in place. Three unbounded scan loops that ran past the
end of the forecast array and hung the browser tab. An off-by-one plus an
inverted failure guard in the nearby-cities scan. Null-row guards in six slide
parsers. San Francisco's longitude was missing its minus sign. Per-slide delay
overrides read slidedelay from a JSON blob that spells it slideDelay, so
every slide silently ran at its tab's delay and the feature had never worked.
The radar slides. These are the centrepiece of the loop and every one of them was visibly wrong in a different way.
The loop froze partway through. How many passes to play was worked out as
slideDelay * 11/60000 — a constant that assumes a pass takes about five and a
half seconds. A pass actually takes one frame interval per frame plus the pause
between passes: 1.8s for RainViewer's thirteen frames. So a ten-second slide
asked for two passes, animated for under four seconds and then sat frozen on the
newest frame for the remaining six; the sixty-second LOCAL RADAR tab asked for
eleven and froze for forty. It is measured from the actual frame count now,
which also means it adapts on its own as that count changes.
Every slide ended on a stripped-down map. Fading a map out was a hardcoded run
of setPaintProperty calls naming each border, highway, road sign and city
label, with the containers hidden 500 ms later. The list could only ever name
the layers the upstream style happened to have — the radar style's own
minor cities copy and coastline were already missing from it, and the
satellite branch never named its land background, water, hillshade, counties,
highways or labels at all. The base rasters underneath have no opacity for such
a list to set in the first place. Everything it missed stayed at full strength
after the fade finished, so the last fifth of a second of every radar and
satellite slide was bare terrain with a few stray labels and no weather on it.
The containers are faded as one surface now, which works on any style, has no
list to keep in sync, and stops flattening the style's own data-driven values
like the Highways zoom ramp.
Only one frame of the loop ever drew across the whole map. Stepping the loop
toggled each frame layer's visibility, and a source is kept alive only while
some layer using it is not hidden — both libraries decide that from
visibility and the zoom range, never from opacity. Hiding a frame therefore
dropped its source, and dropping a source evicts its tiles. Measured mid-loop,
the one visible frame held its two tiles and all twelve others held zero: every
frame was re-fetching from nothing during the 100 ms it was on screen, so
whichever tile arrived first drew and the rest of the map stayed empty. Only the
frame the loop rested on had time to finish, which is why the newest frame
looked complete and the others were missing their left and right edges. The loop
moves raster-opacity now. Both libraries return early from the raster draw at
opacity 0, so a frame that is not showing still costs nothing to render, but its
source stays used and its tiles stay resident — every frame is fully loaded
before the loop reaches it.
The first slide of a session showed the wrong map. The four map surfaces stack
in one container and have no display in the stylesheet, so until each map's
load handler fired they were all visible at once — and the satellite surface,
last in the DOM and a far wider view, painted over the doppler stack.
The mini radar never refreshed. The five-minute observation refresh hangs its
rebuild on minimap.on('load'), and load fires once per map. The first call
worked and every later one registered a handler that could not run, so the mini
radar looped whatever frames were fetched at startup for as long as the display
stayed up, accumulating a dead closure every five minutes. Behind that sat a
second bug the first one was hiding: the rebuild was sequenced on the map's next
idle, but the layers are added after an asynchronous fetch, and a map that has
been sitting on screen is already idle. It only held together at startup because
a freshly loaded map stays busy with tiles for a second or two.
The sim's own city label was missing. Four layers added from JavaScript asked
for a bare ["Frutiger Bold"] where the styles ask for
["Frutiger Bold", "Arial Unicode MS Regular"]. Mapbox 404s a lone font its
account does not have, but serves a comma-separated stack by skipping the
members it lacks — so the styles rendered and the locator dot and city name did
not.
The radar legends tell the truth now. The local Doppler slide claims "Past 3 Hours" and the regional satellite claims "Past 5 Hours"; they were showing two hours and one and a half. Satellite was only a matter of asking NASA GIBS for more of its archive — sixteen slots on a twenty-minute step rather than twelve on a ten-minute one, which spans five hours without making the loop twice as long. Radar is harder: RainViewer's free listing is capped at two hours with no parameter for more. Its tilecache keeps serving a frame long after it drops off that listing, though — a path captured three and a half hours earlier still returns real imagery at every zoom — so the backend keeps a rolling three-hour buffer of frames it has seen and serves the union. Frames older than the current listing are checked before being served, so an expiry shortens the loop instead of blanking frames.
The display stays up now. One failure used to take the whole screen down until someone reloaded the page. It took two defects and a missing guard.
A cache that could deadlock on itself. Reverse geocoding falls back to the
bundled city index when the web geocoder fails, and it used to reach that index
through nearby() — which opens by awaiting reverse() for the same point.
Every lookup goes through a TTL cache that hands a second caller the promise the
first is still producing, so the producer was handed its own promise and awaited
itself. That never settles, and the entry is only cleared in a finally that
therefore never runs: the key stayed poisoned for the life of the process, so
every later lookup for that point hung too, which hung /v3/location/point and
/v3/location/near, which left the frontend with no city list at all. A single
throttled response from a free key-free endpoint was enough to trigger it, and
rural points reach that endpoint on every lookup. The fallback reads the index
directly now — synchronously, with no cache to re-enter.
Nothing noticed. The cache now tracks which keys the current async context is
producing and refuses a producer that re-enters its own key, with the cycle in
the message. Losing one lookup is a bad minute; losing the key is a bad week.
That guard alone is enough — run the old geocoder against it and the cycle
throws where nearby() already catches, so the lookup simply succeeds.
And a slide that threw was the last slide ever shown. Nothing drives the
rotation but the setTimeout each slide schedules at its own end, so a throw
before that line leaves nothing pending. With the city list empty, the first
header substitution read through weatherInfo.*.weatherLocs[location] and died.
The loop now skips a slide it cannot draw and carries on, backing off from one
second to fifteen once several fail in a row, so a display running unattended
comes back on its own when a feed recovers. Slides also claim a generation
counter, so a timer left behind by the slide that failed cannot start a second
rotation running alongside the first.
config.js is the configuration now. weatherInfo was declared twice — once
in config.js as weatherInfoSettings, which nothing read, and once again in
newweathermanager.js, which is the copy that ran. The two had already drifted
in both directions. A pressureTrend was added to config.js to stop the
sidebar printing "pressure undefined", carrying a comment explaining exactly
that, and it never reached the object on screen. The Travel and International
city lists went the other way: they existed only in newweathermanager.js, so
editing them where you were told to edit them did nothing. config.js is the
single declaration now and the manager clones it, which is 93 lines shorter and
removes the whole class of bug. Unifying them immediately caught a third
instance: the dead copy spelled LaGuardia "LaGaurdia".
Two more frontend bugs, found by the new checks. The pollen slide set i = 0
above a forEach that never advanced it, so all four bars animated to the tree
reading and grass, weed and mold were wrong whenever they differed from it. Four
variables were assigned without being declared — i in the pollen slide, i in
resizeText, valueii in the daypart slides, and pages in the bulletin slide,
the only one of seven paginating slides that did not declare it — each of which
put a name on window for the life of the page.
Checks that reach the frontend. webroot/js is two thirds of this project
and nothing checked any of it, which is why every defect above was found on
screen rather than in a test. It has its own harness now: the frontend loads
into a VM against a stubbed jQuery, so all 33 slide handlers can be run
headlessly against a weatherInfo built from the declared shape — populated,
and blank the way it is before the first fetch lands and after one fails. A
throw there is precisely what freezes the loop, so it is the single most useful
thing to assert. The same pass diffs the globals before and after each slide,
which is how the four leaked names surfaced.
Alongside it, the wiring itself is checked, because it is all string keys spread
across config.js, slides-loop.js and index.html and every mismatch fails
silently on screen. Every configured slide must have a handler; every handler a
container and both header entries; every container and content pane must exist
in index.html; every *placeholder* must be substituted; every skip test must
compile and return a boolean. No slide property or dataset key may be read
under a name nothing writes — a key differing only in case is reported
separately, since that is never a coincidence. That last check is the
slidedelay bug's exact signature.
npm run check runs the same validator, so the file you are told to edit is
checked before you start rather than minutes later when the loop reaches the
broken slide, and it names the entry rather than just the symptom:
FAIL config.js 1 problem(s):
slideLoopSettings.order[1](health).slideOrder[0][0]: no slide named "healthIntroo"
Four more bugs, from reworking the boot sequence. The sidebar painted its
temperature and conditions icon once when Loops() was constructed and then not
again for five minutes, so any run where the observation data landed after that
point showed a blank temperature for the rest of the interval; boot now waits
for that data rather than for the location alone. The geocode branch of the
location lookup read data.location.adminDistrict[cidx] — a point response is
scalar throughout, and cidx is only ever assigned by the search branch, so a
configured geocode location looked up its ticker cities for no state at all.
Every map centres on maincitycoords, and nothing tied the old fixed timer to
the lookup having landed, so a slow or retrying lookup built all seven around
empty coordinates. And three more cidx assignments were undeclared, putting
the name on window.
The four branches of getMainLoc ended with the same four init calls written
out again, which is how the adminDistrict bug came to exist in exactly one of
them. They share one function now.
No CDN scripts at boot. jQuery, the marquee plugin, mapbox-gl and
maplibre-gl were pulled from googleapis, jsdelivr and unpkg on every page load,
so a display that could not reach those hosts did not start. They are vendored
under webroot/js/vendor/. What is still remote: Mapbox tiles, which is
unavoidable since that is the tile service, and one small font from
fonts.gstatic.com used for the pressure-trend arrows.
It runs smoothly now. The single largest cost in the whole app turned out to be the weather icons: both sprite sheets are animated PNGs, 4864x125 and thirty frames, looping forever. A CSS background-image animates for as long as anything paints it, and the sidebar icon is never off screen — so every page was costing the browser about 65 MB/s of continuous PNG decode before drawing a single pixel of its own. Splitting the sheets into one small file per icon took that to under 10 MB/s. Details and the rest of the numbers are below.
Security and packaging. Removed cors-anywhere, which was an
unauthenticated open forward proxy published to the host — and unnecessary,
since Open-Meteo and RainViewer both send CORS headers. Dropped every npm
dependency, clearing 25 advisories including 4 critical. Single port, non-root,
read-only container.
The upstream simulator was built to look right, and it does. Nobody profiled it, though, and a display meant to run unattended for weeks is exactly where that shows up — as slides that feel heavy and transitions that judder.
| Before | After | |
|---|---|---|
| Condition-icon decode, continuous | 65 MB/s | 1.8 MB/s per distinct icon on screen; ~9 MB/s on a busy slide |
| Icon bytes fetched at boot | 19.7 MB | 2.6 MB |
| Radar loop, style writes per tick | 26 | 4 |
| Vendor JS, repeat request | 34 ms of CPU, every time | 48 ms once, then 1.5 ms |
| Vendor JS caching | revalidated on every page load | cached for a week |
| Slide-header transition | jQuery tween of left — full relayout per frame |
compositor-only transform |
| WebGL contexts live when the display appears | 7 | 3 |
| Main-thread blocking after the reveal | 88 ms before it, 56 ms after | none |
| Reveal → first slide on screen | 1.0 s of empty display | 0 ms |
| Temperature-bar growth | jQuery tween of height — full relayout per frame, 4 bars at once |
compositor-only transform |
The icon sprite sheets are animated PNGs. images/icons2010sprite.png and
its 2007 counterpart are 4864x125 APNGs: thirty frames, 33 ms apart, looping
forever. Every icon on screen is one CSS background pointing at that one file,
so the browser decodes and composites a 2.32 MB frame thirty times a second for
as long as the page is open — and twenty-eight of the thirty frames redraw more
than 90% of the sheet, so there is no cheap dirty-rect path either.
scripts/split-icon-sprites.js cuts each sheet into 38 individual 128x125 files
(icons whose frames never change come out as ordinary static PNGs, which the
browser does not animate at all). Only the icons a slide actually shows are
animated: per-frame decode drops from 2.32 MB to 0.061 MB, and a page that used
to pull 19.7 MB of sprite at boot pulls about 2.6 MB. The splitter is
zero-dependency Node that honours the APNG dispose and blend ops, and its output
is pixel-identical to the sheet across all thirty frames — the artwork is
untouched.
The files are generated during docker build and are not checked in. For a
local run, npm run split-icons. The server detects whether they exist and the
frontend falls back to the sheets if they do not.
The radar loops did twenty-six style writes to change two layers. Both loops
walked the whole timestamp list — thirteen frames at the time — on every 100 ms
tick and set visibility on every frame of both maps. They now touch only the
frame going out and the frame coming in, and move raster-opacity rather than
visibility — see above for why that second part matters more
than the first. A related bug: each tick called clearInterval on
a shared global rather than on its own handle, so two overlapping loops would
orphan a 10 Hz timer driving two WebGL maps for the life of the page — which is
the sort of thing you only notice on day three.
The slide-name header animated a layout property. $scroller.animate({left})
made jQuery relayout the entire header strip on every frame of a 900 ms tween,
which is why that transition was the choppiest thing in the loop. It is now a
transform animation the compositor handles on its own, composited onto the
stylesheet's existing scale so it lands on exactly the same pixel as before.
The temperature bars relaid out the page sixty times a second. Every bar
that grows — the daypart slides, the lower bar's hourly tiles — was a jQuery
tween of height, four at a time for a second and a half. That is enough
main-thread work to visibly chop the lower ticker, which is a compositor-driven
CSS animation and should be immune to whatever else the page is doing. They now
get their final height up front and scale up from zero, which the compositor
handles on its own. The labels inside are opacity: 0 until the growth
finishes, so there is nothing visible to distort on the way up.
Seven map surfaces were built one second before the display appeared. The
whole boot hung off setTimeout(…, 4000) — "init 1 second before intro stops" —
so initBasemaps(), the slide engine, the sidebar loops and the ticker manager
all landed in the same moment the intro card lifts. initBasemaps() builds
seven mapbox-gl and maplibre-gl instances: seven WebGL contexts, seven style
parses and seven rounds of tile fetching, measured at 88 ms of main-thread
blocking immediately before the reveal. Only three of them are the sidebar
stack. The other four are the full-screen doppler trio and the satellite view,
which nothing looks at until the first radar slide roughly forty seconds into
the loop.
It is the same failure as the temperature bars above, one layer up: the ticker is a compositor-driven CSS animation that should be immune to what the page is doing, and a blocked main thread is the one thing it cannot survive. Boot is the worst case because everything arrives at once.
Three smaller costs were stacked into the same window. The intro spinner
rewrote transition: transform 1s linear and the transform every 100 ms, so a
one-second transition restarted ten times a second on a 3D-transformed element
for the whole intro. The city background PNGs (286–424 KB) sit on containers
that are display:none until their slide shows, so the browser did not request
them until the first transition — they arrived about a second and a half after
the reveal and decoded over the top of the slide that wanted them. And Slides()
waits two seconds after being constructed, so the display appeared with its
header and sidebar up and nothing in the middle of it for a full second.
Boot now runs on readiness rather than a stopwatch. initBasemaps() is split
into initSidebarBasemaps() and initSlideBasemaps(), both idempotent, and the
radar slides build the deferred four on demand if they ever get there first. The
warmup — three maps, the sidebar loops, the ticker manager, and an explicit
preload of the backgrounds the first slides paint — moves into the four idle
seconds behind the intro card, where nothing the viewer can see is moving.
Only the slide rotation waits for the curtain, and it now starts with it rather
than a second later. Measured after: one 57 ms task at t=154 ms, behind the
card, and nothing at all after the reveal.
The deferred surfaces need one more thing: requestIdleCallback on its own
fires within half a second of the reveal, because the browser genuinely is idle
then, which put all four right back into the window this exists to protect.
They wait a fixed settle first, and a hard timer runs alongside the idle
callback because a display parked in a background tab may never be given one.
Static assets are compressed once. Every request used to re-run gzip over
the file — 34 ms of CPU for mapbox-gl.js alone, on a container capped at a
single core, repeated for every viewer and every reload. Compressed responses
are now held in memory, keyed by path, size and mtime, so an edited file is
never served stale. The pinned vendor bundles under js/vendor/ and
js/jplayer/ are cached for a week rather than revalidated on every load;
js/config.js still revalidates every time, because it is a file you are told
to edit.
Two things worth checking on your own display. The radar and minimap
surfaces stack three WebGL maps each — two of them exist only to draw a drop
shadow, which is the upstream design. If the browser showing this runs without
GPU acceleration, common in a VM, that will dominate everything above; check
chrome://gpu. And weatherscan.css still pulls one small font from
fonts.gstatic.com for the pressure-trend arrows, which is the last thing the
frontend fetches off-origin at boot.
git clone https://github.com/negative-video/Weatherscan.git
cd Weatherscan
cp .env.example .envAdd a Mapbox token to .env — free, 50k loads/month, from
account.mapbox.com. Copy the "Default public
token" (pk.…):
MAPBOX_API_KEY=pk.your_token_hereThen:
docker compose up -dOpen http://localhost:8080. It geolocates from your IP; to pin a location, use
http://localhost:8080/?Philadelphia or ?90210.
Without Docker — Node 18+, and no npm install, because there is nothing to
install:
npm startVerify every data source before you start:
npm run check| Required? | Why | |
|---|---|---|
| Mapbox token | Yes | Map tiles for radar, satellite and mini-map. Free tier. |
| Forked map styles | Yes | Without them the maps draw terrain and water but no roads, borders or labels — see below. |
| Weather API key | No | Open-Meteo needs none. |
| Pollen key | Optional | The pollen slide self-skips without one. |
| RSS feed | Optional | The lower ticker falls back to static text. |
The Mapbox token needs only three scopes: STYLES:TILES, STYLES:READ,
FONTS:READ. Leave every secret scope unchecked — ticking one produces an sk.
token, which must never be served to a browser.
The original hardcoded its own Discord invite and a paragraph about Weatherscan into the scrolling line at the bottom. Those belong to that project, and they never change. Here the ticker reads real feeds:
MARQUEE_FEEDS=https://feeds.bbci.co.uk/news/rss.xml,http://rssbridge.lan/?action=display&bridge=...&format=Json
MARQUEE_MAX_ITEMS=12
MARQUEE_TTL_MINUTES=15
MARQUEE_SHOW_SOURCE=trueRSS 2.0, Atom and JSON Feed are all parsed, without a dependency. Headlines are
merged across feeds, de-duplicated, sorted newest-first, and stripped of markup
before they reach the page. A self-hosted RSS-Bridge
works well — its format=Json output is the least fragile to parse.
MARQUEE_SHOW_SOURCE prefixes each headline with its feed's name, and that
name is tidied first. Publishers rarely title a feed with just their masthead —
Ars Technica's is literally "Ars Technica - All Content" — so a trailing segment
is dropped when it is boilerplate ("All Content", "Top Stories", "RSS Feed" and
friends) and kept when it means something: "BBC News - World" survives intact.
A title too long to be a name at all, like the NWS alert feeds' "Current
watches, warnings, and advisories for Virginia", is dropped rather than repeated
in front of every item. Set MARQUEE_SHOW_SOURCE=false for no prefix at all.
Only feeds named in MARQUEE_FEEDS are ever fetched; there is deliberately no
?url= parameter, so this cannot be used to proxy arbitrary hosts.
Set MARQUEE_MESSAGES (pipe-separated) for static text instead, used when no
feed is configured or every feed is unreachable.
By URL — what you want for a wall display:
http://localhost:8080/?Philadelphia
http://localhost:8080/?New York, NY
http://localhost:8080/?90210
By config — edit locationSettings in webroot/js/config.js to pin the main
location, surrounding cities, ticker cities and airports.
Automatic — with no URL parameter it geolocates from your IP and fills the surrounding cities and ticker from the bundled index.
webroot/js/config.js controls the look, unchanged from upstream:
var apperanceSettings = {
iconSet: "2010", // "2007" or "2010"
affilateName: "Midco", // affiliate name shown on screen
logoURL: "", // 879x184px or similar
corebackgroud: "buildings", // forest, mountain, city, buildings,
// neighborhood, southwest, ocean
}slideLoopSettings controls the rotation; audioSettings the music and
narration. weatherInfoSettings declares the shape of the weather record the
display fills in, which is where the Travel and International city lists live.
Everything in here is wired to the rest of the frontend by string keys, and a
mismatch fails silently on screen rather than raising anything. npm run check
validates the file — slide names, containers, headers, placeholders and skip
tests — and names the exact entry when one does not resolve.
Set WEATHER_PROVIDER in .env.
open-meteo(default) — no key, no card, no quota. Hourly resolution, 8-day forecast, air quality.openweathermap— setOPENWEATHER_API_KEY. Note One Call 3.0 is a separate subscription requiring a card on file even for its free 1,000 calls/day; withOPENWEATHER_MODE=autothe server detects the rejection and falls back to the 2.5 endpoints, which work without one.home-assistant— see HOME_ASSISTANT.md.
The backend serves the frontend and a JSON API on one port.
| Endpoint | Purpose |
|---|---|
GET /api/status |
Provider health, cache stats, HA connection and entity list |
GET /api/healthz |
Liveness probe |
GET /api/weather?lat=&lon= |
Normalized weather bundle |
GET /api/radar/series |
Radar and satellite frames with tile templates — three hours of radar, five of satellite |
GET /api/almanac?lat=&lon= |
30-year normals and records |
GET /api/marquee |
Ticker headlines |
GET /api/moon |
Next four moon phases |
GET /api/cities?state= |
Largest cities in a state |
POST /api/cache/clear |
Drop cached upstream responses |
/api/wx/* mirrors the legacy weather.com paths the original frontend calls. It
exists so that newweathermanager.js and slides-loop.js did not have to be
rewritten. Build against /api/weather instead.
browser
│
├── webroot/ the original IntelliStar frontend (Jessecar96 / buffbears)
│ ├── js/weatherscan-api.js redirects legacy calls to the backend
│ └── js/vendor/ jquery, marquee, mapbox-gl, maplibre-gl
│
└── server/ zero-dependency Node HTTP server
├── providers/ open-meteo · openweathermap · home-assistant
├── services/ alerts · airports · almanac · radar · geocode · places · feeds
├── legacy.js rebuilds the weather.com response shapes
└── lib/ units · icons · dayparts · astro · cache
The translation to weather.com's response shapes lives on the server rather than
in the browser. That removes the race that broke the previous attempt, and makes
every shape inspectable with curl.
npm run dev # auto-restart, request logging
npm test # 120 tests, no dependencies
npm run check # probe every data source, and validate config.js
npm run split-icons # cut the animated icon sheets into per-icon filesThe suite covers both halves. test/legacy.test.js and test/api.test.js check
the response shapes the frontend parses and every path it opens at startup —
against a city the bundled index resolves locally and a rural point that has
to fall through to the web geocoder, since testing only the first is how a
deadlock in the second survived to production. Requests carry a time budget, so
an endpoint that hangs fails by name instead of stalling the run.
test/slides.test.js runs the frontend. test/helpers/frontend.js loads
webroot/js into a VM against a stubbed jQuery, which is enough to execute all
33 slide handlers and every structural check described
above — no browser, no fixtures to keep in step, and the
weatherInfo it runs against is built from the shape config.js declares, so
adding a field there reaches the tests without anyone remembering to.
It also pins the boot order, which is load-bearing for how the display feels and easy to undo by accident: that the map surfaces stay split three and four, that both families are idempotent and the radar slides can build theirs on demand, that the settle before the deferred four is long enough to be worth having, and that boot waits on data rather than on the location. None of that is visible to a test that only checks output.
docker build runs split-icons for you. Run it by hand for a local npm start, or the frontend falls back to the 20 MB animated sprite sheets and the
browser pays for it — see Performance.
Tests hit live upstream APIs. SKIP_NETWORK_TESTS=1 runs only the offline logic
tests.
-
The default Mapbox styles will not render fully. They point at the upstream author's styles, which are public to read but pull their vector data from that account's private tilesets. Mapbox fails a composite source if any member is inaccessible, so with a third-party token you get roads, city labels and county lines missing entirely. Fix it once with:
MAPBOX_WRITE_TOKEN=sk.your_token npm run fork-styles -- --create
That copies the four styles into your account, substitutes the private tilesets for their public Mapbox Streets equivalents, and prints the
MAPBOX_STYLE_*lines to paste into.env. The write token is separate fromMAPBOX_API_KEYand must never go in.env— see scripts/fork-mapbox-styles.js. -
Forked styles fall back to Arial until you upload the fonts. The styles ask for
Frutiger BoldandInterstate Regular, which live in the original author's Mapbox account, not yours. Every label still renders, because each font is paired withArial Unicode MS Regularand Mapbox serves a comma-separated stack by skipping members it lacks — it just renders in the wrong typeface, silently. Uploadwebroot/fonts/FrutigerBold.ttfthrough the Fonts item inside the Studio style editor (it is not a top-level page) and every style picks it up with no code change. Mapbox names an upload from the font's internal name table rather than its filename, so of the several Frutiger files in that folder onlyFrutigerBold.ttfresolves to the name the styles ask for. There is no Interstate Regular in an uploadable.ttf/.otfin this repo — only.woff, which Mapbox does not accept. -
Three hours of radar takes an hour to build up. RainViewer's free listing covers two hours, and its frame paths are opaque hashes rather than anything derivable from a timestamp, so the backend's rolling buffer can only hold frames it has actually observed. A cold start serves the two hours RainViewer lists and reaches the full window after about an hour of polling. Frames outliving the listing is observed behaviour, not a documented guarantee, which is why they are checked before being served.
-
Pollen needs a key in the US. The free CAMS pollen data is Europe-only.
-
The aches and breathing indices are derived. They were proprietary Weather Channel products with no free equivalent, so they are computed from the meteorology that drives them and marked
derived: truein the API. -
Almanac records are reanalysis, not station data. ERA5 is a ~30 km grid; averages are good, but record extremes are smoothed relative to an official station record, noticeably so on coastlines and in mountains.
-
Nearby cities and the ticker are US-only at full quality.
-
The frontend checks stub the DOM rather than emulating one. That is enough to run every slide handler and catch what actually freezes the loop — a read landing on undefined — and it keeps the suite dependency-free. It does mean nothing there covers layout, styling, or anything that depends on a real measurement. The map surfaces are not covered at all.
-
otherairportslists LAX twice. One of the sixteen ticker slots is therefore wasted. It is that way in the upstream code; which airport should replace it is a matter of taste, so it has been left alone.
- Jessecar96/Weatherscan — the original simulator. The frontend, the design work, and the research behind it.
- buffbears/Weatherscan — the fork this one is based on.
- Map styles by goldbblazez.
- City data from GeoNames (CC BY 4.0).
- Weather from Open-Meteo, NWS, RainViewer, NASA GIBS and aviationweather.gov.
Weatherscan and The Weather Channel are trademarks of their respective owners. This is a non-commercial recreation for personal and educational use, not affiliated with or endorsed by them.