Skip to main content
Auric Artisan · Documentation

Visual Pathway Developer Reference

Maintain and extend the standalone browser implementation for Visual Pathway: UI contract, runtime state, vision math, exports, URL state and Library integration.

Published: May 24, 2026 Updated: May 24, 2026 Category: Reference Author: Chirag Bansal
Back to Documentation Auric Artisan Home

Overview

This reference maps the Visual Pathway page shell, the simulation engine, the viewing geometry, the state model, the dataset register, the views layer, the exports and public API, the Library binding and the test suite. It was rewritten for the 2026 rebuild, which moved the cone signals into the space their matrix is defined on, gave every spatial frequency a stated viewing geometry, and withdrew two readings the page could not support.

Table of contents

  1. 1. Code map
  2. 2. Page chrome
  3. 3. UI contract
  4. 4. State model
  5. 5. Vision math
  6. 6. Where the work happens
  7. 7. Pipeline runtime
  8. 8. Rendering and exports
  9. 9. URL state and public API
  10. 10. Library binding
  11. 11. Tests

1. Code map

+
FileRole
tool/general/perception-and-neuroscience/neuroscience/index.html SEO shell, masthead, provenance strip, tab rail and the six panels: Lab, Pipeline, Cells, Data, Export, Reference.
js/tool/nv/nv-kernel.js Every calculation and nothing that touches a document: the colour science, the viewing geometry, the stimulus and the five stages. Loaded twice — once as a page script, once inside the worker — so the two copies cannot drift. Exposes window.AANVKernel.
js/tool/nv/nv-worker.js The frame, off the main thread. Takes the control state, returns the pixels and then the maps, both as transferred buffers.
js/tool/neuroscience.js The engine’s half that has a document: state, readouts, painting, exports, and the scheduler that drives the worker. Exposes window.AANeuroEngine and window.AANeuroscience.
js/tool/nv/nv-sources.js The dataset register: nine entries with a status of verbatim, computed, synthesised or absent. Exposes window.AANVSources.
js/tool/neuroscience-views.js The cone-spectra figure and the Pipeline, Cells, Data, Export and Reference views. Holds no colour science of its own except the historical chain it reconstructs for comparison.
js/tool/neuroscience-page.js Tab switching, data-nv-goto links, dropdown label sync and the enlarge-a-canvas overlay.
css/anz-shell.css, css/neuroscience-shell.css The shared workbench slice and this route's sheet. The route loads the slice instead of analyzer.css, which is worth 75.8 KB.
tests/colorimetry/nv-harness.js, nv-reference.test.js, nv-sources.test.js 72 tests that load the shipped kernel and engine under a stubbed DOM, the way the page loads them.

2. Page chrome

+
  • Tabs: buttons carry data-nv-tab and target p-nv-lab, p-nv-pipeline, p-nv-cells, p-nv-data, p-nv-export and p-nv-reference. Activating a tab dispatches an nv:tab CustomEvent on document, which is how the stage grid knows it may size its canvases.
  • In-prose links: an element with data-nv-goto="p-nv-data" opens that tab.
  • Dropdown sync: the value setter is patched for nv-source, nv-carrier, nv-view-map, nv-colormap, nv-v4-preset and nv-cvd-type.
  • Enlarge: double-clicking nv-stim or nv-map opens nsc-chart-fs-overlay.
  • There is no dismissible guide strip. The provenance note above the tabs has no close control, deliberately.

3. UI contract

