# Method, measurements and sources

Everything in this folder is hand written HTML, CSS, JavaScript and inline SVG, plus Canvas 2D in
two places where a canvas is the right tool: the density layer of the scatter, which redraws on
every mouse move, and the frame-time graph. TASK.md allows "SVG or Canvas 2D as your judgement
decides"; the trade-off was read up before deciding. There is no build, no package manager, no
dependency, no CDN, no WebGL and no WASM. Opening
`index.html` by double click is the whole installation; every path used is relative.

## Layout of the code

Paths are relative to this folder. There is no absolute path, no parent directory reference and no
sibling folder anywhere in the source, so the folder can be moved or copied as is.

| file | what lives there |
| --- | --- |
| `index.html` | markup, static prose, data dictionary, noscript content |
| `css/dashboard.css` | the whole visual design, one file, no preprocessor |
| `js/core.js` | PRNG, normal sampler, scales, nice ticks, quantiles, Oklab, formatters, rank comparator |
| `js/palette.js` | ramps built from Oklab, contrast checked pairs |
| `js/data.js` | the generator and the brute force reference block |
| `js/spatial.js` | quadtree used by the tooltips |
| `js/agg.js` | predicates, per record fail masks, the incremental counters, the brush sweep |
| `js/chartlib.js` | SVG and canvas builders, axes, legends, tables, focus ring, tooltip shell |
| `js/charts.js` | stacked area, density scatter |
| `js/charts2.js` | calendar heatmap, ranked bars, distribution panel |
| `js/selftest.js` | the 28 checks and their reporting |
| `js/app.js` | wiring, URL hash, undo, keyboard, frame timing, stress harness |
| `dev/check.js` | Node or Bun harness for the maths only, not loaded by the page |

Scripts are plain `<script>` tags in that order. ES modules are deliberately not used: a module
script fetched from `file://` is blocked by CORS in Chrome and Firefox, which would break the
double click rule.

## Data

`n = 60 030` accepted movements over 731 days, 1 January 2024 to 31 December 2025, from the seed
`cta-vartunda-strait-24m-v3`. The generator is a thinning of a Poisson process: a homogeneous
candidate stream over a slightly larger window, accepted with probability `lambda(t) / lambda_max`.
Arrival intensity carries a weekday term, a twelve hour tidal term, a twice daily port call wave, a
two day weather memory and a seasonal factor.

Randomness is `sfc32` from Melanson's PCG paper, seeded by a `splitmix32` hash of the seed string; the
mixing constants follow the usual abbreviated form. Normals come from the polar (Marsaglia) ratio
method. `Math.random` appears nowhere; the only entropy per record is one hash of its index plus
the seed.

Per vessel:

* `LOA` from a class specific lognormal, clamped to 8 to 341 m.
* Displacement hull speed `V = Fn * sqrt(g * L) * 1.94384` knots with Froude number `Fn` per class
  plus noise, then a spring neap tide of up to 0.6 kn.
* Draught from the displacement relation `T = k * L^0.78` with `k` per class plus noise, clamped to
  2 to 25 m.

The measured correlation between SOG and draught is `r = 0.4345`, which comes from both being
functions of the same length. Class shares are Cargo 29.5 %, Tanker 16.9 %, Container 16.0 %,
RoRo 22.4 %, Fishing 15.2 %. 1.43 % of movements are flagged, by kind: 210 speed anomaly, 63
draught over the hull form envelope, 369 near zero speed on a main route, 196 queue overrun.
Thresholds and every generator constant are listed in `js/data.js` and repeated in the totals
table under the charts.

Flags are a rule over the stored integers, evaluated after rounding, so replaying the rule on the
table reproduces the column exactly. That is one of the self test rows.

Dates are integer milliseconds since epoch plus UTC accessors, per the MDN guidance on dates: a day
index is `(t - t0) / 86400000` and the weekday is that index modulo 7, which is valid because 1
January 2024 was a Monday. 2024 is a leap year, so the second year starts at day 366.

## What was checked against a source, and what was guessed

Checked, with the source read during the build of this page:

* Freedman Diaconis `h = 2 * IQR / n^(1/3)` and Sturges `k = 1 + log2(n)`, from the Wikipedia
  article on histograms. The square root rule is *not* attributed to Sturges here.
