# Coastal Traffic Authority · Strait Observatory

A cross-filtered data observatory of 60,000 deterministic vessel-movement records through a
strait, 1 Jan 2024 to 31 Dec 2025. Pure HTML, CSS, JavaScript, SVG and Canvas 2D. No
libraries, no network, no build step: double-click `index.html`.

## Files

- `index.html` — the only page. Static shell, ARIA labels, table alternatives, self-test
  overlay, noscript fallback (including a noscript-scoped `<style>` that hides the chart
  surfaces so the page degrades to readable text).
- `style.css` — the whole visual system.
- `app.js` — dataset, algorithms, aggregation, rendering and interaction, in one script:
  - **Part A** (constants, generator, scales, ticks, OKLab, formatting)
  - **Part B** (filter state, hash, single-pass re-aggregation, histogram, grid index,
    hidden brute-force reference, pipeline aggregates, self-test)
  - **Part C** (renderers, interactions, frame-time graph, boot)

## Method

### Dataset

A 32-bit `mulberry32` PRNG seeded with `0xC0FFEE` — there is no `Math.random` anywhere.
731 days (2024 is a leap year). 60,000 records; per-day counts follow a seasonal sine plus
a weekend dip, so volume is correlated with month and weekday. Per record:

- **Class** (cargo, tanker, ferry, fishing, yacht) chosen with seasonal mixing
  (fishing up in winter, yachts up in summer/weekends).
- **Hour** drawn from class-specific hour weights (ferries on fixed headways, yachts in the
  evening, cargo flat with a noon bump).
- **Route** (4) and **port** (8) with route-conditional port weights.
- **LOA and draught** from class-specific log-normal-ish ranges; **SOG** correlated with
  class and LOA (long vessels slower) plus a speed–draught penalty: deep-draught vessels are
  slower, which is what shapes the scatter's cloud. Queue minutes are correlated with hour,
  weekday and draught.
- **Anomaly** flag: `SOG > 15 kn and draught < 4 m`, or draught above the 17.4 m strait
  limit, or queue > 180 min — roughly 1 % of records.

Everything is stored in parallel typed arrays (`clsA`, `monthA`, `sogA`, `drA`, `qA`, …).

### Scale maths

Linear scale: `map(v) = r0 + (v - d0) * k` with `k = (r1 - r0) / (d1 - d0)`; `invert(p) =
d0 + (p - r0) / k`. Ticks use Heckbert's "nice numbers": step =
`niceNum((d1 - d0) / (maxTicks - 1), round=true)` where `niceNum` snaps the mantissa to 1,
2 or 5 × 10^k; ticks are `floor(d0/step)*step … ceil(d1/step)*step`. Axis domains are the
tick extents (never the raw data extents), which makes **tick containment** exact by
construction and is asserted in the self-test.

### Binning (histogram)

Freedman–Diaconis, stated in the UI: `h = 2·IQR·n^(−1/3)`, `k = ceil((max−min)/h)`,
clamped to `[4, 128]`; IQR from a hand-rolled deterministic median-of-3 Lomuto
`quickselect` on the filtered SOG values (quartiles at `round(0.25·(n−1))`,
`round(0.75·(n−1))`, i.e. linear interpolation). Degenerate samples (n < 8 or IQR = 0)
fall back to Sturges: `k = ceil(log2 n) + 1`. Unfiltered: 106 bins × 0.195 kn (IQR 3.84).

### Spatial index (tooltips)

A 48 × 48 grid over the (SOG, draught) domain. Each record's cell is computed from its
coordinates; per cell a singly-linked list (`gridHead`/`gridNext` typed arrays, built once).
Nearest-record lookup: find the cell under the cursor, then a 3 × 3 (up to 5 × 5) cell ring
by squared distance — at most a few dozen candidates, never 60,000 rows.

### Density handling

When the scatter's visible (cross-filtered) count exceeds 2,000 it switches from 2.4 px
point marks to a **48 × 48 grid count** heat (the same `densG` array the spatial index
uses), coloured on the OKLab ramp; the record table switches to the 40 densest cells. Under
2,000 it draws every mark.

### Re-aggregation (the one-pass guarantee)

`reaggregate()` loops the 60,000 records **exactly once** per state change. In that single
pass it computes:

- `S_all` (class ∧ months ∧ route ∧ anomaly ∧ brush): class×month matrix, month totals,
  port counts, weekday×hour matrix, Kahan-compensated SOG/queue sums, histogram sample
  buffer, spatial grid counts;
- `S_noClass` (same without class): feeds the stacked area, so a class filter **dims** that
  chart instead of emptying it;
- `S_noBrush` (same without brush): the scatter's visibility mask, so a brush **dim-masks**
  its own chart instead of removing its points (points outside the brush are drawn at 10 %
  alpha on a cached offscreen layer; the brush region clips the live layer).

Categorical masks are only rebuilt when the categorical key changes, so a brush drag
re-runs the pass but skips the visible-point rebuild. Changing one filter therefore never
re-loops the dataset more than once.

### Cross-filter semantics

Each panel shows, in its meta line, its **own axis** (the filter it implements, dimmed not
applied on its own chart) and which **global filters touch it**. The global filters are:
class, month range, route, anomaly-only — plus the scatter brush. The live record count in
the header always counts the full cross-filter conjunction, so it never lies.

