npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

react-cheminfo

v0.24.0

Published

React components, hooks and framework-free helpers shared by the cheminfo sites: site chrome, citations, sharing, routing, pedagogy, charts, and the structure, orbital and 3D molecule viewers

Downloads

2,033

Readme

react-cheminfo

NPM version build status Test coverage npm download

Assembled React components shared by the *.cheminfo.org sites.

What it is

react-science gives us primitives — Toolbar, Accordion, SplitPane, Button. This package is the layer above: assemblies, a component you hand data to and it delivers a whole feature, its copy, its interactions and its logic included. A site normally depends on both.

Installation

npm i react-cheminfo

react and react-dom (19 or later), @blueprintjs/core and react-science are peer dependencies: a site already has them, and a second copy of react-science would mean two Blueprint style trees in one page.

Entry points

Subpaths are bundle boundaries, not taxonomy: a feature gets its own door only when it drags a heavy dependency behind it.

| Import | Holds | Costs | | ---------------------------------- | ------------------------------------------------- | ------------------------------------------------ | | react-cheminfo/core | every framework-free helper — 140 value exports | nothing | | react-cheminfo/ui | every React component and hook — 60 value exports | React | | react-cheminfo/vite | the prerender plugin and the OG card | nothing; React on the card | | react-cheminfo/orbital | the 3D atomic-orbital viewer | React, molstar | | react-cheminfo/molecule3d | the 3D molecule viewer and its toolbar | React, molstar | | react-cheminfo/structure | the structure editor and renderer | React, react-ocl, OCL | | react-cheminfo/conformers | conformer generation, ranking and refinement | OCL | | react-cheminfo/slides | the deck format and the slideshow player | React, react-markdown | | react-cheminfo/spectra | the spectra processing settings editors | React; types only from spectra-processor, ml-pca | | react-cheminfo/xtb | GFN2-xTB as a geometry relaxer | nothing on import; xtb-wasm on the first call | | react-cheminfo/styles/chrome.css | the shared tokens and site-header stylesheet | nothing | | react-cheminfo/styles/slides.css | the deck stylesheet, themed on the site's colours | nothing |

A backend serving an RIS endpoint, a prerender script writing a sitemap, and every unit test of that logic therefore load no React at all — and a worker sampling an orbital loads neither React nor molstar. Every peer is optional, so a site that only wants the Tools menu downloads none of them, and a project that only prerenders installs neither React nor Vite. The Markdown stack of the slides (react-markdown, rehype-raw, rehype-sanitize, about 9 MB installed) is a regular dependency instead: it is small enough to always install, and a site that never imports react-cheminfo/slides bundles none of it. The same holds for openchemlib, which every function of react-cheminfo/conformers runs at module scope: a site that never imports that door bundles none of it.

react-cheminfo/xtb is the door a page opens only when a visitor asks for a better number. refineConformers in react-cheminfo/conformers orchestrates a refinement against the framework-free GeometryRelaxer contract — which react-cheminfo/core owns, so the code choosing a relaxer loads neither openchemlib nor WebAssembly; xtbRelaxer from this subpath is the implementation, and it import()s xtb-wasm on the first call, so a site that offers refinement as an action still ships nothing extra to a visitor who never clicks it.

react-cheminfo/vite is the one door with a split bill: cheminfoPrerender needs nothing beyond Vite's own plugin type, while ogCardHtml renders a mark and therefore loads React — but on the call, not on the import, so the plugin stays reachable from a checkout that has neither.

The stylesheet ships from styles/, which tsc leaves alone, and is reached through a wildcard subpath exactly as react-science serves its own.

What is in it

