Neuroscience Developer Reference
Maintain and extend the standalone browser implementation for Neuroscience Lab: UI contract, runtime state, vision math, exports, URL state and Library integration.
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.
1. Code map
+| File | Role |
|---|---|
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 |
63 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-taband targetp-nv-lab,p-nv-pipeline,p-nv-cells,p-nv-data,p-nv-exportandp-nv-reference. Activating a tab dispatches annv:tabCustomEvent ondocument, 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-presetandnv-cvd-type. - Enlarge: double-clicking
nv-stimornv-mapopensnvs-chart-fs-overlay. - There is no dismissible guide strip. The provenance note above the tabs has no close control, deliberately.
3. UI contract
+| Area | Important 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 outL = 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 atSENS_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 × 256 | Before | After |
|---|---|---|
| The five stages, one pass | 195 ms, on the main thread | 77 ms, on the worker |
| of which the V1 bank | 190 ms | 26 ms |
| The stimulus, first pass | 580 ms, on the main thread | 7 ms, on the worker |
| Main thread blocked during a drag | 6982 ms | 0 ms |
| Frames longer than 50 ms during a drag | 40 of 116 | 0 of 109 |
| Picture updates after an input | 215 ms | 66 ms |
| First contentful paint | 1132 ms | 948 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.jsanswers 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 astagesarray 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. Itssfargument is in cycles per pixel, derived from the geometry; it used to begaborSF ÷ 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 asG(v)eiAvagainstG(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, andgaborEnergyDense()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_MAPSlists 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()writessettingsObject().
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: "Neuroscience",
href: "/tool/general/perception-and-neuroscience/neuroscience/",
category: "Perception"
},
asset_type: "preset",
capture() {
const c = firstToolCanvas();
if (c) {
return captureCanvasAsImage({
canvasId: c.id,
name: "Neuroscience snapshot",
asset_type: "preset"
});
}
return captureFormPreset({ name: "Neuroscience 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;
conePeaksreturning a constant instead of deriving one;installConeTableaccepting 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.