Guidance for Claude Code (and humans) working in this repository. This file is a map, not a manual: it says what exists, where it lives, and the non-obvious rules, so you can find the code, not re-read it in prose.
Caution
This is a large, wide-ranging codebase: manage your context deliberately. The
data model, generator, viewer, sourcing pipeline and deploy stack are each sizeable, so
you cannot hold it all at once. Read only what a task needs (this map -> the one file ->
the one symbol), lean on the doc split (ARCHITECTURE.md, tools/README.md, docs/*)
instead of re-reading prose, and prefer targeted search over whole-file reads.
Keep files small and hierarchically organized so no single file balloons and dilutes
context. When adding code, anticipate its growth and place it so it stays cohesive:
split a module before it gets unwieldy, reuse existing helpers rather than duplicating
(see the no-duplication rule under Conventions), and design the seams up front so a later
costly refactor / dedup pass is not needed. A file that has grown too big to load
comfortably is itself a bug to fix.
Important
Keep this file current AND terse, and do not grow it by default. Update a line here only when a change adds or removes something a reader cannot quickly recover from the code itself: a new file, a new user-facing control, a new data-contract field, or a non-obvious rule. A pure refactor that keeps the same concepts needs no edit here. Prefer editing an existing line to adding one. Format contract, to stop it ballooning again:
- One line per feature/file/control. Name the symbol/file; don't narrate the code.
- State the current behavior only. Never write the history of a decision you reversed ("used to", "the old X", "earlier this was", "no longer"). Just describe what is true now; delete what is not.
- Give a rationale only when it is non-obvious (a "why" a reader would otherwise get wrong). Skip the obvious why.
- State each behavior once; cross-reference with "(see X)" instead of repeating.
- Deeper narrative (diagrams, module graph, boot sequence) lives in
ARCHITECTURE.md.
A browser-based three.js 3D brain visualizer, built with the help of Claude Code (see README.md for the feature tour): brain regions as procedural meshes, projection arrows between them, and datasets of neurotransmitter receptors, psychiatric drugs (Stahl's Prescriber's Guide), named circuits, and projection groups layered on top. Focusing a receptor/drug dims the brain and lights the regions (glowing "gem" dots) and pathways it touches; a drug adds effect colours (boost/block/modulate) + a by-mechanism flow overlay. The explode slider blows the assembled brain radially apart to reveal the deep nuclei.
Region group values (lobe, basal_ganglia, diencephalon, limbic,
hindbrain, brainstem_nuclei for the source nuclei raphe / locus coeruleus /
VTA) drive the legend headings + ordering via GROUP_LABELS in
tools/generate_data.py (emitted into meta.json, read by the viewer). Adding a
group means adding it there or its structures drop from the legend.
Coordinate convention (arbitrary units, brain centered on origin): x left(-)/
right(+), y down(-)/up(+), z posterior(-)/anterior(+).
A node is any sourceable datum, one atom of brain knowledge attributable to a
source; the dataset is a graph of nodes and a detail panel is a view of one node plus
every node linked to it. The concept, the umbrella-vs-kind distinction, and the coverage
tally are narrated in ARCHITECTURE.md ("The node model"); this file
keeps only the kind map below and the grading rules under Source provenance. New data is a
node, and every node must be sourceable so the coverage tally stays honest.
Important
"Node" is the umbrella term; the per-kind names (structure, projection, circuit,
receptor, drug, binding, target, ...) are node kinds and keep their names in the data
files + code (a structures.jsonl, a showReceptor). Don't rename the collections to
"node": that erases the distinction the graph needs.
Node kinds and where each lives (the emitted collection -> the sourcing-tally kind
in meta.provenance_stats.by_kind):
- brain region ->
structures.jsonl->structures - projection (pathway) ->
projections.jsonl->projections - functional circuit ->
circuits.jsonl->circuits - projection group ->
projection_groups.jsonl->projection_groups - receptor classification ->
receptors.jsonl, split into four independent graded sub-claims: neurotransmitterfamily->receptors; mechanismreceptor_class(GPCR/ionotropic) ->receptor_class;sign(excit./inhib.) ->receptor_sign;synapticsite (pre/post) ->receptor_synaptic(a receptor'sclassification[attr]each carry their own grade) - receptor expression region -> a receptor's
location_sources->receptor_locations - non-receptor drug target ->
meta.drug_targets->targets - target tone polarity (a direction-flipping
vesicular/sign/synapticflag) -> a target'spolarity_provenance->target_polarity - target expression region -> a target's
location_sources->target_locations - drug binding -> a drug's
bindings[]->drug_bindings - drug NbN label -> a drug's
nbn->drug_nbn - drug class classification -> a drug's
categories(+category_provenance) ->drug_categories - Wikipedia reference -> any node's
wikipedia->references(a pointer at a node, tallied but excluded from the headline; a reference is not itself a knowledge node)
The node sourcing contract. Every node carries a provenance grade and, ideally, a source. The grades, the single source shape, the sourceless case, and the tally + headline % maths are all defined once under Source provenance; don't restate them here.
The guiding principles (data separate from rendering, no build step, single source of
truth, self-describing data, fail-loud generation), the three-layer data flow, the module
graph, and the boot sequence are narrated in ARCHITECTURE.md. The
non-obvious rules a maintainer needs at the file level:
Most regions are symmetric L/R pairs: a region is defined once on the right in
generate_data.py and mirrored, avoiding per-side duplication. The same holds for
projections: a symmetric pathway is emitted once in projections.jsonl carrying
mirror: true (not as two _R/_L rows), and the consumer reflects it by flipping
_R <-> _L on both endpoints (js/data.js at load, check_data.py before its
checks); symmetric: False opts out. So each pathway is one node in the tally, not two.
Generated files are committed so the static site fetches them directly.
Project layout. Everything the browser loads is under public/ (the served
site). That directory is the only thing web-exposed: Caddy's /srv and
tools/serve.py both root there, so docker/, tools/, .git and the
uncommitted .env / deploy.sh / CLAUDE.local.md are never web-reachable.
Authoring + dev tooling live in tools/, deployment config in docker/, the
README hero shot in docs/.
The tools/ script reference and the emitted-data field contract (public/data/
.jsonl / meta.json / shapes/*.json fields) live in
tools/README.md (Tool reference + Data contract), moved there to
keep this file terse. In short: the anatomy is authored once in generate_data.py (drugs
in tools/data/drugs_data.jsonl), which emits the committed public/data/. generate_data.py is
now a thin orchestrator: the data lives in the tools/data_generators/ package (i18n,
provenance, drugs, geometry, presentation, connectivity, quotes/, quote_table,
receptors/, regions/; per-module purpose in tools/README.md). Each geometry form
is one data/shapes/<name>.json (blob/curve/composite, L/R pairs share one right-side
file via mirror:true). The author-side scripts are grouped: external-data fetchers under
tools/fetch/, provenance appliers under tools/sourcing/; their generated caches in
tools/generated_cache/. Run them from the repo root (python tools/fetch/<x>.py).
Note
An ongoing effort under geometry_refinements/ (its own CLAUDE.md +
STATUS.md, auto-loaded only when working there) is replacing the procedural
blob/curve/composite shapes with a self-authored SDF atlas, one structure at a time. It
adds an sdf shape type. Before editing data/shapes/* or shapes.js
geometry, check its STATUS.md so two sessions don't collide.
Viewer (public/):
index.html— page shell: loads three.js (vendored import map) and, on?debug=1, vendored eruda. Holds the#controlspanel, the popups (#shortcuts-modal,#legend-modal,#sourcing-modal,#about-modal,#image-lightbox, all.modal-overlay), the#bannersstack, the startup#loadingoverlay. UI-chrome accent = the--accent*palette in:root; data/semantic colours live inmeta.json, never here. Also wires the PWA: linksmanifest.webmanifest+ registerssw.js.- PWA (installable + offline):
manifest.webmanifest(name/icons/theme_color),sw.js(service worker that ALWAYS contacts the server when online, so a visitor never renders outdated data or a stale ES module; the Cache-API copy is an offline-only fallback, NOT stale-while-revalidate. Split by asset kind:/data/*(data + shapes) uses explicit conditional revalidation (revalidate()forwards the cached copy'sETag/Last-ModifiedasIf-None-Match/If-Modified-Sinceso an unchanged file returns a bodyless304served from cache, a changed one a fresh200: always fresh, cheap when unchanged, no version key to forget;cache:"no-store"on the fetch so OUR validator is the only one in play, since a plain SW fetch re-downloads the full body); code + shell use plain network-first (always refetch the full file, never mixing a stale ES module with a fresh one, matching the deploy'sno-storeintent). BumpCACHEwhen the caching logic changes;activate()prunes older caches), andfavicon.svg+icon-192/512.png+apple-touch-icon.png(a placeholder node-cluster glyph, to be replaced by the designed favicon). Caddy pins.webmanifest's content-type (Go's mime table lacks it). js/data.js— fetchesmeta.json+ the.jsonl(incl.quotes.jsonl) + shape files, rehydrates each{quote_id, provenance}source from the deduplicatedquotes.jsonlexcerpt table (rehydrateQuotes, mirror of the generator's externalize; see Source provenance); returns a normalized{structures, projections, circuits, projectionGroups, projectionGroupsByKey, receptors, targets, drugs, drugsByTarget, byId, meta}. Resolves each node's localized fields + derived render props (projectioncolor/sign, receptor labels +structureIds, per-bindingtargetName/actionLabel/effect/effectColor/structureIds/flowKind+ the drug's unionstructureIds/flowKinds/focusable/searchkeywords); builds the mergedtargetsbrowse list, thedrugsByTargetreverse index, andprojectionGroupsByKey(${mode}:${key}).js/shapes.js—buildGeometry()dispatches on type tobuildBlobGeometry/buildCurveGeometry/buildCompositeGeometry;mirrorGeometryXfor the left member. Self-contained PerlinfractalNoise(fBm/ridged/domain-warp). Cortical lobes are cel-shaded (MeshToonMaterial) domes with a shader-drawn swirl (injectCortexSwirl/CORTEX_SWIRL, pure colour, no relief).buildBlobGeometryhonoursclip_planeswhenJIGSAW_CLIP.enabled.js/arrows.js— curved tube+cone arrows; colour fromprojection.color, recolourable viasetColor;tentative-> dotted. Exposesarrow.curve. Each end attaches to the surface point nearest the other end (surfaceToward, a nearest-vertex scan) so the tip lands on real mass even for a concave region (the C-shaped caudate).update(fast)re-fits;fastreuses the cached offset + defers the pick-hull rebuild (see Spread performance),ensurePickGeometry()rebuilds it on demand.setWidthScale(s)rebuilds only the shaft/cone width from the cached arc (see Arrow width).setOpacityclamps toARROW_MAX_OPACITY(0.8), so arrows are always a translucent overlay.js/labels.js— floating name labels (CSS2DRenderer): one hidden label per region, shown on hover / show-all / when pinned (setPinned). Reads the hemisphere-strippedbase_name(the side is obvious from position).js/circuit-schedule.js—scheduleCircuit()BFS firing order for the circuit pulse (no three.js, testable; see Circuit animation).js/circuit-anim.js—createCircuitAnimationrenders that schedule as beads ridingarrow.curve+ a wash echo on landing (see Circuit animation).js/receptor-markers.js—createReceptorMarkers: gem-dot expression clouds for a focused receptor/target. ExportsbuildGemCloud+GEM_DOT_SIZE(reused by the drug animation). See Receptors & targets.js/drug-anim.js—createDrugAnimation: per-drug effect-coloured gem dots + surface wash;matches. Flow overlay reusescircuit-anim.js. See Drugs.js/surface-wash.js— sharedbuildWashShell+washStrength"wash of light" primitive (used by circuit echo + drug glow).js/anim-settings.js—animSettings, the single source of truth for decorative-animation state (read by every animated module):enabled(the Animations toggle) +quality(0..1 adaptive). See Settings & toggles + Rendering (adaptive quality).js/wiki.js—fetchWikiLead(url, lang)runtime fetch of a Wikipedia lead; locale wins via langlinks, English fallback; cached; best-effort (failure -> null).js/main.js— scene/camera/renderer/lights/OrbitControls; explode + transparency; the intro, auto-rotate, hover/pick raycasting;createInfoPanel; search; the legend builders (buildLegend/buildLegendKey/buildTargetLegend/buildDrugLegend); the on-demand render loop.app-config.js—window.__APP_CONFIG__. This committed copy is the local-dev fallback (feature fields empty). In the containerentrypoint.shrenders an env-filled copy into/genand Caddy serves that. Generic name (not "analytics-*") so content filters don't 404 it. CarriesANALYTICS_*,DEV,STARTED_AT,sourceUrl.- Single-purpose modules, each detailed in its own section below:
js/i18n.js(I18n),js/app-init.js(Analytics),js/dev-banner.js(Dev banner),js/error-banner.js(Error banners),js/loading.jscreateLoadingScreen()(Loading overlay),version.jswindow.__APP_VERSION__(Versioning).
Deployment (docker/): docker-compose.yml (hardened Caddy), Dockerfile
(strips caddy's cap_net_bind_service so exec works under no-new-privileges),
Caddyfile (serves /srv on :8359, serves /gen/app-config.js for
/app-config.js, split Cache-Control: no-cache on /data/* (revalidate -> cheap 304s,
never stale), no-store on code + shell; security headers incl. CSP),
env.example, entrypoint.sh (stamps STARTED_AT, validates ANALYTICS_URL,
derives ANALYTICS_ORIGIN, renders /gen/app-config.js).
Uncommitted, gitignored, environment-specific: deploy.sh, CLAUDE.local.md
(per-developer setup notes, incl. the deploy procedure and the Stahl source
material location).
Moved to
docs/RUNNING.mdto keep this file terse: servepublic/over HTTP (tools/serve.py);tools/shot.pyscreenshots + the?paramsdeep-link view keys.
Moved to
docs/DEPLOYMENT.mdto keep this file terse: the hardened Caddy container (non-root, read-only rootfs, dropped caps); actual deploy procedure inCLAUDE.local.md.
Moved to
docs/DEPLOYMENT.mdto keep this file terse: repo-tracked hooks undertools/git-hooks/, activated per-clone;pre-pushguardsmain+ offers the data check.
Moved to
docs/DATA_CHECKS.mdto keep this file terse:tools/check_data.py(stdlib) over emittedpublic/data/: six families (duplicates, reachability, TODOs, provenance grades, source quotes, connectivity).
Moved to
docs/I18N.mdto keep this file terse: EN/FR, no build step; UI strings injs/i18n.js, data strings authored as{en,fr}via_t()/FRingenerate_data.pybut emitted English-only (a serialization-timeexternalizepass collapses each{en,fr}to English + deduplicates the French intopublic/data/translations.fr.json, fetched by the viewer only in French). Any new string needs both languages or the build raises.
Moved to
docs/ANALYTICS.mdto keep this file terse: optional umami, runtime-injected viaentrypoint.sh->/gen/app-config.js; generic client-facing names dodge content filters.
Moved to
docs/CSP.mdto keep this file terse: Caddy sends CSP + security headers on every response; a new external resource (CDN, font, image host, cross-origin fetch) needs its directive extended indocker/Caddyfile.
Moved to
docs/ANALYTICS.mdto keep this file terse: optional amber WIP banner,DEV/STARTED_ATinapp-config.js,js/dev-banner.js; same runtime-injection plumbing as analytics.
Moved to
docs/BANNERS.mdto keep this file terse: failures surface as red dismissible banners (js/error-banner.js) so a visitor never opens the console.
Moved to
docs/BANNERS.mdto keep this file terse: startup#loadingprogress overlay (js/loading.js) + the startup Sources-and-provenance gate.
Moved to
docs/CONTROLS.mdto keep this file terse: the one collapsible bottom-left#controlspanel and everything in it: the Settings pane + accordion sections, the detail-tab strip, the sevenshow*()info-panel views, selection/halo + isolate, structure-name labels, legend sections, keyboard/touch input + search, and camera focus.
The render loop (renderer.setAnimationLoop) is on-demand: each frame runs the cheap checks
(tweens + controls.update()) but skips the expensive part (cull.tick() + renderer.render() +
labels.render()) unless a render is needed; when idle the canvas holds its last frame. A render is
triggered when a controller animated (each per-frame tick() returns a "did I animate" boolean:
intro/focus/circuitAnim/receptorMarkers/drugAnim), the camera moved (controls.update()
true), or invalidate() fired (wired to OrbitControls change, resize, a catch-all over user
input).
Adding a per-frame controller? Make its
tick()return whether it animated, or it runs but never repaints. Screenshots are unaffected (renders the settled frame, then idles).
createAdaptiveQuality keeps animation smooth on a weak GPU by watching the frame time of
rendered, animating frames (adaptive.tick(rendered && active)) and, with hysteresis, stepping the
shared animSettings.quality (0..1, clamped [0.6,1]) down when frames stay slow and up when they
recover. The dominant lever is renderer.setPixelRatio(baseDpr * quality) (fewer shaded pixels beats
the additive-glow overdraw of the gem/wash animations); secondarily the gem-dot (dotCountFor) +
circuit bead counts scale too, picked up on the next focus. Not persisted (a live measurement).
Composes under Animations: off means no motion to measure, so quality idles.
Re-fitting the ~100 arrows each explode frame was dominated (~90%) by the per-end nearest-surface
scans, making Separate janky. During a continuous spread the arrows update in fast mode
(applyExplode(..., true) -> ProjectionArrow.update(true)): each end reuses its cached offset
(valid because regions only translate, never rotate) and pick-hull/halo rebuilds are deferred.
createArrowRetrim then re-trims precisely once the spread has been still ~120ms, a chunk per frame;
a click mid-spread calls arrow.ensurePickGeometry(). The settled result matches the per-frame-precise
layout.
Arrows hold a roughly constant apparent width as the camera zooms. createArrowWidth scales each
arrow's shaft/cone by the camera<->target distance via ProjectionArrow.setWidthScale (rebuilds only
the width from the cached arc, no surface scan; reference distance captured on the first tick). The
explode auto-zoom is divided out via focus.explodeZoom(), so a spread doesn't rescale arrows
(only a genuine user zoom does), keeping it off the spread's hot path. The fat pick hull stays a
constant world size. Persists across explode rebuilds; clamped [0.4, 2.4]x.
Isolating a circuit plays a traveling-pulse: glowing beads ride each arrow source -> target, firing in sequence and looping. Split in two:
js/circuit-schedule.js(ordering, no three.js, testable).scheduleCircuittreats the arrows as a directed graph and a multi-source BFS from seeds sets each arrow's firing slot (phase) to its tail's BFS depth. Seed per component: thegroup=="lobe"node (cortex), else highest out-degree, else any. The seed set is mirror-completed (mirrorId) so L/R-paired nodes fire at equal depth. A feeder branch fires when activation reaches its tail, else at the cycle top.js/circuit-anim.js(rendering).createCircuitAnimationturns each slot into an additive bead ridingarrow.curve(rebuilt on every explode), the burst keyed off the projection'ssign(BURST: excitatory more/faster/brighter, inhibitory dimmer). On landing a bead fires a wash echo over the target (buildWashShellatarrow.curve.getPoint(1), pathway colour, sign-keyed).
Lifecycle (js/main.js): the row calls selection.setCircuit(...) then circuitAnim.play(...).
Stopping rides the selection state: an onIsolate watcher stop()s whenever the pinned-arrow set is
no longer exactly the animating circuit (circuitAnim.matches), so a clear / different circuit /
group focus / legend isolate all stop it; a mere structure highlight keeps it. Circuit-only (a group
focus uses setCircuit but never play).
A Circuits row and a Projections (per-pathway) row each open a sourced detail tab, like a
structure/drug row. Member pathways are never stored: a circuit's are the projections with both
endpoints in its set, a group's are those whose kind/sign matches key. js/data.js localizes
both and indexes groups by ${mode}:${key} (projectionGroupsByKey).
showCircuit: a "Functional circuit" heading with the loop's source pill (circuit.provenance), the Wikipedia illustration (appendWikiImages), the description + reference (appendReference, live-refreshed with a baked fallback), its structures (deduped to bases, clickable viaonStructure), and its member pathways.showProjectionGroup: a by-transmitter / by-effect heading with the group's source pill (group.provenance), the description (live-refreshed, its own pill), the reference, the member pathways, then (kind-mode only) a Drugs acting on this system list = focusable drugs whoseflowKindsinclude this kind (mirror of the drug panel's Projections affected; jumps viaonDrug).- Both reuse the shared
pathwayRow/appendPathwayListhelper (also used byshowStructure), which runs rows throughpathwayListto collapse left/right twin pathways to one row. focusCircuit/focusProjectionGroupmirrorfocusDrug: isolate (a circuit alsocircuitAnim.play()s; a group is a static pinned-arrow focus), show the panel, open the tab (circuit:/group:) with a reopen thunk.tabs.setOnEmptyclears the focus when the last closes. i18n:circuit.heading/structures/pathways,group.kindHeading/signHeading/pathways.
A focusable section listing the merged data.targets = every receptor (receptors.jsonl, authored
as RECEPTORS) plus every non-receptor drug target from meta.drug_targets (transporters,
enzymes, ion channels, receptor groups), so a target like SERT is explorable on its own. Built by
buildTargetLegend, grouped by neurotransmitter system. System headings order by total
knowledge nodes (each target's own node + its "Found in" regions + the bindings on it via
data.drugsByTarget), so a heavily-drugged system leads; "Other" is pinned last. Members order
lexicographically (localeCompare numeric, so 5-HT2 precedes 5-HT10). Both sources are normalized
in js/data.js.
- A receptor row's swatch = its sign colour; a non-receptor row's = its type colour
(
target_type_colors) + a muted tag. Clicking focuses it: dims to its regions (selection.setCircuit(regionMeshes, []), no arrow pin) +createReceptorMarkers.show(...). - Markers (
js/receptor-markers.js): dense additive gem dots over each region's surface (aTHREE.Pointscloud sampled from the mesh geometry + parented to it, so they track explode/mirror and vanish when it hides; count scaled by surface area; pulsed). BuilderbuildGemCloud(+GEM_DOT_SIZE), reused by the drug animation. Stopped via anonIsolatewatcher (.matches). - Panels: a receptor opens
showReceptor(system, Wikipedia link, live-refreshed description, then the four classification facts (family / class / sign / synaptic) each carrying their own per-attribute grade pill (a quote grades only the attributes it substantiates, so an unsourced GPCR/sign/site reads honestly asllminstead of borrowing a neighbour's quote), then the "Found in" list vialocationListgrouped undergroupLabelssub-headings, each region carrying its own expression-provenance pill + an amber "· " tag when it has no human assay (locationEntryprefers Human, so an Allen confirmation clears it), or one pilled "Throughout the brain" for ubiquitous). A non-receptor target opens the lightershowTarget(type + system facts with their grade pills, a Tone polarity row for a target carrying a direction-flippingvesicular/sign/synapticflag (its own graded nodetarget_polarity, not the classification grade, since the flag flips the drug-flow overlay's sign;TARGET_POLARITY_QUOTESupgrades it, elsellm), then the same per-region "Found in" list, kindtarget_locations). Both add a PDSP Ki lookup link (appendLookupLink); a receptor also gets UniProt (human-only) + GtoPdb name-search links (uniprotSearchUrl/gtopdbSearchUrl, no pill). Both carry an Interacting drugs section (fromdrugsByTarget, grouped by category, each row aneffectGlyph+ the binding'sbindingProvenancePill= the same resolved binding the drug panel shows; jumps viainfo.onDrug). Both make each "Found in" region clickable (info.onStructure->selectStructure). A stub receptor / unlocated target renders muted.
Receptor data: _receptor_record validates every family/class/sign/synaptic key + location base;
locations="ALL" -> ubiquitous. Each of the four classification attributes is a separate graded
node: the base grade defaults llm (overridable in RECEPTOR_PROVENANCE) and a
STAHL_ESSENTIAL_RECEPTOR_QUOTES quote upgrades only the attributes listed for that receptor in
RECEPTOR_CLASSIFICATION_COVERAGE (assigned conservatively: never when the quote and the record
disagree, e.g. CB1's "inhibition of release" quote describes retrograde function, not the receptor's
own excit./inhib. sign, so sign stays llm). A quote
need not be the same across the four attributes: RECEPTOR_ATTR_QUOTES gives an attribute its own
quote(s) instead of the main one, so a compound value earns verified only when every part is
attested (5-HT1B/D synaptic="both" needs one presynaptic + one postsynaptic quote). Each
expression region is a separate graded claim (kind
receptor_locations, default llm, upgraded per (receptor, region) by RECEPTOR_LOCATION_SOURCES,
quote-checked); these drove the brainstem_nuclei group. A non-receptor target's type/system/regions
are authored in DRUG_TARGETS; its regions grade identically (kind target_locations,
TARGET_LOCATION_SOURCES; both share the _location_sources emitter).
A focusable Drugs section showing, per drug, what it does to the brain. The psychiatric
drugs come from Stahl's Prescriber's Guide (8th ed.), extracted strictly from the dump
(only interactions literally stated; gaps left as TODO / no binding); the recreational
category (LSD, MDMA, ketamine, cocaine, nicotine, ...) and any future substance are not
Stahl-bound, their bindings sourced from measured PDSP Ki instead. Adding a drug is just a
new row in drugs_data.jsonl (see Changing the data), so the corpus is open-ended, not a
fixed Stahl list.
- Data. The drugs live in
tools/data/drugs_data.jsonl, read by_load_drugs. Vocabularies are defined once ingenerate_data.py:DRUG_CATEGORY_LABELS,DRUG_ACTIONS(action -> {label, neteffect}),DRUG_EFFECT_COLORS/DRUG_EFFECT_LABELS(boost/block/modulate),DRUG_TARGETS(non-receptor targets,typeaTARGET_TYPE_LABELSkey)._build_drug_targetsmergesDRUG_TARGETSwith every receptor id (so a binding can targetsertor5ht2a) ->meta.drug_targets._drug_recordvalidates category/target/action/effect + rejects duplicate ids. A binding's neteffect: agonist / reuptake-inhibitor / releaser / enzyme-inhibitor / PAM -> boost; antagonist / inverse-agonist / NAM / blocker -> block; partial-agonist / modulator -> modulate. - Animation (
js/drug-anim.js). Clicking a drug row (buildDrugLegend, grouped by category, with the live#drugs-filter) focuses it: dims to the union of its targets' regions (selection.setCircuit(regionMeshes, flowArrows)) +createDrugAnimation.show(...), which scatters abuildGemCloudper binding coloured by net effect (pulsed per effect) under abuildWashShellin the same colour; each binding'saffinityWeightscales its dot density (buildGemCloud'sdensityScale), size and brightness (potent target reads denser/brighter). Stopped via anonIsolatewatcher (.matches). - By-mechanism flow overlay (reuses
js/circuit-anim.js). The focus also rides beads along the projections of the drug's target system(s). It is a tone-setter + autoreceptor model, split from the dots: only tone-setting bindings drive flow (a reuptake/enzyme/vesicle target or a presynaptic inhibitory autoreceptor), never a postsynaptic receptor (those stay dots).js/data.jsgives each binding a signedtoneSign(toneSignOf: reuptake-inhibitor/releaser/MAO-inhibitor +, vesicular (VMAT2)/vesicle-protein blocker −, autoreceptor agonist − / antagonist +; avesicularflag + the α2 group'ssign/synapticcome frommeta.drug_targets) and anaffinityWeight(0.35..1 pKi ramp from the measured Ki, engagement not effect size). Per engaged kind it sumstoneSign*affinityWeightintoflowSystems({direction, weight, rel}): the sign is the flow direction (an SSRI drives serotonergic up, buspirone's 5-HT1A agonism down, a VMAT2 blocker down), the clamped magnitude its absoluteweight, andrelthe same normalized per-drug so the strongest engaged system = 1 (dosage varies, so the overlay shows relative intensity across systems).circuit-anim.jsplay(arrows, flowSystems)streams beads continuously end-to-end along each arc (a curated circuit keeps its sequential BFS volley; a drug focus, signalled by a passedflowSystems, streams so the relative density + speed read), recolouring/scaling per arrow offrel(boost = warm/bright/fast/dense, damp = cool/dim/slow/sparse). The system map is data:system_flow_kinds(targetsystem-> projectionkind, the diffuse ascending systems with a modeled source nucleus; glutamate/GABA left out).focusDrugfilters arrows (flowArrowsOf), pins +circuitAnim.play()s them; a purely postsynaptic drug sets no tone -> dots + wash only. Caveat: a D2-antagonist antipsychotic reads dopaminergic-up (blocking the D2 autoreceptor disinhibits release); its postsynaptic blockade shows in the block-coloured dots. (This is why the dataset carries the ascending monoamine pathways.) - Panel (
showDrug): the molecule image (when fetched), the class, the NbN, the description (live-refreshed from Wikipedia, re-gradedsourced), a Wikipedia link, then the Acts on binding list (each row: an effect glyph + target + action·note, "· speculative" when tentative, plus abindingProvenancePill= the binding's quote source, else its Ki (verified), elseNOSOURCE), sorted strongest-affinity first. Below it a Projections affected list (only whenflowKindsnon-empty): one row per ascending system whose tone the drug sets (jumps to that kind-mode group, pilled with the strongest binding on the system), an out-/in-arrow perflowSystems[kind].direction(raises / lowers tone); a derived inference from the tone-setter bindings (caption says so). Class + Nomenclature are clickable (open search with aclass:/nbn:filter) and each carries its grade pill: the NbN quote source, and the class classification'scategory_provenance(its own node, kinddrug_categories, defaultllm, upgradeable viaDRUG_CATEGORY_PROVENANCE/category_sources). - Binding affinity (PDSP Ki). A binding's
ki(fromfetch_ki.py) renders as akiChip: the median +[min-max]+ human/non-human counts + a verified badge (tooltip = the representative assay). Non-human-only is amber; an alias-borrowed value (ki.mapped) carries a "⚠ measured as<compound>" warning.affinity_onlybindings (a Ki, no known direction) list as "affinity only" with a neutral glyph, no source pill, and never animate (excluded fromstructureIds/flowKinds). A combo drug (name "A + B") leads with a warning box linking each constituent (drug.combo); combos carry no Ki. A measured Ki backs_binding_grade, lifting the binding toverified.fetch_ki.py --applydrops every PDSP assay >=10 uM as "inactive", so a genuine but weak binder (caffeine at A2a) can only be recorded by hand; such aki.sourcecarries"curated": trueso the idempotent--applyrefresh never strips it (_is_curated_ki).
5 drugs stay unbound as genuinely non-receptor agents (lithium, disulfiram, l-methylfolate,
triiodothyronine, caprylidene). The Stahl corpus url is "TODO" (the grade, not the link, conveys
provenance).
Two third-party image sources, handled differently on purpose.
- Molecule images (vendored same-origin; CSP
img-src 'self' data:).tools/fetch/fetch_molecules.pydownloads each drug's lead infobox SVG via the MediaWikipageimagesAPI intopublic/data/molecules/<id>.svg(.svgonly,<script>stripped); writestools/generated_cache/molecules_sources.json.generate_data.pyemitsstructure_imageonly when the file exists.showDrugrenders it as<img class="mol-structure">with CSSfilter: invert(1)for the dark panel (so the page declarescolor-scheme: dark). No image if absent. - Structure images (hot-linked from Wikimedia; the multi-MB GIFs are NOT vendored, only the url).
tools/fetch/fetch_structure_images.pyresolves a hero per base (fallback:.gif->.svg-> infobox/lead; pdf/djvu lead -> first-page JPG) + a gallery (gather_gallery: the other gif/svg on the base's EN+FR articles, chrome excluded via_is_gallery_chrome, cappedMAX_GALLERY) intotools/generated_cache/structure_images_sources.json, downloading no bytes (IMAGE_OVERRIDESwins for the hero). The same resolver runs over wiki-linked circuits (--target circuits) intotools/generated_cache/circuit_images_sources.json.generate_data.pyemitsstructure_image+_galleryfor both (loaded by_load_image_sources).showStructure/showCircuitrender them via the sharedappendWikiImages(heroUrl, gallery, altName): the hero lazily with a spinner (errorremoves the figure), then a "show more" toggle that builds the gallery lazily on first expand. Needs theimg-src https://upload.wikimedia.orgCSP allowance.
Lightbox. Clicking any panel image pops it up large in #image-lightbox (wireImageLightbox,
reuses .modal-overlay): open(src, alt, {invert}) fills the viewport (capped MAX_UPSCALE; SVGs
stay crisp), invert mirrors the molecule inversion. Closed by ×, backdrop, or Esc (routed first
in wireShortcuts). Darker backdrop than the shortcuts modal. i18n: image.close/image.zoomHint.
How every node (any sourceable datum, see Nodes) is graded + sourced. Every node's source/reference
carries a provenance grade (the dataset is LLM-assisted, not yet human-checked), rendered as a
coloured pill; the grade is data. Grades (PROVENANCE_LEVELS, weakest to strongest):
llm(grey ?): LLM from memory, unchecked, may be a hallucination.sourced(yellow ~): LLM given the source document, not quote-verified.verified(green ✓): an LLM extracted a quote, it was programmatically confirmed present in the source, and a separate LLM agreed it supports the claim. Highest grade; still LLM-driven.- absence -> a red ✕ pill (
NOSOURCE_GLYPH,.src-todo). Not a stored grade.
Every node's grade rides its own row/heading (makeProvenancePill(level), info.prov* tooltip via
withTip), never a separate bottom "Sources" block (a source only ever grades one node); a node with
no source shows NOSOURCE, never a blank. How the tally buckets these grades (and why llm counts as
unbacked): see The "% sourced" figure.
The sourcing model. A verified quote node may carry an optional llm (haiku/sonnet/opus,
SOURCING_LLMS) naming the model that extracted+judged it, so a reader can weigh a quote by that
model's capability; it is quote-node metadata (not part of the id hash), absent = unknown. Any new
sourcing/recheck pass must stamp it (a non-LLM deterministic source like Allen's PACall omits it).
A batch recheck (tools/sourcing/recheck_quotes.py, run as a Sonnet workflow) writes a central
generated_cache/quote_llm.json ({quote_id: llm}) that quote_table applies uniformly (an override
wins over a source-level llm); quotes it could not confirm land in quote_recheck_flagged.json for
review, not stamped.
Where the grade lives. One source shape, quote-level {corpus, page, quote, provenance} against a
SOURCE_CORPORA corpus; provenance defaults DEFAULT_PROVENANCE ("llm"), a sourceless node is
NOSOURCE. Each wikipedia reference emits a sibling wikipedia_provenance (WIKIPEDIA_PROVENANCE
registry); a present link defaults "sourced" (WIKIPEDIA_DEFAULT_PROVENANCE), not llm (a real
reference the viewer live-fetches). _provenance validates every grade; upgrading a source is a data edit.
Per-claim sources + the verify gate. The nodes carrying such a source: a
binding's sources[], a drug's nbn_sources[], a projection/circuit/group quote (KANDEL_QUOTES), a
receptor/target location/classification, region anatomy. corpus keys the source-agnostic
SOURCE_CORPORA registry ({ref, citation, url, pages_dir}, emitted as meta.source_corpora; the
full citation is resolved from there, not denormalized onto the ~429 bindings). _quote_sources /
_binding_sources validate corpus + grade (a verified source needs page + quote). verified is
earned by a two-step (LLM extract + LLM judge supports), then check_data.py's source-quote check
confirms the quote is really on the page (the backstop against a hallucination). Page files are
author-side (see CLAUDE.local.md), so the quote gate is skipped + warned on a clone that lacks them
(true for every corpus below). A binding with no quote source falls back to its Ki (verified), else
NOSOURCE. The NbN is simpler: apply_nbn_sources.py greps Stahl's verbatim
"Neuroscience-based Nomenclature: " line and confirms the dataset nbn is a substring (stronger
than a judge for this fixed field); a newer drug with no NbN line falls back to Stahl's Class line
under the same gate, marked nbn_nonstandard.
The corpora (SOURCE_CORPORA), each quote-gated author-side as above unless noted:
- #1 Stahl / #2 Kandel / #3 Stahl Essential / #4 Carlat / #6 Nieuwenhuys are
pages_dirbook corpora (page= a page number): drug bindings/NbN/class (Stahl), pathways + region anatomy (Kandel, Nieuwenhuys), receptor/target mechanism (Stahl Essential); seeCLAUDE.local.mdfor the trees. - #5 PDSP Ki (
pdsp_ki) has acsvpath, notpages_dir(measured Ki values, not paged text). A binding'ski.sourcecites a Ki id (a CSV row) instead of a page;check_data.pyconfirms the row exists with that value. A verified Ki backs_binding_grade. Refreshfetch_ki.py --apply. - #7 GtoPdb (
gtopdb,page= a GtoPdb targetId) is the source for receptor "Found in" regions. A confirm-only LLM judge maps a cached quote (by index) to each existing region (never adds/drops one);apply_location_sources.pywrites theverifiedsource intotools/generated_cache/location_sources.json. Each quote carries the assayspecies(Human/Rat/Mouse/Monkey, validated in_quote_sources); a non-human-only region shows an amber "· " tag. - #8 Allen AHBA (
allen_ahba,page= an HGNC gene) is the complement covering non-receptor targets + the receptors/regions GtoPdb misses.fetch_allen.pyturns the microarray PACall boolean across human donors into a deterministic present/absent (no judge);apply_location_sources.py --corpus allenwrites theverified,species: Humansource. The panel's species tag prefers Human (locationEntry), so an Allen confirmation clears the amber tag. Caveat: microarray = mRNA in cell bodies, so a transporter confirms at its source nucleus (SERT->raphe, NET->LC) and its terminal-region claims honestly stayllm. - #9 Wikipedia pharm (
wikipedia_pharm,page= the article slug) backs a binding Ki where PDSP (#5) has none (a fallback, never overriding a measured assay).fetch_wikipedia_pharmacology.pystores the whole English article author-side (pinned to a revision id) and mines its pharmacodynamics binding table; a Ki source'squoteis the verbatim table row, gated exactly like a book page. A tertiary source citing the primary literature: the grade attests quote-presence, the corpus label conveys tier.
Descriptions are not a node kind (not tallied). Drugs, structures and non-receptor targets carry
no baked description: their panel fetches the current Wikipedia lead (CC BY-SA) at runtime via
liveWikiDescription over js/wiki.js fetchWikiLead(url, lang) (locale lead, English fallback), so
the dataset ships no copyrighted prose (a panel whose live lead fails shows none). Receptors + projection
groups carry a short authored description as the offline fallback, overridden best-effort by the
live lead. Needs the connect-src https://*.wikipedia.org CSP allowance.
The wikipedia pill (green, viewer-only). A live-fetched lead renders a green ✓ pill (same
glyph as verified: both are inspectable non-LLM extracts), NOT the stored sourced/llm grade: a
live fetch is a verbatim programmatic read that cannot drift from the article. It is a presentation
(PROVENANCE_PILLS.wikipedia + info.provWikipedia, .src-prov-wikipedia shares the green), not a
stored grade and not tallied. Asymmetry: a baked Wikipedia snapshot stays yellow sourced (a
stored copy can drift); only the live read earns green. A present reference link carries no pill
(the description above already grades the same source, appendWiki(url)); a missing link shows NOSOURCE.
The "% sourced" figure. _provenance_stats reduces every node + reference to its strongest grade and
buckets it into verified (quote-checked) / sourced (from a document) / missing (no document).
A bare llm grade is missing (an LLM asserting from memory has no document), so it buckets with a
sourceless node (the viewer still shows them differently, grey ? vs orange NOSOURCE). It tallies per
node kind (drug bindings, NbN, drug class, projections, circuits, projection groups, receptor/target
classifications, receptor/target expression regions, region anatomy, wikipedia references), plus a
headline pct_backed over the knowledge nodes (references excluded, a reference points at a node).
The references kind stays in by_kind but the viewer does not render it as a coverage bar (a
reference is not a knowledge node, and every present link defaults to sourced, so the bar was uniformly
yellow noise); it is emitted as meta.provenance_stats (key nodes). Each expression region is its own node, individually
upgradeable. The live per-kind figures live in the README SOURCING_STATS block (auto-written by
update_readme_stats.py, CI runs it --check) and the Sources & provenance popup; they are not repeated
here, to avoid drift. check_data.py re-confirms the tally is self-consistent (coverage columns M/S/S+V).
Separately, meta.provenance_stats.ki_coverage tracks measured-affinity (PDSP Ki) coverage (bindings
with a Ki, drugs with none; combos excluded): NOT a grade (a quote-only binding is still sourced), but the
honest complement surfacing where a measured affinity was never looked up. Rendered in the sourcing popup +
README block, warned per-drug by check_data.py family 7.
The step-by-step runbook (per node-kind authoring fields, the regenerate/check/fetch
sequence, and the author-side "refresh external data" recipe) lives in
tools/README.md. The invariants that outlive any recipe: edit the
source of truth (generate_data.py, or tools/data/drugs_data.jsonl for drugs), never the
emitted public/data/; every new display string needs its FR/{en,fr} translation or
the build raises; commit the generator change + regenerated artifacts together; keep drug
extraction strictly dump-sourced. The legend rebuilds from the data at runtime.
The version is a single string in version.js (window.__APP_VERSION__), shown in the
panel header + the WIP banner (both read the global). Follow semver;
to release, bump version.js. It is intentionally not derived from git (the site deploys as
plain files).
- No JS build step or package manager: three.js is vendored same-origin under
public/vendor/threeand loaded via an import map inindex.html. Keep the import-map entries pointing at the vendored files; bump the vendored copy as a unit. generate_data.pyis stdlib-only so it runs offline.- Don't duplicate the anatomy or its presentation maps: positions/colors/shape params, the
kind -> colourandgroup -> headingmaps live only ingenerate_data.py(the latter emitted intometa.json, read by the viewer). - Structure granularity is demand-driven. The modeled brain sits at a deliberately uneven granularity: fine where the data forces it (the monoamine source nuclei; the brainstem cut into midbrain/pons/medulla because the pathways name the pons), coarse where nothing yet forces it (each lobe is one piece, the thalamus one nucleus). Cut a region into finer sub-structures only when the receptor/projection/drug data distinguishes its sub-parts AND can source that distinction, or the LLM-assisted dataset is pushed to invent anatomy it cannot source. The frontal-lobe -> prefrontal-cortex split is the next cut this would justify.