| Area | …/core | …/ui | | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Site identity | siteById, findSiteByHost, siteTokensCss | SiteMark, Wordmark, SiteTheme, SiteTile, EcosystemButton, EcosystemLinks | | Chrome | — | SiteHeader, SiteFooter, NavLink, NavMenuButton, MenuButton, useCompactHeader | | Citation | formatCitation, formatCitations, citationSegments, downloadCitation, citedReferences, doiUrl | CiteButton, CitationMenu, CitationPreview | | Share & embed | parseShareConfig, applyShareConfig, buildShareUrl, buildEmbedCode, isHidden, visibleShareParts, applySharePreset, findSharePreset, the param codecs, syncPreferencesWithUrl, applyPreferencesFromSearch, writePreferencesToSearch | ShareDialog, ShareButton, HiddenPartsProvider, PagePart, useIsHidden | | Routing & head | createTabRouter, readRoute, writeRoute, subscribeToRoute, adoptLegacyHashAddress, writeDocumentMeta | useTabRoute | | Indexing | injectPageMeta, pageHeadTags, pageDocumentMeta, fill, PAGE_HEAD_MARKER, PAGE_BODY_MARKER, robotsTxt, sitemapXml, noscriptIndex, structuredDataScript, assertRoutes, routeFor, homeRoute | cheminfoPrerender, ogCardHtml, OG_WIDTH, OG_HEIGHT (all /vite) | | Pedagogy | parseGlossaryMarkers, localStorageProgressStore, progressSummary, finishValidation | GlossaryText, SyntaxTooltip, HintLadder, ExerciseActions, ExerciseProgressHeader, TutorialStepStrip, ReferenceGrid, TestCaseList | | Clipboard & files | writeToClipboard, downloadBlob, downloadText, sanitizeFileName, toDelimited, readDelimited, downloadFigure, figureSvg, figurePng, figureSize | ClickToCopy, CopyButton, CopyableValue, CodeBlock, DelimitedTextDialog, FigureDownload | | Formatting & colour | formatInteger, formatDecimal, formatBytes, pluralize, readableInk, contrastRatio, COLOR_SCALES, resolveColorScale, formatColorScale, colorAt, swatchAt, sampleScale, colorScaleGradient, rgbToHsv | ColorScaleLegend, ColorScaleSelect, ColorScaleEditor, ColorScaleBar | | Widgets | CREDITS, credits | ErrorBoundary, CollapsibleSection, CapsuleFilter, HelpTooltip, CreditsList | | Hooks & state | createWorkerChannel, persistBucket, persistSignalBucket | useDebouncedValue, useContainerSize, useListKeyboardNavigation, useDisclosure | | Chemistry | atomicOrbitalsOf, configurationOf, classifyMolfile, readStructure, toMolfileExport | AtomicOrbitalViewer (/orbital), StructureEditor, Structure (/structure) | | Conformers | generateConformers, continueConformers, refineConformers, conformerShape, isSameMinimum, rankByEnergy, minimiseConformer, DEFAULT_CONFORMER_OPTIONS, CONFORMER_STRATEGIES (all /conformers); GeometryRelaxer, readRelaxableGeometry, centredRmsd | — | | Spectra | FILTER_CATALOG, filterMenu, settingsProblems, normalizationFilters, addFilter, moveFilter, setFilterOption, principalComponentChoices, clampPrincipalComponents, EMPTY_SETTINGS (all /spectra) | SpectraSettingsEditor, FilterChainEditor, PrincipalComponentSelect (all /spectra) | | About | resolveAbout, aboutProblems | AboutPage | | Which build is running | formatBuiltAt, shortCommit, BuildInfo | AboutBuild, cheminfoBuildInfo (/vite) | | Slideshows | parseTalk, splitDemoLinks, slideActionForKey, parseTalkOrigin, buildTalkManifest (all /slides) | Slideshow, SlideView, TalkList, BackToSlides (all /slides), cheminfoTalks (/vite) | | Token guard | findTokenViolations | cheminfo-check-tokens (the bin) | | Figures | chartScale, chartAxisScale, chartAxisTitle, chartShare, chartColumnExtent, chartBinCounts, chartSeriesColor, rowMatrix, stackedMatrix, emptiestCorner, placeOverlayCard, overlayMetrics | ChartFrame, ChartAxis, TrackedLineChart, OverlayBar, OverlaySelect, OverlaySegmented, OverlayToggle, OverlayNumber, OverlayLegend, OverlayCaption, OverlayReadout | | Projections | pcaResult, embeddingResult, projectionTabs, loadingProfiles, explainedShares, confidenceEllipse, projectEllipse, pointsInPolygon, resolveProjectionGroups, resolveProjectionShapes, PROJECTION_COPY | PcaViewer, ProjectionViewer, ScatterPlot, ScatterMatrix | | Parallel coordinates | parallelAxisOf, parallelAxisLayouts, parallelIncludedMask, parallelKeptCount, parallelNearestRow, parallelSegmentAt, parallelBandAt, parallelRangeOf, parallelPalette, parallelColorSteps, paintParallelLines | ParallelCoordinates | | Periodic table | PERIODIC_ELEMENTS, elementBySymbol, elementByAtomicNumber, cellOf, placedElements, elementByArrowKey, categorySwatch, CATEGORY_LABELS | PeriodicTable, ElementCell, CategoryLegend |

Everything in that table is exported from ./core, ./ui, ./slides, ./vite or ./structure and nothing else is: the sub-components a component is built from, the parsers a helper calls and the internals of a hook stay inside the package, reachable only by their own path. If it is exported, it is supported.

The rule for a new site is short: react-cheminfo first, react-science second, your own code last. The full import table and the checklist live in websites/CLAUDE.md.

Seeing it

Storybook is the demo, and every exported component has one — 287 stories:

npm run dev              # the book on http://localhost:10815
npm run test-e2e         # Playwright opens all 287 and fails on any console error

The Brand toolbar at the top retunes --brand / --brand-alt, so any story can be read as it would look on any site of the family.

The sources are organised the other way round — one folder per feature (src/citation, src/ecosystem, src/share, src/seo, src/pedagogy, …), each holding a core/ and a ui/ half — and src/core.ts, src/ui.ts, src/vite.ts, src/structure.ts and src/orbital.ts are the barrels the entry points point at. ESLint forbids a core/ folder from importing react, react-dom or anything under a ui/, which is what keeps the framework-free entry points honest; src/seo/vite is the one half named for its consumer rather than for a framework, because a build plugin is neither. src/shared holds what more than one of them is built on: MenuButton, the shape every button of a site header takes, which is why CiteButton and EcosystemButton differ only in their glyph and their menu, and why both accept the compact and placement props of the exported HeaderButtonProps.

Components

CiteButton