+
AreaImportant ids
Stimulus nv-source (grating, testcard), nv-row-grating, nv-base-hex, nv-carrier, nv-cpd, nv-contrast
Geometry nv-distance, nv-pitch, nv-pupil
Stages nv-en-retina, nv-en-lgn, nv-en-v1, nv-en-v2, nv-en-v4
Stage parameters nv-row-retina with nv-dog-center, nv-dog-surround, nv-dog-weight; nv-row-v1 with nv-gabor-cpd, nv-orient, nv-rf, nv-gsig; nv-row-v4 with nv-v4-preset
Display nv-view-map, nv-colormap, nv-show-scale, nv-cvd-type
Output nv-stage, nv-stim, nv-map, nv-map-name, nv-scale with nv-scale-bar, nv-scale-min, nv-scale-max, nv-scale-unit; nv-alias
Readouts nv-peak-gap, nv-stat-gap, nv-neutral, nv-stat-neutral, nv-rms, nv-sens, nv-sens-peak, nv-deg-per-px, nv-nyquist
Views nvSpectra, nvChain, nvPipeTable, nvStageGrid, nvDeadList, nvDataRows, nv-cone-drop, nv-cone-file, nvRefNav, nvRefBody
Actions nv-download, nv-share, nv-batch-input, nv-batch-run, nv-batch-body, nv-batch-count, nv-export-csv, nv-copy-csv, nv-export-json, nvSettingsPre

Removed in the rebuild: nv-advanced, nv-hyper and nv-poisson (read into state and consulted by nothing, or by something that did not do what the name said), nv-proxy (a dominant-wavelength readout that returned 570 nm for a neutral), nv-spf and nv-gsf (replaced by nv-cpd and nv-gabor-cpd, both in cycles per degree), nv-tf, nv-detect, nv-best-text, nv-spike-raster, nv-plot, nv-hist and the Actions, Standards, Formulas, References and Research panels.

4. State model

+

readState() writes to the module-scoped state object and returns it. Defaults are used when controls are missing.

{
  source, baseHex, carrier, cpd, contrast,
  distanceCm, pitchMm, pupil,
  nOrient, rfSize, gaborCpd, gaborSigma,
  dogCenter, dogSurround, dogWeight,
  v4Preset, cvdType, colormap, showScale, viewMap,
  enableRetina, enableLGN, enableV1, enableV2, enableV4
}

Every field reaches something that draws. The shipped state carried advanced, hyper and showScale, three of which were read on every update and consulted by nothing; showScale is the one that survived, because the scale bar it named now exists.

5. Vision math

+

The front end is sRGB → linear → XYZ → LMS → opponent. The XYZ step was absent: the Hunt-Pointer-Estévez matrix is published for XYZ and was handed linear sRGB, and that reaches every stage.

  • srgbEotf(), srgbOetf() — IEC 61966-2-1, with a 256-entry decode table.
  • rgbToXyz(), xyzToRgb(), xyzToLMS(), lmsToXYZ() — the published matrices; the cone inverse is computed, not typed a second time.
  • linToLMS() — linear sRGB to cone signals, through XYZ. The matrix is normalised so the D65 white point comes out L = M = S, which is what puts a neutral at the origin of both chromatic axes.
  • lmsToOpponent() — L−M, S−(L+M)/2, L+M. Not DKL, and the register says why.
  • neutralOffAxis() — the largest departure a grey ramp makes from the origin. 2.2×10−4 now, the published matrix's own rounding; 0.0877 before.
  • CONE_TABLE, conePeaks(), installConeTable(), PUBLISHED_PEAKS — the spectral curves the figure draws, their derived peaks, and the way to replace them. The shipped table's L and M both peak at 590 nm where the published ones peak at 566.8 and 541.2; it drives the figure and nothing else.
  • degreesPerPixel(), cpdToCyclesPerPixel(), cyclesPerPixelToCpd(), nyquistCpd() — the viewing geometry every frequency on the page is derived from.
  • spatialSensitivity() — a log-Gaussian peaking at SENS_PEAK_CPD = 4 c/deg, this tool's own. It takes frequency alone; the shipped one moved its peak from 9.3 to 103.5 c/deg with pupil size.
  • CVD_MATS, cvdLinearMatrix(), applyCVD() — Viénot, Brettel & Mollon 1999, composed through XYZ and the cone transform.

6. Where the work happens

+

The stimulus and the five stages are arithmetic on typed arrays. They ran inside the animation frame, on the thread that paints, and that was the whole of the lag: a forty-step drag of one slider took 9.4 seconds, blocked the main thread for 7.0 of them, and dropped every frame it touched. The same drag now takes 1.2 seconds, blocks the main thread for zero, and holds its frame interval at 16.6 ms.

