The inner pages are plain HTML written by tools/chart-pages.py and styled by assets/pages.css. This file is their only script. It has no dependencies, and it is written in ES5 inside one IIFE so that nothing leaks onto window. The homepage does not load it: index.html has its own script, assets/home.js, which mirrors several of the behaviors here (menu, reveals, floating bar, weather).
What the pages actually load is assets/pages.min.js, the same code with the comments and the spacing taken out by tools/minify.py. This file is the one to read and the one to edit; the minified copy is built from it and committed beside it.
The split, the map dialog and the chart / log linking are progressive enhancements: without script a chart shows no divider, and each anchorage's map control is a plain link to Apple Maps (Google Maps off Apple devices, once script runs). The entrances are not. .reveal blocks, pins and routes start hidden in pages.css and wait for this file to mark them as entered, so with script off they stay hidden.
Each block below looks its elements up first and does nothing when they are missing, so the same file serves all three pages (and the style guide) whatever subset of components a page has.
The contract with the markup is a small set of hooks, all emitted by tools/chart-pages.py:
.chart[data-split] a chart that carries a satellite plate; the value is where the divider rests
.split-knob, .split-line the divider's two draggable parts
[data-pin], [data-seg] a numbered pin and a route segment inside a chart, keyed by leg or entry
[data-for][data-key] a log entry or key row: names the chart id and the key it lights
.inset, .inset-open an anchorage inset and the link that opens its satellite map
#mapbox the map dialog, present only when a MapKit token is set
.reveal, .gauges elements that enter once when scrolled into view
.chart-scroll the horizontal scroller around a chart, live at 900px and below
.scroll, .sg-main pre the style guide's scrollers: a wide table, a code sample; all three take focus while they overflow
.float-cta, .close the floating action bar, and the closing section it hides over
.between, .close the two sections whose blueprint plate waits until it is within reach (.bg-on)
footer .weather the footer conditions widget
State lives in the DOM (classes, data attributes, custom properties) so that pages.css can do the drawing; this file rarely sets a style directly. The exceptions are --split and --cta-vv, which are measurements only script can take.
Doc comments here follow JSDoc, plus one house tag, @section, which opens a named group; the stylesheets use the same tag. tools/build-docs.py turns both into docs/javascript.html and docs/css.html. The line-by-line comments are for reading straight through, and cost nothing on the wire: the minifier removes every one of them.
Static capture switch
assets/pages.js:49
Adding ?static to any inner page's URL resolves every entrance at once: reveals shown, routes drawn, pins placed, the chart / satellite divider already at rest. It exists for screenshots and print. The class is set here, synchronously and before anything else runs, because headless browsers capture at the load event, before a requestAnimationFrame chain or a timer can settle. pages.css carries the matching html.static rules.
Chart panning
assets/pages.js:145
At 900px and below a chart keeps a minimum width (--chart-min) and pans sideways inside its .chart-scroll box. It opens scrolled to its subject, not to the left edge: data-focus-x is the fraction of the chart's width to center. Applied at once and again at load, when the chart's final width is known. It runs before the split below, which rests its divider in the middle of whatever this leaves on screen.
Keyboard panning
assets/pages.js:165
A scroller that a finger can drag has to answer to the keyboard as well (WCAG 2.1.1). While its content is wider than it is, a .chart-scroll box takes focus as a named region, and the arrow keys pan it. So do the style guide's two scrollers, a .scroll box around a wide table and a code sample's pre, but as named groups: a page has three charts at most and some twenty tables, and twenty landmarks would bury the real ones. While everything fits a box is none of those things, because a tab stop that scrolls nothing is noise. pages.css draws the ring.
scrollersmember
NodeList · assets/pages.js:175
Every sideways scroller on the page.
scrollerName(box)function
assets/pages.js:177
What a scroller is called. A chart takes the name its drawing already has. A table or a code sample takes the heading of its section and says what it is, numbered when the section has several, so that no two names on a page are the same and none repeats the section's own.
Returns string
syncScrollers()function
assets/pages.js:194
Gives each scroller focus, a role and a name while it overflows, and takes them back when it does not.
Chart / satellite split
assets/pages.js:212
Every chart but the itineraries hero carries a satellite plate of exactly the same window, revealed by a draggable divider. The divider's position is the custom property --split on the chart, in percent from the left: chart to its left, satellite to its right. pages.css turns that one number into the plate's clip-path and the positions of the line and the knob.
Only the knob and the line take the drag, never the whole plate, because on a phone the plan charts already pan sideways and the page has to scroll past them (both set touch-action: pan-y). A finger is taken twice over: by the pointer events, and, where a chart pans, by touchmove as well, since that is the only thing an iOS scroller will let go of. See tmove below.
Where it comes to rest is measured, not fixed. data-split is a percent of the whole chart, which is what it means while the whole chart is on screen; once the chart is wider than the screen and pans, that percent lands off to one side, so the divider rests at the middle of what is on screen instead and is within reach whatever the screen's size. The measurement is taken again whenever the geometry moves under it, unless the visitor has taken the divider somewhere.
A chart moves through four classes:
.split-on script is running: the plate, line and knob are displayed (they are display: none without it)
.split-intro the entrance is in flight: --split changes are transitioned, after --split-delay
.split-live the line and knob are visible
.is-dragging a pointer holds the divider
The knob is a role="slider". Its aria-valuenow is the divider's position; its aria-valuetext names the share of satellite, which is what a listener wants to know.
stillmember
boolean · assets/pages.js:241
True when nothing should animate: the ?static switch, or the visitor prefers reduced motion. Still pages open with the divider already at rest instead of playing the entrance.
splitsmember
Object<string, function(): void> · assets/pages.js:247
Entrance functions for the charts that animate, keyed by chart id. enter() calls one when its chart first scrolls into view, so the satellite wipes in after the route has drawn.
relaxersmember
Array<function(): void> · assets/pages.js:253
One per chart: puts its divider back where it should rest, after the geometry has moved. Called for every chart on the page at once, by relax() below.
restAt()function
assets/pages.js:270
Where the divider should rest. The authored rest is a percent of the whole chart, so it only says what it means while the whole chart is on screen; while the chart pans inside .chart-scroll the divider rests at the middle of the part that is on screen, whatever the screen's size.
Returns number Percent from the chart's left edge.
set(v)function
assets/pages.js:283
Moves the divider. The one place --split is written, so the custom property and the slider's ARIA values cannot disagree.
settle()function
assets/pages.js:295
Ends the entrance, so that later moves follow the pointer at once instead of easing over 1.5s.
at(e)function
assets/pages.js:297
The divider position a pointer event asks for, allowing for where the knob was taken.
Returns number Percent from the left; the current value if the chart has no width yet.
down(e)function
assets/pages.js:303
Starts a drag from the knob or the line. Only the primary button counts. The pointer is captured so the drag carries on when it leaves the 40px knob; capture can throw for a synthetic event, which is harmless, hence the empty catch.
Listens PointerEvent#pointerdown
move(e)function
assets/pages.js:319
Follows the pointer during a drag. Pointer events can arrive several times a frame, so only the newest position is kept and it is written once per animation frame. A Touch is as good as a pointer event here: all it reads is clientX.
Listens PointerEvent#pointermove
up()function
assets/pages.js:331
Ends a drag, however it ended: release, cancel, or losing the capture.
touchmember
?{x: number, y: number, mine: number} · assets/pages.js:333
The gesture in hand, while a finger is down: where it began, and whose it turned out to be (0 undecided, 1 the divider's, -1 the page's). Null between gestures and for a second finger.
tdown(e)function
assets/pages.js:339
Notes where a touch began. One finger only: a second means a gesture of the browser's.
Listens TouchEvent#touchstart
tmove(e)function
assets/pages.js:345
The touch half of the same drag, and the reason there is one. Below 900px a chart pans sideways inside .chart-scroll, and iOS settles whether that scroller takes a sideways gesture before any pointer event can claim it: on a tablet held upright the divider would not move at all, while the same tablet on its side, where the chart fits and nothing scrolls, dragged perfectly. touch-action: pan-y already says a sideways drag on the knob is ours, and is not enough there, so the first move of each gesture decides instead: a drag more along than up has its default prevented, which no scroller argues with, and then drives the divider itself, so the drag survives whether or not pointermove does. A drag more up than along stays the page's, because the line is a full-height strip and must not trap the scroll.
Listens TouchEvent#touchmove
tup()function
assets/pages.js:367
Ends a touch gesture, whichever way it went.
note
assets/pages.js:400
Takes this chart's resting position again, after the geometry has moved under it: the chart's final width at load, a resize, a phone turned on its side. Nothing moves before the entrance has run or after the visitor has taken the divider, and a chart that does not pan measures the same authored rest and stays where it is.
splits[chart.id]()function
assets/pages.js:412
This chart's entrance: from closed (chart only), the satellite wipes in from the east and settles at rest.
relax()function
assets/pages.js:420
Takes every divider's resting position again, once per animation frame, when the geometry the measurement was made against has moved: the charts' final widths at load, a resize, a phone turned on its side. One listener for the page.
Listens Window#load, Window#resize
Anchorage maps
assets/pages.js:434
Every inset carries a link to Apple's satellite map at its pin, and the whole plate answers for it. On anything that is not an Apple device the link is pointed at Google Maps' satellite view instead. With a MapKit token on the page the click opens a live map in a dialog instead; without one, or if anything about the map fails, the link still goes to Apple Maps. Apple is not contacted before a click in either form: MapKit JS is fetched on the first one.
The token is MAPKIT_TOKEN in tools/chart-pages.py, which writes it into a meta tag and emits the #mapbox dialog only when it is set. While it is empty, which it is today, everything inside the if block below is dormant and the insets behave as plain links.
MapRequesttypedef
Object · assets/pages.js:447
What the dialog needs to draw one anchorage, read from the opener link's data attributes.
mapMetamember
?HTMLMetaElement · assets/pages.js:458
The meta tag carrying the MapKit JS token, if the generator wrote one.
mapTokenmember
string · assets/pages.js:460
The token, or "" when the in-page map is off.
mapboxmember
?HTMLElement · assets/pages.js:462
The map dialog.
openMapmember
?function(HTMLAnchorElement): void · assets/pages.js:464
Opens the map dialog for an inset's link. Stays null when there is no token or no dialog, which is how the inset wiring below knows to leave the links alone.
mapFailed()function
assets/pages.js:480
Shows the fallback line over the map, unless MapKit is known to be working.
mapKeys(e)function
assets/pages.js:482
The dialog's keys: Escape closes, Tab wraps. The focusable set is rebuilt on every press because MapKit adds and removes its own controls, and an element counts only if it has a box on screen.
Listens document#keydown
closeMap()function
assets/pages.js:498
Closes the dialog, lets the page scroll again, and returns focus to the link that opened it.
loadKit()function
assets/pages.js:506
Loads and initializes MapKit JS, once. Later calls return the same promise.
Returns Promise<Object> Resolves with window.mapkit; rejects if the script cannot load or init throws.
drawMap(mk)function
assets/pages.js:529
Aims the map at the wanted anchorage: satellite view, framed to about the inset's own window, with one marker in the pin's colors (Shallow Water disc, Midnight Hull numeral).
appleDevicemember
boolean · assets/pages.js:564
Whether this is an Apple device, where the generator's Apple Maps links stay as written. Everything else (Android, Windows, Linux, ChromeOS) is sent to Google Maps instead. An iPad asking for the desktop site calls itself a Mac, which is the right answer here too.
googleHref(link)function
assets/pages.js:571
The Google Maps form of an inset's Apple Maps link: the same center and zoom, satellite view, in Google's documented Maps URLs. That form draws no pin, so the view is centered on the anchorage.
Returns string The Google Maps address for the same view.
Reveals, chart entrances and gauges
assets/pages.js:601
Three kinds of element enter once, when any of them is on screen and they are 80px clear of the viewport's bottom edge: .reveal blocks fade up, charts draw their route and place their pins, and the "how the week leans" gauges fill. Script only adds a class; pages.css owns the motion and its reduced-motion counterpart. Each element is unobserved after it has entered, so nothing replays.
enter(el)function
assets/pages.js:609
Marks one element as entered. Charts and gauges take .is-in; everything else takes .reveal-visible. A chart that registered a split entrance starts it here.
iomember
?IntersectionObserver · assets/pages.js:618
The shared observer; null in a browser without IntersectionObserver, where everything enters at once.
Deferred plates
assets/pages.js:630
The blueprint plates behind .between and .close are background images, which a browser fetches with the first render however far down the page they are. pages.css withholds them until the section takes .bg-on, which happens here once it comes within two screens or so of the viewport. Under ?static, and without IntersectionObserver, every plate is let through at once.
Chart and log linking
assets/pages.js:648
Pointing at, or focusing, a log entry lights its leg and pin on the chart, and pointing at a pin lights its entry: the link runs both ways. A row names its chart with data-for and its key with data-key; pins carry data-pin and route segments data-seg with the same key (a leg number on the itineraries page, an anchorage number on the destination pages).
setActive(chartId, key)function
assets/pages.js:656
Lights one key on one chart, or clears the chart. Sets data-active on the chart, which is what dims the other routes and pins in pages.css, and toggles .active on the matching pin, route segment and rows.
Floating action bar
assets/pages.js:689
Call, Email and Book, fixed to the viewport's right edge (its bottom edge on a phone). It shows once the hero has passed the middle of the viewport and hides over the closing section, which carries its own coral Book button: two corals never share a view (The One Coral Rule, DESIGN.md).
onScroll()function
assets/pages.js:699
Scroll handler, limited to one measurement per animation frame. Writes data-shown on the bar; pages.css fades it and takes it out of the pointer's way.
Listens window#scroll
ctaShiftmember
assets/pages.js:718
Keeps the bar on the visible bottom edge of a phone. A fixed element is laid out against the layout viewport, but a mobile browser's toolbar or keyboard shrinks the visual viewport inside it. The difference is written to --cta-vv on the root element, and the bar's bottom offset adds it.
Listens window#resize, VisualViewport#resize, VisualViewport#scroll