The Cite entry of a site header: one button opening the work at its DOI, copying its reference in the style a journal asks for, and saving the files a reference manager imports.

import { CiteButton } from 'react-cheminfo/ui';

<CiteButton reference={PAPER} />;
  • HTML and Markdown, each in the four styles chemists are asked for — ACS, Nature, RSC and Wiley. An HTML copy is written to the clipboard in both flavours, so Word and Google Docs keep the emphasis while a plain editor receives a clean line; that is why plain text is not a separate entry.
  • BibTeX, RIS and the DOI link, which carry no style.
  • RIS and BibTeX files, served with the MIME types Zotero, Mendeley and EndNote recognise, so opening the saved file imports it.
  • A hover preview of exactly what each entry copies or saves.
  • compact drops the text and the caret, leaving the icon alone to open the menu, for a header that has run out of room. The label stays what the pointer and a screen reader are told.

Adding a style means one function in citation/core/segments.ts and one entry in CITATION_STYLES — the three output formats and the preview follow.

A site is often built on more than one work — the tool it wraps, and the platform it runs on — and a reader handed two references has to be told what is what. Pass works rather than reference, each one carrying the words that say what citing it credits:

<CiteButton
  works={[
    {
      reference: SURGE_PAPER,
      what: 'The isomer generator',
      note: 'Cite it for the enumeration: every structure here comes from surge.',
    },
    {
      reference: PLATFORM_PAPER,
      what: 'The browser platform',
      note: 'Cite it for the site itself.',
    },
  ]}
/>

The menu then opens on a line asking for all of them — guidance writes that line when the default does not fit — and lists every work with what it covers. Each work holds its own article and its own reference, in the default style; the sections below carry the whole set at once, so one copy pastes both citations and one saved file holds both records (references.ris). Nothing changes for a site asking for a single work.

The two works the whole family shares are held here rather than copied into each site, so none of them can name a different version of the same paper — import them from react-cheminfo/core and put the site's own work first:

| Export | The work | | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | PLATFORM_PAPER / PLATFORM_WORK | Chemical data processed in the browser, which is what makes these applications rather than queues of jobs. Every site. | | TEACHING_PAPER / TEACHING_WORK | Two decades of online teaching, which is what the tutorials and the exercises are. The sites that teach. |

import { PLATFORM_WORK, TEACHING_WORK } from 'react-cheminfo/core';

<CiteButton works={[OWN_WORK, TEACHING_WORK, PLATFORM_WORK]} />;

EcosystemButton

The Tools entry of a site header: one button opening every other site of the family, each behind its own little logo and the two colours it owns.

import { EcosystemButton } from 'react-cheminfo/ui';

<EcosystemButton currentSiteId="vcl" />;
  • currentSiteId is the one thing that differs per site: that tile is shown with a you are here label and is not a link. Passing nothing links them all.
  • compact drops the text and the caret, leaving the icon alone to open the menu, for a header that has run out of room. The label stays what the pointer and a screen reader are told.
  • A tile lights up in the colour of the site it opens, so running the pointer down the grid is what makes the pairs of colours read.

AtomicOrbitalViewer

One hydrogen-like atomic orbital, screened by Slater's rules, sampled in the browser and drawn as a signed isosurface with molstar.

import { AtomicOrbitalViewer } from 'react-cheminfo/orbital';

<AtomicOrbitalViewer atomicNumber={26} orbitalId="3dz2" />;
  • The maths is exact, not a cartoon: a hydrogen-like radial function with its n − ℓ − 1 nodes, times a real spherical harmonic with its ℓ angular ones. The Slater orbital a hybridisation model uses is nodeless and would draw a 3s exactly like a 1s, only fatter.
  • The canvas is behind a React.lazy boundary and nothing this entry point exports pulls molstar in statically, so a page that never draws an orbital never downloads it.
  • A WebGL probe runs before molstar is touched (probeViewerCapability), so a locked-down school machine gets a sentence rather than a blank rectangle.
  • Every orbital is drawn at one canonical size. Molstar's camera clamps its near plane, so uranium's 4f — which reaches 0.35 Å — would otherwise stay a dot in the corner; the true extent is reported as ⟨r⟩ instead.
  • The isovalue is a weighted quantile of the samples (isocontourCutoff), never molstar's computeOrbitalIsocontourValues: that one abandons any field whose mean ψ² falls under an absolute 1e-5, which reads the orbital's size rather than its shape, and left 2588 of 7460 orbitals blank. A quantile has no scale of its own.
  • The isosurface is extracted on the CPU (tryUseGpu: false). Molstar's GPU marching cubes quantises the field to 255 steps on upload, which terraces xenon's 4p, and pits a diffuse outer lobe with voxel-sized dimples. The surface is a thin shell whatever the box holds, so the CPU path costs 13–44 ms even at 152³.
  • resolution takes a { floor, cap } pair as well as a number, and then each orbital picks its own: one resolution for a whole table leaves xenon's innermost 4p lobe spanning 6.6 voxels while the outer one spans 41. A nodeless orbital stays on the floor and costs nothing extra.
  • sample accepts a worker-backed sampler when a site would rather not spend ~25 ms of its main thread per orbital. runAtomicSample from react-cheminfo/core is the function that worker calls, and it imports neither React nor molstar.