Measured at 256 × 256BeforeAfter
The five stages, one pass195 ms, on the main thread77 ms, on the worker
  of which the V1 bank190 ms26 ms
The stimulus, first pass580 ms, on the main thread7 ms, on the worker
Main thread blocked during a drag6982 ms0 ms
Frames longer than 50 ms during a drag40 of 1160 of 109
Picture updates after an input215 ms66 ms
First contentful paint1132 ms948 ms

Three things make it work, and one keeps it honest:

  • The numeric half is nv-kernel.js, which reads no DOM and is loaded by both the page and the worker, so there is one copy of the colour science and it cannot drift.
  • nv-worker.js answers in two parts: the pixels the moment they exist, then the maps. The stimulus is the direct answer to the control that just moved, and it lands about ten milliseconds after the drag; the maps follow.
  • Every request carries a sequence number and every buffer is transferred rather than copied. An answer that is not the newest one asked for is dropped, which is far cheaper than blocking until a stale frame finishes.
  • A change that cannot alter a single number the stages produce — the colourmap, which map is on screen, the scale bar — repaints from the maps already in hand and never asks the worker at all.

If a worker cannot be had — no Worker constructor, a constructor that throws, a URL that will not load — the page runs the same kernel inline and says so once in the console. All three routes were tested and produce output identical to the worker’s, byte for byte; the page is exactly as correct, just as slow as it used to be.

7. Pipeline runtime

+
  • runPipeline(s, pixels, w, h) returns the maps for the stages that are enabled, plus a stages array naming them. V2 needs V1 and does not run without it.
  • dogSeparable() — the retinal centre-surround, two separable Gaussian passes. At a surround weight below 1 the kernel is not balanced, so it carries a DC term; that is why the off-centre map can be empty while the on-centre one is not, and the page says so.
  • gaborEnergy() — a quadrature pair per orientation at half resolution. Its sf argument is in cycles per pixel, derived from the geometry; it used to be gaborSF ÷ width, which no control related to the stimulus frequency. Both members of the pair come out of one complex separable pass: a Gabor with an isotropic envelope factors as G(v)eiAv against G(u)eiBu, and the modulus of that is the energy. It is a factorisation, not an approximation — it agrees with the dense form to 2×10−14, and gaborEnergyDense() is kept in the kernel as the reference the tests hold it to. The work drops from 2K² multiply-adds per pixel per orientation to 6K, which at the shipped K of 15 took the V1 stage from 190 ms to 26.
  • hypercolumnMap(), v2Texture(), v4ColorCell(), divisiveNorm() — the remaining stages. Heeger 1992 gives the form of the normalisation and none of its constants.
  • STAGE_MAPS lists the nine maps the viewport can show, each naming the pipeline key it reads. A test asserts every one of them exists after a full run.

8. Rendering and exports

+
  • genStimulus() builds the grating at a stated frequency in cycles per degree, converted through the geometry, so the bar spacing in pixels does not depend on the canvas size.
  • genTestPattern() is the procedural card the file carried with no call sites; it is a source option now.
  • renderHeatMap() returns the range it normalised against, so the caller can label the scale bar. Every map is scaled to its own extremes, which is what makes the bar necessary rather than decorative; a map with no variation says so rather than showing 0 to 0.
  • exportCSV() writes the colour table under a comment header naming the chain, the cone table and its peaks, the opponent axes, the geometry and whether anything in the file is reportable against a publication.
  • exportPNG() saves the active map; exportJSON() writes settingsObject().

9. URL state and public API

+

shareURL() serialises source, baseHex, carrier, cpd, contrast, distanceCm, pitchMm, nOrient, rfSize, gaborCpd, gaborSigma, the three DoG parameters, v4Preset, cvdType, colormap and viewMap. loadURL() restores them at boot.

window.AANeuroscience = {
  getState(), getSettings(), restoreState(saved), refresh()
};