### Colour

One neutral warm-grey palette, one accent (deep-sea teal `#0e6b5c`). The heatmap, density
grid and class stack are all built in **OKLab** (hand-converted with the 2021 reference
matrices; round-trip error on in-gamut sRGB measured ≈ 1.7e-6): single teal hue, lightness
ramp `L = 0.965 − 0.60·t`, quantised to 12 perceptual steps for the calendar cells —
ordered, not rainbow. Text/sparklines on dark cells flip to a light ink automatically.

### Interaction & accessibility

- Focus order follows DOM order: skip link → filters → each chart (SVGs and the canvas are
  focusable with descriptive `aria-label`s) → tables → footer. Focus ring is a custom teal
  outline + halo (`:focus-visible`), never the UA default.
- Per-chart keys: arrows move a visible cursor (dashed line / outline), Enter pins a filter
  (month, brush around a record, cell summary), Escape clears; scatter also steps through
  visible points in (SOG, draught) order. A "Cursor record" panel mirrors the hovered or
  focused record for everyone.
- Every chart has a hidden-until-requested **data table** (button in the panel meta) built
  from the committed filters, and an `aria-live="polite"` **sentence summary** that updates
  on commit (not per frame, so screen readers are not spammed while brushing).
- `prefers-reduced-motion: reduce` kills all transitions; charts render only final states
  (there is no motion to begin with).

### Performance

The footer frame-budget graph is drawn by hand on a 264 px canvas: last 120 rAF frame
durations, 16.7 ms budget line, red when a frame exceeds it, with `now / p95 / max` readout
(`window.__cf.frames()` exposes the window for measurement). Measured headless on this
machine: **~7 ms JS per frame while brushing** (p95 7.2 ms), i.e. well inside the 16.7 ms
60 fps budget; on a 60 Hz display the rAF interval itself sits at 16.7–17.3 ms.

## Self-test results (current)

The overlay runs on load: a hidden brute-force reference (12 plain passes over all 60,000
unfiltered records) versus the single-pass pipeline, plus four scale-maths assertions.
Current state: **16/16 pass** (pipeline ~2–4 ms).

| Check | Reference | Pipeline | Tolerance | Δ | |
|---|---|---|---|---|---|
| Total movements (24 mo, 5 classes) | 60,000 | 60,000 | Δ = 0 | 0 | PASS |
| Mean SOG: cargo, Mar 2025 | 10.5661 kn | 10.5661 | ≤ 1e-6 | 0 | PASS |
| p95 queue: winter weekends | 44.1879 min | 44.1879 | ≤ 1e-6 | 0 | PASS |
| Ferry movements: Jul 2024 | 425 | 425 | Δ = 0 | 0 | PASS |
| Anomalies: East Channel | 462 | 462 | Δ = 0 | 0 | PASS |
| Median draught: tankers | 5.7841 m | 5.7841 | ≤ 1e-6 | 0 | PASS |
| Mean queue: yachts 18–23h | 6.5036 min | 6.5036 | ≤ 1e-6 | 0 | PASS |
| Winter Saturday movements | 1,867 | 1,867 | Δ = 0 | 0 | PASS |
| Max LOA: fishing | 39.9952 m | 39.9952 | ≤ 1e-6 | 0 | PASS |
| Fast & shallow (SOG>15 ∧ dr<4) | 2,934 | 2,934 | Δ = 0 | 0 | PASS |
| Mean SOG: top 100 queues | 5.3518 kn | 5.3518 | ≤ 1e-6 | 0 | PASS |
| Movements: last 30 days | 3,072 | 3,072 | Δ = 0 | 0 | PASS |
| Domain→range round trip (15 samples) | ≤ 1e-9 rel | 2.37e-16 | ≤ 1e-9 | — | PASS |
| Tick containment (3 scales) | all contained | all contained | 0 failures | ok | PASS |
| Bin coverage (Σ bins = n) | 60,000 | 60,000 Σ, 0 outside | exact | exact | PASS |
| Sum identity (total line = Σ bands) | 24/24 months | 24/24 months | exact | exact | PASS |

The "top 100 queues" aggregate is kept in the pipeline by a hand-rolled 100-slot max-heap
(ties broken by record index), still single-pass. If a check ever fails, the fix belongs in
the pipeline, not the reference.

## Shareable state

Filter state serialises to the URL hash (`#c=1&ms=0,11&r=2&an=1&b=8.4-13.95,9.32-14.67`),
so any view is reopenable from disk; the browser back/forward button walks the filter
history. Undo (button or Ctrl+Z) has a 50-deep stack and survives reset.

## What was researched

Freedman–Diaconis and Sturges (formulas and fallback semantics), Heckbert's nice-number
tick algorithm (1/2/5 × 10^k snapping), OKLab conversion matrices (verified against the
2021 reference, round-trip measured), Canvas vs SVG at 20–60k points (SVG for the 5
structured charts; Canvas 2D for the scatter, with cached offscreen layers for the
dimmed/live composite), and ARIA patterns for charts (table alternatives, live regions,
keyboard operability, non-colour encoding).