The maths is exported on its own from react-cheminfo/core — atomicOrbitalsOf, configurationOf, slaterScreening, radialProfile, radialNodeRadii, sampleAtomicOrbital, orbitalContour — so a site can draw its own radial plot, list an element's orbitals, or print a screened charge without mounting anything.

Each mark keeps the geometry of that site's own logo where it has one, redrawn on a plate of the site's own colour so every mark of the family still reads as one row, and carrying the site's answering colour on exactly one element — which is what stops it collapsing into a flat shape at 16 px.

NMRium keeps its artwork whole: the symbol is the one from Logo.tsx of the NMRium sources, in its orange, on the plum its logo sets the rest of the wordmark in. ChemCalc is the one mark that is ours — its logo is an atom that is unreadable small, so the tile draws what the tool returns, an isotopic pattern, in ChemCalc's own indigo and teal.

The colours a name is set in are the site's own and are not retuned to reach the 4.5:1 of body text: ChemCalc's teal and NMRium's orange both land just under it, which is why a name is set bold, where 3:1 is the threshold.

ColorScaleSelect

The colours a quantity is read with, chosen by looking at them rather than by reading their names — and, behind the last entry, built from anchors of the reader's own.

import { COLOR_SCALES, resolveColorScale, swatchAt } from 'react-cheminfo/core';
import { ColorScaleSelect } from 'react-cheminfo/ui';

<ColorScaleSelect label="Colour scale" value={scale} onChange={setScale} />;

const { scale } = resolveColorScale(text); // 'plasma', or 'hsv-long,0-0000ff,1-ff0000'
swatchAt(scale, 0.5); // { background, foreground }
  • The choice is one string, which is what goes in the state and in the address: the id of one of COLOR_SCALES, or a scale spelled out. Nothing else has to be carried, and resolveColorScale never throws — a scale renamed since a course page was written falls back to viridis rather than blanking the figure.
  • Ten scales are offered: viridis, plasma, magma, inferno, cividis and turbo; greys for a photocopier; cool-warm and blue-red for a quantity read away from a middle; and the rainbow, which orders nothing but is what a course usually draws.
  • A custom scale is anchors plus a path. rgb mixes the three channels, which is what a browser gradient does; hsv turns along the colour wheel the short way, keeping the saturation instead of fading through the grey in the middle; hsv-long turns the other way, which is how two anchors of one hue draw a whole rainbow. ColorScaleEditor is that editor on its own, for a site that wants it inline.
  • A scale that turns is sampled, never handed to CSS as its two ends — a browser only interpolates the straight line between two colours. colorScaleGradient and ColorScaleBar do the sampling.

SpectraSettingsEditor

Everything spectra-processor can be told, laid out in the order it happens — and the principal components a score plot is then drawn against.

import { EMPTY_SETTINGS, settingsProblems } from 'react-cheminfo/core';
import { SpectraSettingsEditor } from 'react-cheminfo/ui';

<SpectraSettingsEditor
  value={settings}
  onChange={setSettings}
  spectrumIds={processor.getIDs()}
  principalComponents={{
    selection,
    onSelectionChange: setSelection, // { x: 0, y: 1 } — columns of the score matrix
    count: scores.columns,
    explainedVariance: pca.getExplainedVariance(),
    settings: pcaOptions,
    onSettingsChange: setPcaOptions,
  }}
/>;

processor.setNormalization(settings.processor.normalization);
processor.getPostProcessedData(settings.postProcessing);
  • The types come from the packages that own them, not from a copy. src/spectra/core/settings.ts is the only file naming spectra-processor or ml-signal-processing, and it reads the shapes off their public surface — ConstructorParameters<typeof SpectraProcessor>[0], FilterXYType. Both are optional peers and every import is import type, so a site that does not use this component downloads neither.
  • All 23 chain steps are offered, grouped as a chain is built — baseline, smoothing and derivatives, scaling, the two axes, housekeeping — with every option each one takes, down to the nested peak picking of calibrateX. The catalog is a Record keyed by the step name, so the day ml-signal-processing adds a filter this package stops compiling instead of quietly not offering it.
  • The order is the meaning, so it is editable and it is checked. A step moves with two arrow buttons — no drag dependency, and it works from the keyboard. Scaling placed before the baseline it depends on, a second resampling, a crop that leaves the spectra on grids that no longer line up: each is named where it sits.
  • Nothing blocks. A reader mid-thought may hold settings the processor would throw on. settingsProblems says which value it would throw on — separating what fails from what merely looks unintended — and the editor still takes the edit.
  • A cleared box means "whatever upstream does", never undefined written into the settings, which is the only way back to a default once it has been typed over. A half-typed 1e- is kept as typed rather than parsed away.
  • The components are named PC1 and handed back as 0. The label is what a chemist writes; the number is the column of the score matrix, because that is what indexes it. clampPrincipalComponents keeps a selection saved on other data inside the components that exist, and keeps the two axes apart so a plot never collapses onto its diagonal. FilterChainEditor and PrincipalComponentSelect are each usable on their own.

PcaViewer

A finished principal component analysis, read by somebody who has never met one.

import { PCA } from 'ml-pca';
import { PcaViewer } from 'react-cheminfo/ui';