window.AANeuroEngine = {
  // transfer and spaces
  srgbEotf, srgbOetf, rgbToXyz, xyzToRgb, xyzToLMS, lmsToXYZ,
  linToLMS, lmsToOpponent, luminance,
  // matrices
  M_RGB2XYZ, M_XYZ2RGB, M_XYZ2LMS, M_LMS2XYZ,
  M_LINRGB2LMS, M_LMS2LINRGB, CVD_MATS,
  cvdLinearMatrix, applyCVD, mul33, mm33, inv33,
  // cone table
  coneTable(), conePeaks(table?), installConeTable(rows, name), PUBLISHED_PEAKS,
  // geometry and sensitivity
  degreesPerPixel, cpdToCyclesPerPixel, cyclesPerPixelToCpd, nyquistCpd,
  spatialSensitivity, SENS_PEAK_CPD,
  // stages
  STAGE_MAPS, mapById, V4_PRESETS, runPipeline, gaborEnergy,
  dogSeparable, divisiveNorm, hypercolumnMap, v2Texture,
  // analysis
  analyseHex, neutralOffAxis, genStimulus, genTestPattern,
  hexToRgb, rgbToHex
};

window.AANVSources = { all, get, byStatus, reportable, STATUS_LABEL };

10. Library binding

+

js/library/tool-bindings.js registers the page with:

attachTool({
  matchPath: /^\/tool\/general\/perception-and-neuroscience\/neuroscience\/?/,
  tool: {
    id: "neuroscience",
    name: "Visual Pathway",
    href: "/tool/general/perception-and-neuroscience/neuroscience/",
    category: "Perception"
  },
  asset_type: "preset",
  capture() {
    const c = firstToolCanvas();
    if (c) {
      return captureCanvasAsImage({
        canvasId: c.id,
        name: "Visual Pathway snapshot",
        asset_type: "preset"
      });
    }
    return captureFormPreset({ name: "Visual Pathway preset" });
  },
  restore: restoreFormTool(["ns-render", "neuro-render", "ns-apply"])
});

The shared runtime capture registry also includes neuroscience: window.AANeuroscience?.getState and restores with window.AANeuroscience?.restoreState. Keep this pair in sync if the public API changes.

11. Tests

+

npm run test:colorimetry runs 72 tests for this tool, in tests/colorimetry/nv-reference.test.js and nv-sources.test.js. They load the shipped js/tool/nv/nv-kernel.js and js/tool/neuroscience.js under a stubbed DOM, in that order, the way the page loads them — so every assertion runs the code the page runs.

Each group was proved by injecting the original defect back into the kernel and confirming the suite fails. Seventeen mutants, seventeen caught:

  • the cone matrix handed linear sRGB again;
  • the dichromacy matrices straight onto linear sRGB;
  • the cone table relabelled as a publication;
  • conePeaks returning a constant instead of deriving one;
  • installConeTable accepting a degenerate table;
  • the sensitivity peak moving with pupil size, and moving out of the photopic range;
  • degrees per pixel dropping the viewing distance;
  • the Gabor frequency back in cycles per image;
  • the cone inverse typed a second time;
  • a spike raster returning as a stage map;
  • V2 running without V1;
  • and five register mutations, from promoting a stand-in to dropping the published peaks out of a note.

The optimisations were held to the same standard, because an answer that arrives faster and differs is a regression rather than a speed-up. Eleven more mutants, eleven caught: the separable filter dropping its cross term, losing a term in the imaginary pass, taking the real part instead of the modulus, swapping sin for cos in the rotation, halving the envelope, wrapping at the edge instead of clamping; the byte encoder built on the approximate threshold rather than the bisected one, rounding down at the top of the range, and off by one in its search; and makeFrame skipping or transposing the colour-vision matrix.

By hand, in a browser: step through all nine maps and confirm each draws with a labelled scale; uncheck the scale and confirm it hides; move the viewing distance and confirm the Nyquist figure and the alias warning follow; drop a cone table into the Data rail and confirm the figure, the peak-gap readout and the register entry all change; and check the page at 390 px, in both themes and in Hindi. Then drag a slider and watch the frame rate: if the pipeline has found its way back onto the main thread, that is where it will show.