* `niceNum` and the tick loop, from Heckbert's published code, including `1e-3 + fpp` in the
  logarithm, `m = f / (1e-3 + fpp)` and the 1, 2, 5 thresholds.
* The Oklab forward and inverse sRGB matrices, from Bjottosson's post.
* That children of a `role="img"` element are removed from the accessibility tree, from MDN, which
  is why each chart's sentence is carried by `aria-label` and the tables are outside the SVG.
* Axial and cube hex coordinates, and the neighbour distance `sqrt(3) * size`, from redblobgames.
* That `Math.random` has no seed or state to capture, from MDN, which is why the PRNG is hand
  written.
* That a module script over `file://` is blocked by CORS, tested in Chromium on this machine before
  deciding on plain scripts.

Judgement calls, not copied from a source, each chosen to be plausible rather than canonical:

* The five vessel classes, their shares, their length and draught parameter ranges, the Froude
  numbers, the destination list and the route names are invented for the fiction.
* The daily traffic level, the seasonal curve, the tidal term and the storm factor are shaped to
  look like an approach to a strait, not read from any real AIS study.
* The anomaly thresholds 0.45, 1.32, 1.42 and 300 minutes are set so the flag rate lands near 1.4 %
  and each kind stays visible in the panels.
* Where the task asked for a relation between speed, class and draught, it is imposed through the
  shared length driver rather than fitted; the resulting `r = 0.4345` is reported, not tuned to a
  target.

## Indexes

Four typed array key columns feed the counters:

* `kSeries = day * 5 + class`, 731 by 5 cells. The day column must be 16 bit: 731 does not fit in a
  byte, and a wrap there silently piles three years of traffic onto 256 days. The self test row
  "Calendar columns agree with the timestamps" is what catches that class of bug.
* `kCal = hour + 24 * weekday`, 168 cells.
* `kHex` and `kSq`, the bin ids in a 640 by 360 reference plot, rebuilt when the plot is resized.

A quadtree over speed and draught serves the tooltip snap. A nearest query with an accept predicate
returns the closest record that passes the *other* filters, and reports how many nodes it touched.

## Cross filtering

Every record carries an eight bit fail mask, one bit per dimension: class, time, route, flag, brush,
calendar, destination, distribution value. A counter keyed on a dimension excludes that dimension's
own bit, which is the crossfilter trick of counting with one dimension omitted; it is why a category
dimension costs exactly one pass over the records and why no panel ever filters itself.

Changing a category or temporal filter touches each record once (`flip`) and updates every counter
by plus or minus one. Nothing loops the dataset more than once per change; the metric
`re-aggregation` in the panel strip is the measured time of that update.

The scatter brush is the exception worth naming: a naive implementation would re-project 60 030
points per mouse move. Instead a CSR grid of 48 by 32 coarse cells stores record ids and a move only
visits the symmetric difference of the cells covered by the old and the new rectangle. Measured on a
drag: 0.20 ms median, 8 754 records visited, worst case 60 030.

`reconcile()` recomputes selected counters from the records themselves over a sample of cells and
reports the largest discrepancy; it is 0.

## Bins

The distribution panel states its rule in the UI and uses it. On the whole record set:

* Freedman Diaconis: `h = 2 * IQR / n^(1/3)`, then `k = (max - min) / h`, with the width thinned by
  an integer factor when `k > 140`. For draught, IQR = 6.26 m, `h = 0.3198`, `k = 62`, and the
  tallest bin holds 2019 movements in the reference pass.
* Sturges: `k = ceil(log2(n)) + 1 = 17` for 60 030 records.

Bins are fixed on the whole record set, not on the filtered subset, otherwise panels would silently
change their x axis against each other. Sources: Freedman and Diaconis (1981), and Sturges (1926),
both as summarised in the Wikipedia article on the histogram bin width rules; the `niceTicks`
implementation follows Heckbert, *Nice Numbers for Graph Labels*, Graphics Gems I, pp. 61 to 63,
where `niceNum` is defined in terms of FPP and the mantissa thresholds 1, 2 and 5.

## Colour