const pca = new PCA(rows, { scale: true });

<PcaViewer
  pca={pca}
  rows={rows}
  scaled
  projected={newBatch} // placed into the finished model, drawn hollow
  variables={{
    kind: 'named',
    names: ['Sepal length', 'Sepal width', 'Petal length', 'Petal width'],
    unit: 'cm',
  }}
  samples={{
    ids,
    labels: names, // what the card and the map write; `ids` stay the keys
    groupings: [
      { id: 'species', label: 'Species', groups: species }, // colours the dots
      { id: 'cluster', label: 'Cluster', groups: clusters }, // shapes them
    ],
    fields: (index) => [
      { label: 'Sepal length', value: `${rows[index][0]} cm` },
    ],
  }}
  onSelectionChange={(ids) => setPicked(ids)}
  onTrackVariable={(track) => setHovered(track?.readout ?? null)}
/>;
  • Four tabs, and only the ones the data can fill. projectionTabs reads the result rather than a prop: a principal component analysis publishes a share of the variance and a set of weights, so it gets the map, every pair, what differs and how much each explains. A UMAP embedding publishes neither, so it gets the map alone — one tab and no tab strip, instead of three empty ones.
  • It never imports ml-pca. PcaLike is a structural type — predict, getExplainedVariance, getEigenvalues, getLoadings — which ml-pca's PCA satisfies as it stands, with no adapter and no cast. The package has no runtime dependency on it, and embeddingResult is the same door for coordinates from anywhere else.
  • An outline says what share of a group it holds, not how many standard deviations it is. In two dimensions those are not the numbers anybody remembers: an ellipse at one standard deviation holds about 39% of the points, not 68%. The picker therefore offers 50% of samples … 99% of samples, and the legend writes the same words, so the figure cannot promise a coverage it does not have.
  • The outline is projected, not rotated. The two axes almost never carry the same units per pixel, so an ellipse turned by an angle in the data is not turned by that angle on the screen. Handing an SVG <ellipse> the data-space angle draws every tilted group slightly wrong and never says so.
  • A sample the model was built from is filled; one placed into it afterwards is hollow. pcaResult(pca, { rows, projected }) appends the second kind and records where they start, because the two are not equally trustworthy: a fitted sample helped choose where the axes point and is bound to land somewhere reasonable, while a projected one can land anywhere — and landing far out is the finding, not a fault.
  • Two groupings of the same samples, on one picture. samples.groupings is a list: the first colours the dots and the second shapes them, and the reader can give either to any grouping from the bar and the cog. A grouping of more groups than there are shapes is refused as a whole rather than drawn with two groups under one mark, and the hover card names every grouping. It is what a clustering is read with: the clusters it found in colour, the classes you gave as shapes.
  • Colour means one thing per tab and the legend says which. Groups on the map and the pair grid, components on the other two, drawn from two orders of the Okabe–Ito palette that are disjoint over their first four, so blue-is-setosa on one tab is never blue-is-PC1 on the next. OverlayLegend has no default title: a legend that does not name its encoding is how a reader carries the wrong meaning from one tab to the next.
  • The direction of a component is arbitrary, so it is never a colour. Sign is carried by geometry — above or below the zero line — and each component is flipped so its strongest measurement comes out positive, because otherwise refitting on one more sample mirrors the whole map and reads as a fault.
  • Selecting is a drag. Plain drag replaces the selection, shift adds, alt removes, and the caption says which while the pointer is still down. onSelectionChange hands back sample ids, so a caller never keeps a second index. Pointer capture, cancellation and lost capture all funnel into one path, which is what stops a lasso being left half-drawn.

ProjectionViewer and the floating options

PcaViewer is a thin adapter over ProjectionViewer, which draws any dimension-reduction result — a k-means run with its centroids, a UMAP embedding, coordinates read from a file — from one ProjectionResult. The pieces below it are meant to be built on:

| Domain | Holds | | ---------------- | ---------------------------------------------------------------------------------------------------- | | src/chart | MatrixLike read where it stands, linear scales, round ticks, the SVG frame, the tracked line chart | | src/overlay | The card of controls that floats over a figure, and the caption, legend and readout that explain one | | src/scatter | Lasso selection, hover, confidence ellipses, the plot and the pair grid | | src/projection | The result model, the two adapters, and the viewer |

The overlay domain is the part most likely to turn up elsewhere. A card rests at three quarters strength and wakes when the pointer reaches the figure or the keyboard reaches one of its controls; only its ground fades, never its text, because a label at three quarters over a busy scatter is unreadable exactly when it is wanted. One or two controls stay in the strip and everything an expert changes lives behind a cog — concealment is the default, and a control is visible only because somebody put it there deliberately. Below about 420 pixels the whole bar folds into that cog, and the tab order is the same folded or open.

<OverlayLayer width={width} density="compact">
  <OverlayBar placement={emptiestCorner(points)} more={<Expert />}>
    <OverlaySelect caption="Across" value={x} options={axes} onChange={setX} />
  </OverlayBar>
</OverlayLayer>

Saving a figure

