Every number on the map comes from one live AIS feed and a chain of five steps. This page explains each of them, and is wired to the running configuration — the constants below are read from the server, so they can't drift away from the model actually in use.
A single WebSocket feed of live AIS, filtered to a bounding box over the strait and its approaches. Ships broadcast two kinds of message we care about:
| message | carries | how often |
|---|---|---|
PositionReport | position, speed over ground, course over ground, navigation status | every few seconds to 3 minutes, depending on speed |
ShipStaticData | name, dimensions, draught, ship type, and a hand-typed destination | about every 6 minutes |
A ship is only usable once both have arrived — which is why a freshly appeared vessel can sit on the map for a few minutes before it earns a forecast.
The feed's own connection limits, and how this client respects them, are in section 9.
Kept: anything or longer. If a ship hasn't broadcast its dimensions yet, its AIS type stands in — tanker, cargo and passenger ranges are kept, everything else waits. Dropped: ferries, tugs, fishing boats, and anything making less than , which covers vessels at anchor and alongside. A typical moment in the strait has 60–90 vessels of which 5–10 survive this filter.
Dimensions arrive in ShipStaticData, roughly every six minutes, so a ship
that has just appeared may have a position but no length — and therefore no wake
estimate. There is no free vessel-particulars API worth wiring to; the lookup
services are all paid or credit-metered. The cheaper answer is simply never to
throw the data away. data/registry.json keeps every set of particulars the
feed has ever broadcast, keyed by MMSI, and is never pruned even though the live
picture is. The same ships work this strait week after week, so after a few days
of listening a returning vessel is fully described the moment its first position
arrives. Rows filled this way are marked fromRegistry.
Ships don't wander — they follow the traffic separation scheme. Inbound traffic keeps to the south side and outbound to the north, which is the single most useful fact in the whole model: it's why the US-side spots in the strait see inbound ships close aboard and outbound ones much further out. Past the fork the channels narrow to two or three miles, and there both directions pass within about a mile of the beach.
Each ship is projected onto the centreline for its direction. That gives two numbers: how far along the route it sits, and how far off the centreline it is. More than 8 nm off and it isn't following that lane at all, so it's ignored.
Inbound or outbound is read the same way: the ship's course is compared with the direction of the lane at the point it snaps to. Within 60° of the lane is inbound, within 60° of the reverse is outbound, and anything in between is crossing traffic — a ferry, a pilot boat, a ship turning. This matters past the fork, where Admiralty Inlet runs south-south-east and Haro Strait runs due north; a fixed east-or-west compass rule would drop every ship in the narrows.
A spot is only useful if a ship passes close enough to paddle out to — about a
mile. node index.js discover finds those places from the data rather
than a chart: it takes the US shoreline from OpenStreetMap, joins each large
ship's logged fixes into a track, and for every point on the shoreline counts
the ships that passed within a mile. The best stretches are spaced at least
3 nm apart and listed with their ships per day, median passing distance and the
direction the beach faces. The Admiralty Inlet, Haro Strait and Rosario Strait
spots came out of this. In the main strait nothing qualifies — the lanes run
2–4 nm offshore — so those spots are kept for their long warning, not their
distance.
The same pass is a check on the lanes: it reports how far the modelled lane runs from each candidate. Where ships demonstrably pass a beach but the lane doesn't, the forecaster would mis-time them, so the lane gets fixed before the spot is added.
At the east end, near Hein Bank, inbound traffic splits three ways: Admiralty Inlet for Puget Sound, Rosario Strait, or Haro Strait for Vancouver. This is the one genuinely uncertain thing in the forecast, and it matters enormously — the same ship passes Point Wilson at 0.8 nm if it takes Admiralty, and misses it by 12 nm if it takes Haro.
The fork is resolved by a strict priority ladder. Position beats paperwork:
The branch_basis column in the CSV tells you which rule fired for every row.
Eta field, and this
model ignores it entirely — that's an ETA to a destination port, not to your beach,
and it is frequently stale.A spot west of the fork is passed whichever branch a ship eventually takes, so
those predictions merge into one row labelled branch: any and the
probabilities add back up to roughly 1. A spot east of the fork carries the full
branch risk.
With a route chosen, the forecaster walks it forward in 0.2 nm steps looking for the point where it comes nearest the spot — the closest point of approach. That gives both the distance the ship will pass at, and the run distance to get there. Speed does the rest:
Speed is clamped to a sane 5–25 kn, because a garbled report of 0.1 or 40 knots shouldn't produce an ETA next week or in four minutes.
These are deliberately kept apart, because they answer different questions.
| term | what it accounts for |
|---|---|
branch_prob | the fork, from the ladder above |
nav_status | under engine, less for anything else |
decay | the chance per hour that a transit changes materially — anchoring off Port Angeles for a pilot or bunkers, slowing for traffic, diverting |
staleness | trust decays once the last fix is over 15 minutes old |
The broadcast speed is instantaneous speed over ground, which is not the same as progress along the route — a ship yawing, cutting a corner, or being set by the tide closes on a spot at a different rate than its speedometer suggests. So each ship's own recent track is used to measure speed made good along the lane: how far along the route it has actually travelled, divided by how long that took.
When the measurement and the broadcast figure agree, the measured one is used.
When they disagree sharply, one of them is wrong and there is no way to tell
which, so the row keeps the broadcast speed and widens its window instead — the
speed_basis column says which of the three happened.
The leg-by-leg speeds also give the scatter, which replaces a guessed uncertainty with a measured one: a ship that has held a steady speed for twenty minutes earns a tighter window than one that has been surging, rather than both getting the same assumed .
Separate from the above, and shown as the 80% interval on the map:
This is the part that changed most, and it splits cleanly into physics that needs no calibration and one heuristic that does.
A ship's wake is bounded by the Kelvin wedge — a wake pattern sits inside a half-angle of 19.47° either side of the track, and that angle is fixed, independent of the ship's speed or size. So the wake reaches a beach that is d off the lane well after the ship has gone past:
This matters more than anything else on this page. At New Dungeness, 2.1 nm off the inbound lane, a 13-knot ship's wake lands about 27 minutes after the ship is abeam. Off Freshwater Bay at 3.9 nm it is closer to 50 minutes. Timing a walk down to the water off the ship's own ETA gets you there far too early — so every time shown on the map and in the board is the wake arrival, with the ship's abeam time given alongside it.
Transverse waves in a Kelvin pattern travel at the ship's own speed, which pins their wavelength and period to speed alone — nothing about the hull enters:
So a ship at 13 knots throws a 4.3-second wave and one at 18 knots a 5.9-second wave. Longer-period waves shoal and break better, and lose less energy on the way in, which is why speed matters out of proportion to its effect on the ETA.
Displacement is estimated from the broadcast hull dimensions:
The block coefficient Cb comes from the ship type, with a wrinkle: AIS
lumps container ships and bulk carriers into one cargo code, so the length/beam
ratio breaks the tie — slender hulls above 7:1 are treated as container ships at
0.65, beamier ones as bulkers at 0.82. If draught hasn't been broadcast, L/19
stands in and the row is flagged draught_estimated.
The 0–100 score itself is a ranking aid, and it is worth saying plainly why it doesn't simply follow the textbook. The published deep-water forms predict wave height relative to hull length, which makes a small fast ferry outrank a loaded 366-metre container ship. That is correct for shoreline erosion — it is why fast ferries get speed-restricted — but wrong for someone waiting on a beach, who cares about the energy in the whole train and how long the waves are. So the score leans on displacement, softens the Froude term, and rewards period:
The distance term is the d^(-1/3) decay of the divergent waves. Treat the
number as an ordering, not a wave height.
Knowing a wake is due at 06:40 is only half the answer; whether it is worth walking down for depends on what the water and sky are doing at that hour. Each passage therefore carries a weather lookup — taken at the wake's arrival hour, not the ship's, which can be the better part of an hour earlier.
| what | why it matters |
|---|---|
| wind speed, direction and gusts | strength and, more importantly, which way relative to the beach |
| offshore / cross / onshore | offshore wind blows from the land over the incoming wave and grooms its face; onshore chops it up. Each spot carries a facingDeg — the direction it looks out toward — and the wind is classified against it |
| ambient wave height | the sea the wake has to stand out from. A 1.5 m sea at Neah Bay will swallow a wake that would be obvious in the 0.2 m water off Freshwater Bay. The weather service forecasts height for inland waters but not period |
| cloud, precipitation, daylight, temperature | whether you can see it, and what it costs you to stand there |
On the map these are drawn rather than written out. The wind arrow points the way the wind is blowing — downwind — which is the opposite of the meteorological convention of naming the direction it comes from, because downwind is what you can read at a glance against the shape of the coast. The shaft thickens with strength, and the whole group takes the offshore/cross/onshore colour. The sea glyph gains a line as the ambient sea builds: one line below 0.3 m, two below 1 m, three above. The sky glyph follows cloud cover, switches to a moon outside daylight hours, and becomes a rain cloud when precipitation is forecast. Hovering any of them gives the numbers, and the map legend spells the whole set out.
In the passage list the icons stack in the left column beneath the arrival time, which the row already reserves — wind on one line, sea and sky sharing the next. That fills space the layout was wasting instead of adding height, so the conditions cost nothing. The spot popup is the expanded view, where the same readings get their words and full numbers.
Forecasts come from the US National Weather Service (api.weather.gov),
which is free, needs no key, and is public-domain data that may be used
commercially. It covers the US only, which is why every spot is on the US side.
Each spot is looked up once to find its 2.5 km forecast grid cell, and that is
remembered in data/nws-points.json; after that a refresh is one call per
grid cell, cached for . The grid comes in runs
of varying length — three hours of one wind speed, then two of another — which
are laid onto an even hourly axis, with rainfall totals spread across their run.
Daylight is worked out from the sun's position rather than fetched. If a lookup
fails, rows simply carry no weather rather than the forecast failing.
No database — flat files:
| file | shape | purpose |
|---|---|---|
data/state.json | snapshot, overwritten every 30s | the current picture; lets the forecaster run as a separate process |
data/tracks.jsonl | append-only, one line per vessel per minute | history — what the lane refit reads, and what any future climatology would use |
data/registry.json | MMSI → particulars, never pruned | remembers hull dimensions so returning ships are described immediately |
data/nws-points.json | spot → weather grid cell | the one lookup per spot the weather service needs, kept across restarts |
data/coastline.json | OpenStreetMap shoreline | fetched once by discover to score beaches against ship tracks |
data/users.json | email → access status | who has asked for access and who's been let in, when sign-in is on |
The feed is rate-limited and carries no SLA, so the client is built to stay inside its published limits rather than discover them:
| limit | what this app does |
|---|---|
| a small number of concurrent connections per account and per IP | data/feed.lock holds the pid of whichever command owns the feed; a second listen, serve, run or check refuses to start rather than quietly opening a second connection |
| subscription must arrive within 3 s of connecting | sent in the socket's open handler, before anything else |
| at most one subscription update per second | the subscription is sent once per connection and never updated |
| messages are dropped if you don't read fast enough | frames are parsed synchronously; a full forecast pass costs about 0.5 ms, so the event loop is never blocked long enough to build a backlog |
| reconnect with exponential backoff and jitter | backoff doubles to a 60 s ceiling with ±30% jitter, and resets only once data actually flows again — not merely on a connection that opens and then sits silent |
| don't connect from the browser | the key stays on the server; the map is fed over server-sent events and never sees it |
There is no durable replay and no uptime guarantee, so gaps are expected and every reconnection re-sends a complete subscription.
Forecasts are written to out/passages.csv, out/passages.json and
out/board.txt every .
node index.js lanes refits them from the median track of real large
ships in your own logged history; the map header shows
lanes: charted or lanes: learned so you
always know which is in use.tracks.jsonl is the single highest-value improvement available.