Ramps are generated at load in Oklab with monotone lightness and gamut clipped chroma reduction,
using the matrices published by Bjottosson for the sRGB forward and inverse transform. The class
ramp steps L through 0.355, 0.465, 0.575, 0.685 and 0.800 while gliding hue from 262 to 160 degrees,
so adjacent bands differ in two channels at once yet still read as an ordered sequence in greyscale.
Amber is kept out of the ramp because amber means *selected* on this page. The stack is ordered by
mean band width, largest at the bottom, fixed for the session so the order never jumps between
redraws. The sequential heat ramp is sRGB 2.2 decoded, interpolated in Oklab, then re encoded.
Contrast is checked with the WCAG 2.1 relative luminance formula and the worst pair in use is
reported by the self test.

## Accessibility

* Filters, legend chips and chart marks are real buttons, selects or focusable hosts.
* In a chart: arrows move a cursor through the marks, Enter pins or unpins a filter at the cursor,
  Escape clears that panel's filter, Space toggles a legend entry, Home and End jump to the ends,
  PageUp and PageDown step by ten.
* Focus is shown by a drawn ring on the chart plus a panel outline, never the default outline.
* Every chart has `role="img"` with an `aria-label` that carries the current sentence, and a table
  in the DOM behind a Table toggle.
* Charts are described once in prose; the decorative SVG inside them is hidden from AT.
* The self-test sheet is an overlay. From 900 px up it takes a column and the dashboard narrows to
  make room, so no chart is ever behind it; below 900 px it becomes a bottom drawer that Close or
  Escape gets rid of. Checked by measuring the intersection of the sheet rectangle with every panel
  at 1440, 1280, 1120, 1024, 900, 820, 768, 480 and 380 px: zero overlap at 900 and above, no
  horizontal overflow anywhere.
* The page works with script off: the headline, the prose, the dictionary and the noscript notice
  render, with no console errors.
* `prefers-reduced-motion` removes transitions and the brush animation.

## Measured numbers

Measured in Chromium for Testing 151 (headless shell) on this machine, viewport 1280 by 940, all
28 self test rows passing.

| quantity | value |
| --- | --- |
| boot to first paint of all six panels | 1.1 s including the reference pass, of which the generator is about 150 ms |
| brush drag, re aggregation | 0.30 ms median |
| brush drag, records visited | 8 754 of 60 030 |
| brush drag, paint | 6.5 to 8.0 ms |
| frame time while brushing | p50 16.7 ms, p95 20 to 50 ms, worst 50 ms |
| long tasks | 2 over a 30 step drag |
| stress mode, 20 000 raw marks per frame | median 5.5 ms, p95 6.3 ms, worst 11.5 ms |
| console messages of type error or warning | none, in every test run |

The stress harness exists because the requirement is stated in visible marks rather than records:
hexbins and the grid collapse 60 030 records into about 1 000 bins, so the frame graph would
otherwise never see 20 000 marks. It draws 20 000 points for 40 frames and feeds the same graph.

## Testing tools, not part of the page

`dev/check.js` is a Node or Bun harness that loads `js/core.js`, `js/palette.js` and `js/data.js`
without a DOM and re-runs the aggregate, scale and chaos tests. It is a development aid; `index.html`
never loads it and the page has no dependency on it. The browser side of the verification was done
with Playwright driving `chrome-headless-shell` over `file://`; those scripts live outside this
folder and are not referenced by the page.

## Sources consulted

* Sturges and Freedman Diaconis rules and their constants, Wikipedia article on the histogram, bin
  width choice section.
* Bjottosson, *Oklab and access to it*, for the sRGB forward and inverse matrices used in `core.js`.
* Melanson, *PCG: A Family of Simple Fast Space Efficient Statistically Good Algorithms for Random
  Number Generation*, for `sfc32`.
* Heckbert, *Nice Numbers for Graph Labels*, Graphics Gems I, pp. 61 to 63, for `niceNum` and the
  tick thresholds used by `niceTicks`.
* redblobgames, *Hexagonal geometry*, for the axial coordinates behind the hexbin key.
* MDN, ARIA `role="img"` and the `Date` reference, for the accessibility note that children of a
  `role="img"` element are a black box, and for doing date arithmetic in UTC milliseconds.