Every projection viewer carries a save glyph in its bar: a reader picks PNG or SVG, picks a resolution, and gets the figure as a file. The panel writes out the pixels each resolution would produce, and fileName names what arrives — the view is appended, so fileName="ecstasy" saves ecstasy-map.png.

The control behind it is FigureDownload, and it is not tied to the viewer: it takes the id of the box a figure sits in, so it can stand in a bar, a toolbar or a menu without the component that drew the figure handing anything over.

<FigureDownload targetId="pca-figure" fileName="ecstasy-map" />
<div id="pca-figure">
  <PcaViewer pca={pca} rows={rows} samples={samples} />
</div>

Three things it does that a screenshot does not. Everything inside the box is saved, so a view that is really sixteen charts arrives as one picture with each placed back where the reader saw it. The tokens the figure is drawn from are resolved first — a var(--border) that left the site would draw a chart with no axes. And the chrome floating over the picture is left behind: an OverlayLayer marks itself, so the cog in the corner never lands in the middle of the scatter.

downloadFigure(target, options), figureSvg and figurePng are the same thing without the button, for a site saving a figure from its own menu.

ParallelCoordinates

Many rows against many properties at once: one vertical axis per column, one line per row, and a brush on every axis.

import { ParallelCoordinates } from 'react-cheminfo/ui';
import { parallelAxisOf, parallelIncludedMask } from 'react-cheminfo/core';

const axes = useMemo(
  () => [
    parallelAxisOf(rows, {
      id: 'mw',
      label: 'MW',
      unit: 'g/mol',
      value: (r) => r.weight,
    }),
    parallelAxisOf(rows, { id: 'clogp', label: 'cLogP', value: (r) => r.logP }),
    parallelAxisOf(rows, {
      id: 'mutagenic',
      label: 'Mutagenic',
      domain: [0, 2],
      ticks: [
        { value: 0, label: 'none' },
        { value: 1, label: 'low' },
        { value: 2, label: 'high' },
      ],
      value: (r) => r.mutagenic,
    }),
  ],
  [rows],
);
const included = useMemo(
  () => parallelIncludedMask(axes, ranges, rows.length),
  [axes, ranges, rows.length],
);

<ParallelCoordinates
  axes={axes}
  width={width}
  height={340}
  colorAxis="clogp"
  ranges={ranges}
  onRangeChange={setRange}
  included={included}
  hovered={hovered}
  onHoverChange={setHovered}
  selected={picked}
  onRowClick={pick}
  renderTooltip={({ index }) => <MF mf={rows[index].mf} />}
/>;
<MoleculeTable included={included} />;
  • It is told nothing about what the rows are. Every axis hands over one ArrayLike<number> read in place, so a repaint costs an indexed read rather than a call per value, and the same figure draws a library of molecules, a set of runs and a table of measurements. parallelAxisOf is the one place a row type is named, and it runs once per data change.
  • The table beside it filters on the same answer. parallelIncludedMask is exported and is what the figure itself uses: a plot drawing one line under a table showing fourteen rows is not a rounding error, it is two programs.
  • A brush reports on release, never during the drag. The band follows the pointer live — that costs nothing, it is one component's own state — while the interval reaches the caller once per gesture, so a table filtering on it is asked to re-render once rather than sixty times a second. onRangePreview is there for a caption, and for nothing else.
  • The brush is arithmetic, not a library. Create by dragging on a bare axis, move the band, drag either edge, clear by pressing outside it: four answers to one question, which part of the axis the press landed on. Written that way the whole gesture is unit-tested in a runtime with no DOM, which is what a brush attached to an element can never be — and it keeps 15.8 kB of d3-brush out of every site that draws one.
  • The axes end exactly on the data. No nicing, because the reader is comparing rows against each other and the best one has to touch the top of its axis rather than float below a round number nothing reached. nice turns it on per axis when round numbers really are the point, and a column with no spread at all is opened by half a unit so that it still draws.
  • A coded quantity writes its own graduations. domain: [0, 2] with ticks reading none, low, high is what a risk column needs; 2 on an axis means nothing to anybody.
  • The lines are a canvas, everything else is not. Ten thousand polylines are not ten thousand elements, so the mass is painted — the rows a brush left out in one batched path underneath, the kept ones over them — while the axes, the bands, the names and the card stay SVG and HTML. The consequence is worth knowing before choosing it: FigureDownload copies SVG off the page, so a saved figure keeps the axes and loses the lines.
  • A canvas cannot read a custom property, and the accent is the one colour a site owns, so the figure asks the browser what its own --accent, --text, --text-muted and --surface resolved to and paints with the answer. ink overrides any of them.
  • The card is a render prop, because what belongs in it is the caller's subject — a formula through react-mf, a structure, a name — and a readout of strings cannot hold any of that.

Carrying talks

A deck is one Markdown file in the repository of the site whose subject it teaches — front matter, slides separated by a --- line, and a <!-- layout: … --> comment where the default is not what is wanted. The format and the player are react-cheminfo/slides; the addresses and the authoring rules are in rules/slideshows.md.

import { parseTalk, Slideshow } from 'react-cheminfo/slides';
import 'react-cheminfo/styles/slides.css';

const talk = parseTalk(source);

<Slideshow
  talk={talk}
  talkId="20260824_IMSC"
  index={slide}
  onIndex={(next) => navigate(`/talks/${id}?slide=${next}`)}
  layouts={{ octochemdb: OctoChemDbFlow }}
/>;

The index is controlled: the site owns the address, so a link can open one slide and a demo link can come back to it. Arrow keys, space and PageUp/Down navigate, Home and End jump, f is fullscreen, b and w blank the screen, n shows the speaker's notes. A layout name the player does not know falls back to content rather than breaking a deck in front of a room, and a site adds its own through layouts.

The embed layout puts a live tool on the slide — the last bare URL of the body is framed — which is what our ?embed mode exists for.

The format itself is framework-free and is also exported from react-cheminfo/core, so a backend gathering the family's manifests reads them without loading React.

A site that fullscreens its own shell — so a slide can open the live tool and come back still presenting — passes fullscreen={{ isFullscreen, onToggle }}, and the f key and the bar's button drive that instead of the player's own element.

Publishing a site's decks to the family is one plugin:

import { cheminfoTalks } from 'react-cheminfo/vite';

plugins: [cheminfoTalks({ site: 'chemcalc' })];

It writes dist/talks.json and dist/talks/<id>.md, which is what learn.cheminfo.org reads to list and play every site's talks beside its own.

Prerendering a site

A static site has no server to rewrite a head per request, so a crawler gets whatever came off the wire. cheminfoPrerender writes one real HTML file per routed address at build time, each with its own title, description and canonical, plus sitemap.xml, robots.txt, the JSON-LD block and the noscript crawl path. Without it every address carries the same head and a search engine folds the whole site into one result.

The template says where the head goes

index.html is the template, and it carries no title and no description of its own: it declares where they go, with two comments.

<head>
  <meta charset="utf-8" />
  <link rel="icon" href="%BASE_URL%favicon.svg" />
  <!--cheminfo:head-->
</head>
<body>
  <div id="root"></div>
  <!--cheminfo:body-->
</body>

That is the whole contract, and it is what makes the rest of this small: the head is written, never found and operated on. Nothing is parsed and nothing is searched for but the marker, so a byte order mark, an implicit head, a </head> the page's own prose displays or a bundled script quotes in a string, and a <title> inside an <svg> all leave the result exactly as it is — and no duplicate title or description is possible, because the template carries none. A page missing <!--cheminfo:head--> throws rather than shipping headless.

<!--cheminfo:body--> is where the noscript crawl path goes, and a site running with noscript: false needs no such marker.

It is a Vite plugin, and the whole of a live caller — 3d.cheminfo.org — is:

// vite.config.ts
import react from '@vitejs/plugin-react';
import { cheminfoPrerender } from 'react-cheminfo/vite';
import { defineConfig } from 'vite';

import { PAGE_ROUTES } from './src/seo/routes.ts';

export default defineConfig({
  plugins: [
    react(),
    cheminfoPrerender({
      site: '3d',
      routes: PAGE_ROUTES,
      operatingSystem: 'Any browser with WebGL2',
    }),
  ],
});

routes is one RouteMeta[] the site owns and three things read: this plugin, injectPageMeta on a server that has one, and writeDocumentMeta in the running app after an in-app move. A page missing from it is a page a search engine only ever sees as the home page.

npm run dev fills the same two markers from the home route, so a developer never opens a page showing its markers and dev reads like production.

The mount lives in origin, and nothing else

origin is where the site is served, mount path included — one value carrying both, because that is exactly what a canonical link, an og:url and a sitemap entry need. Everything else is read back off it: robots.txt writes its Allow and Disallow under the mount, the noscript index links under the mount, and every absolute address is built on it. There is no second option to keep in step.

cheminfoPrerender({ site: 'surge', routes: PAGE_ROUTES });
// origin defaults to https://surge.cheminfo.org — the site owns its host,
// the mount is '', and /exercises is written at dist/exercises/index.html.

cheminfoPrerender({
  site: 'surge',
  routes: PAGE_ROUTES,
  origin: 'https://learn.cheminfo.org/surge',
});
// the same build, mounted under /surge: canonicals and the sitemap read
// https://learn.cheminfo.org/surge/exercises, and robots.txt allows /surge/.

The files on disk are laid out from the build's own root either way — dist/exercises/index.html, never dist/surge/exercises/index.html. It is the server that puts them under the mount. An origin must be an absolute http or https address or it is refused: one written localhost:3000 parses with localhost: as its scheme, which would read the mount back as /3000.

robots: false skips robots.txt for a site shipping its own, category: false skips the JSON-LD block, and noscript: false leaves the site without its only crawl path — so leave it on.

The card a link unfurls into

ogCardHtml draws public/og.png from the site's own record — its mark, its two colours, its name — so the card never drifts from the mark the site shows. It returns a page for a headless browser to screenshot, and it is the one export of this entry point that loads React, on the call rather than on the import:

// scripts/generateOgImage.ts, run with `npm run og-image`
import { chromium } from '@playwright/test';
import { OG_HEIGHT, OG_WIDTH, ogCardHtml } from 'react-cheminfo/vite';

const browser = await chromium.launch();
const page = await browser.newPage({
  viewport: { width: OG_WIDTH, height: OG_HEIGHT },
});
await page.setContent(await ogCardHtml({ site: '3d' }), { waitUntil: 'load' });
const png = await page.screenshot({ type: 'png' });
await browser.close();

On a site that does have a server

The same head is written per request instead: injectPageMeta(index, { site, routes, origin, url }), next to the tracking-script injection, on /, /index.html and the SPA fallback alike — the served page is the same template, with the same two markers. robotsTxt and sitemapXml are the same two calls, answered as routes rather than written to disk. A server writing more into the same place composes it itself, with pageHeadTags and fill(page, PAGE_BODY_MARKER, noscriptIndex(…)). All of that is react-cheminfo/core, so a backend loads no React and no Vite.

Saying which build is running

A report of something going wrong is worth answering only when we know what was running, and a hand-written version in a source file is wrong by the next release. So the build states it: cheminfoBuildInfo resolves the release, the instant and the commit once, and the About page reads them.

// vite.config.ts
import { cheminfoBuildInfo } from 'react-cheminfo/vite';

plugins: [react(), cheminfoBuildInfo(), cheminfoPrerender({ ... })];
// src/about.ts
import { BUILD_INFO } from 'react-cheminfo/build-info';

export const ABOUT: AboutContent = { siteId: 'smiles', build: BUILD_INFO, ... };

react-cheminfo/build-info is a real module that exports undefined, and the plugin fills it in during the build. So a Playwright spec or a unit test that imports the same About record in plain Node still resolves it — a virtual specifier does not, and the whole suite dies on the import — and a build without the plugin simply shows no build line.

Where each value comes from:

  • version — the package.json at the repository root, which is the one release-please bumps. A site whose tool lives in frontend still reports the root version rather than the 0.0.0 that workspace was scaffolded with.

  • builtAt — the instant the build ran, to the second, honouring SOURCE_DATE_EPOCH.

  • commit — read out of .git as files, never by running git: the image build has no git binary and the shared docker-image workflow passes no build argument. A site therefore lets three paths back into the build context, which costs a few kilobytes:

    # .dockerignore
    .git
    !.git/HEAD
    !.git/refs
    !.git/packed-refs

    Without them the commit is simply left out, and GITHUB_SHA is used when the environment names one.

The same record is written to build-info.json in the build, so the whole family answers one question with one request each:

curl -s https://smiles.cheminfo.org/build-info.json

Styling

No stylesheet, nothing for a consumer to import. Every rule is a module-level CSSProperties constant applied with style, the way react-mf does it — so there is no import step, no load-order surprise, and no specificity fight with Blueprint, since an inline rule outranks every selector.

A component never hard-codes a site's identity: what differs per site is passed in as props, or read from a CSS variable (--brand, --brand-alt, --accent) so each site keeps the two colours it owns.

Development

npm run dev

opens Storybook on http://localhost:10815 — every component of the package, under every prop it takes, with the controls to set them and the snippet that reproduces what is on show. The Brand toolbar retunes --brand / --brand-alt / --accent on the canvas, so you can watch a component carry a site's two colours. Components land there as they move into the package.

A story is a *.stories.tsx file in stories/, written in CSF: a default export naming the component, and one export per state the sites use it in. The prose and the types of the controls come from the props' own JSDoc, read off the source by @storybook/addon-docs, so a component is documented by documenting its props — never by writing a page for it. stories/headerButton.ts already describes the controls every header button shares.

npm run build-storybook   # storybook-static/

is what GitHub Pages serves at https://cheminfo.github.io/react-cheminfo/, built and deployed by .github/workflows/pages.yml on every push to main.

Testing

Unit tests cover the framework-free half, which is where the citation formats and the site list live:

npm run test         # vitest + type-check + eslint + prettier

The components themselves are almost entirely interaction — Blueprint popovers with hover delays, submenus, clipboard writes in two flavours, file downloads — so they are tested in a real browser rather than a simulated DOM:

npx playwright install --with-deps chromium   # once
npm run test-e2e                              # or test-e2e-ui to watch

npm run test-e2e starts Storybook itself and drives each story on its own address (/iframe.html?id=…), so there is nothing to launch first. It is a separate command from npm run test, because it needs a browser that a plain CI job does not have; its own workflow installs one.

Testing a local build

Before publishing, test the packed artifact rather than the sources: it is what a site will actually install, and it is where a missing file or a wrong exports entry shows up.

npm run tsc                # build lib/
npm pack                   # react-cheminfo-0.0.0.tgz

Then, from a throwaway app — or from the site you are about to adopt it in:

npm i ../react-cheminfo/react-cheminfo-0.0.0.tgz

Import from every entry point, so the check covers the framework-free halves too:

import { CITATION_STYLES, formatCitation } from 'react-cheminfo/core';
import { CiteButton, EcosystemButton } from 'react-cheminfo/ui';
import { cheminfoPrerender } from 'react-cheminfo/vite';

Install the tarball on its own once — with no other dependency — and import react-cheminfo/core and react-cheminfo/vite from it. Every peer is optional, so npm installs none of them, and either import failing means a module that should be framework-free is loading one at module scope.

Reinstall the tarball after every npm pack — npm caches it by name and version, and this package stays at 0.0.0 until release-please cuts the first one.

API Documentation

License

MIT