react-cheminfo 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -6
- package/lib/core.d.ts +1 -0
- package/lib/core.d.ts.map +1 -1
- package/lib/core.js +1 -0
- package/lib/core.js.map +1 -1
- package/lib/ecosystem/core/sites.d.ts +1 -1
- package/lib/ecosystem/core/sites.d.ts.map +1 -1
- package/lib/ecosystem/core/sites.js +18 -0
- package/lib/ecosystem/core/sites.js.map +1 -1
- package/lib/ecosystem/ui/glyphs.d.ts +16 -0
- package/lib/ecosystem/ui/glyphs.d.ts.map +1 -0
- package/lib/ecosystem/ui/glyphs.js +41 -0
- package/lib/ecosystem/ui/glyphs.js.map +1 -0
- package/lib/ecosystem/ui/marks.d.ts.map +1 -1
- package/lib/ecosystem/ui/marks.js +2 -33
- package/lib/ecosystem/ui/marks.js.map +1 -1
- package/lib/orbital/core/atomicGrid.d.ts +58 -0
- package/lib/orbital/core/atomicGrid.d.ts.map +1 -0
- package/lib/orbital/core/atomicGrid.js +69 -0
- package/lib/orbital/core/atomicGrid.js.map +1 -0
- package/lib/orbital/core/atomicOrbitals.d.ts +97 -0
- package/lib/orbital/core/atomicOrbitals.d.ts.map +1 -0
- package/lib/orbital/core/atomicOrbitals.js +135 -0
- package/lib/orbital/core/atomicOrbitals.js.map +1 -0
- package/lib/orbital/core/constants.d.ts +33 -0
- package/lib/orbital/core/constants.d.ts.map +1 -0
- package/lib/orbital/core/constants.js +21 -0
- package/lib/orbital/core/constants.js.map +1 -0
- package/lib/orbital/core/electronConfiguration.d.ts +106 -0
- package/lib/orbital/core/electronConfiguration.d.ts.map +1 -0
- package/lib/orbital/core/electronConfiguration.js +207 -0
- package/lib/orbital/core/electronConfiguration.js.map +1 -0
- package/lib/orbital/core/grid.d.ts +60 -0
- package/lib/orbital/core/grid.d.ts.map +1 -0
- package/lib/orbital/core/grid.js +81 -0
- package/lib/orbital/core/grid.js.map +1 -0
- package/lib/orbital/core/hydrogenic.d.ts +99 -0
- package/lib/orbital/core/hydrogenic.d.ts.map +1 -0
- package/lib/orbital/core/hydrogenic.js +181 -0
- package/lib/orbital/core/hydrogenic.js.map +1 -0
- package/lib/orbital/core/index.d.ts +22 -0
- package/lib/orbital/core/index.d.ts.map +1 -0
- package/lib/orbital/core/index.js +12 -0
- package/lib/orbital/core/index.js.map +1 -0
- package/lib/orbital/core/numerics.d.ts +39 -0
- package/lib/orbital/core/numerics.d.ts.map +1 -0
- package/lib/orbital/core/numerics.js +75 -0
- package/lib/orbital/core/numerics.js.map +1 -0
- package/lib/orbital/core/occupancy.d.ts +44 -0
- package/lib/orbital/core/occupancy.d.ts.map +1 -0
- package/lib/orbital/core/occupancy.js +85 -0
- package/lib/orbital/core/occupancy.js.map +1 -0
- package/lib/orbital/core/palette.d.ts +36 -0
- package/lib/orbital/core/palette.d.ts.map +1 -0
- package/lib/orbital/core/palette.js +37 -0
- package/lib/orbital/core/palette.js.map +1 -0
- package/lib/orbital/core/realHarmonics.d.ts +57 -0
- package/lib/orbital/core/realHarmonics.d.ts.map +1 -0
- package/lib/orbital/core/realHarmonics.js +144 -0
- package/lib/orbital/core/realHarmonics.js.map +1 -0
- package/lib/orbital/core/sample.d.ts +52 -0
- package/lib/orbital/core/sample.d.ts.map +1 -0
- package/lib/orbital/core/sample.js +54 -0
- package/lib/orbital/core/sample.js.map +1 -0
- package/lib/orbital/core/screening.d.ts +46 -0
- package/lib/orbital/core/screening.d.ts.map +1 -0
- package/lib/orbital/core/screening.js +72 -0
- package/lib/orbital/core/screening.js.map +1 -0
- package/lib/orbital/ui/AtomicOrbitalCanvas.d.ts +57 -0
- package/lib/orbital/ui/AtomicOrbitalCanvas.d.ts.map +1 -0
- package/lib/orbital/ui/AtomicOrbitalCanvas.js +105 -0
- package/lib/orbital/ui/AtomicOrbitalCanvas.js.map +1 -0
- package/lib/orbital/ui/AtomicOrbitalViewer.d.ts +73 -0
- package/lib/orbital/ui/AtomicOrbitalViewer.d.ts.map +1 -0
- package/lib/orbital/ui/AtomicOrbitalViewer.js +49 -0
- package/lib/orbital/ui/AtomicOrbitalViewer.js.map +1 -0
- package/lib/orbital/ui/camera.d.ts +36 -0
- package/lib/orbital/ui/camera.d.ts.map +1 -0
- package/lib/orbital/ui/camera.js +98 -0
- package/lib/orbital/ui/camera.js.map +1 -0
- package/lib/orbital/ui/capability.d.ts +28 -0
- package/lib/orbital/ui/capability.d.ts.map +1 -0
- package/lib/orbital/ui/capability.js +77 -0
- package/lib/orbital/ui/capability.js.map +1 -0
- package/lib/orbital/ui/index.d.ts +17 -0
- package/lib/orbital/ui/index.d.ts.map +1 -0
- package/lib/orbital/ui/index.js +13 -0
- package/lib/orbital/ui/index.js.map +1 -0
- package/lib/orbital/ui/renderVolume.d.ts +57 -0
- package/lib/orbital/ui/renderVolume.d.ts.map +1 -0
- package/lib/orbital/ui/renderVolume.js +120 -0
- package/lib/orbital/ui/renderVolume.js.map +1 -0
- package/lib/orbital/ui/viewer.d.ts +86 -0
- package/lib/orbital/ui/viewer.d.ts.map +1 -0
- package/lib/orbital/ui/viewer.js +155 -0
- package/lib/orbital/ui/viewer.js.map +1 -0
- package/lib/orbital/ui/volumeField.d.ts +49 -0
- package/lib/orbital/ui/volumeField.d.ts.map +1 -0
- package/lib/orbital/ui/volumeField.js +108 -0
- package/lib/orbital/ui/volumeField.js.map +1 -0
- package/lib/orbital.d.ts +11 -0
- package/lib/orbital.d.ts.map +1 -0
- package/lib/orbital.js +11 -0
- package/lib/orbital.js.map +1 -0
- package/package.json +11 -3
- package/src/core.ts +1 -0
- package/src/ecosystem/core/sites.ts +21 -1
- package/src/ecosystem/ui/glyphs.tsx +246 -0
- package/src/ecosystem/ui/marks.tsx +4 -201
- package/src/orbital/core/atomicGrid.ts +115 -0
- package/src/orbital/core/atomicOrbitals.ts +212 -0
- package/src/orbital/core/constants.ts +36 -0
- package/src/orbital/core/electronConfiguration.ts +242 -0
- package/src/orbital/core/grid.ts +126 -0
- package/src/orbital/core/hydrogenic.ts +222 -0
- package/src/orbital/core/index.ts +72 -0
- package/src/orbital/core/numerics.ts +79 -0
- package/src/orbital/core/occupancy.ts +100 -0
- package/src/orbital/core/palette.ts +56 -0
- package/src/orbital/core/realHarmonics.ts +172 -0
- package/src/orbital/core/sample.ts +91 -0
- package/src/orbital/core/screening.ts +90 -0
- package/src/orbital/ui/AtomicOrbitalCanvas.tsx +181 -0
- package/src/orbital/ui/AtomicOrbitalViewer.tsx +150 -0
- package/src/orbital/ui/camera.ts +125 -0
- package/src/orbital/ui/capability.ts +102 -0
- package/src/orbital/ui/index.ts +17 -0
- package/src/orbital/ui/renderVolume.ts +190 -0
- package/src/orbital/ui/viewer.ts +186 -0
- package/src/orbital/ui/volumeField.ts +128 -0
- package/src/orbital.ts +11 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one job the viewer asks for, shaped so it can cross a worker boundary.
|
|
3
|
+
*
|
|
4
|
+
* The request carries an atomic number and an orbital id, never an
|
|
5
|
+
* `AtomicOrbital`: that object holds its spherical harmonic as a
|
|
6
|
+
* function*, which the structured clone algorithm cannot copy. Rebuilding the
|
|
7
|
+
* list from two plain values costs microseconds and keeps the boundary honest.
|
|
8
|
+
*
|
|
9
|
+
* A site that would rather not spend ~25 ms of its main thread per orbital
|
|
10
|
+
* implements this contract in a worker and hands `AtomicOrbitalViewer` the
|
|
11
|
+
* `sample` prop; one that does not gets the in-process version for free.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { AtomicGridOptions } from './atomicGrid.ts';
|
|
15
|
+
import { sampleAtomicOrbital } from './atomicGrid.ts';
|
|
16
|
+
import {
|
|
17
|
+
atomicOrbitalsOf,
|
|
18
|
+
findAtomicOrbital,
|
|
19
|
+
hydrogenicParametersOf,
|
|
20
|
+
} from './atomicOrbitals.ts';
|
|
21
|
+
import type { OrbitalGrid } from './grid.ts';
|
|
22
|
+
import { radialNodeRadii } from './hydrogenic.ts';
|
|
23
|
+
|
|
24
|
+
/** Which orbital to sample, and how finely. */
|
|
25
|
+
export interface AtomicSampleRequest {
|
|
26
|
+
/** Proton count, 1 to 118. */
|
|
27
|
+
atomicNumber: number;
|
|
28
|
+
/** Which orbital of that element, e.g. `3dz2`. */
|
|
29
|
+
orbitalId: string;
|
|
30
|
+
/**
|
|
31
|
+
* Samples along each edge of the cube. The cost is the cube of this.
|
|
32
|
+
* @default 56
|
|
33
|
+
*/
|
|
34
|
+
resolution?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** What one sampling job produces. */
|
|
38
|
+
export interface AtomicSampleResult {
|
|
39
|
+
/** The signed field, ready for an isosurface. */
|
|
40
|
+
grid: OrbitalGrid;
|
|
41
|
+
/** Radii of the orbital's radial nodes, ångström, ascending. */
|
|
42
|
+
nodeRadii: number[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Sample one atomic orbital onto a grid.
|
|
47
|
+
*
|
|
48
|
+
* Synchronous and free of React, molstar and the DOM, so it runs unchanged on
|
|
49
|
+
* the main thread, in a worker, and under vitest.
|
|
50
|
+
* @param request - See {@link AtomicSampleRequest}.
|
|
51
|
+
* @returns The field, and where its radial nodes are.
|
|
52
|
+
* @throws {Error} When the element or the orbital id names nothing.
|
|
53
|
+
*/
|
|
54
|
+
export function runAtomicSample(
|
|
55
|
+
request: AtomicSampleRequest,
|
|
56
|
+
): AtomicSampleResult {
|
|
57
|
+
const { atomicNumber, orbitalId, resolution } = request;
|
|
58
|
+
const orbitals = atomicOrbitalsOf(atomicNumber);
|
|
59
|
+
const orbital = findAtomicOrbital(orbitals, orbitalId);
|
|
60
|
+
if (orbital === null) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`Element ${atomicNumber} has no orbital ${orbitalId}. Known: ${orbitals
|
|
63
|
+
.map((entry) => entry.id)
|
|
64
|
+
.join(', ')}.`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
const options: AtomicGridOptions = {};
|
|
68
|
+
if (resolution !== undefined) options.resolution = resolution;
|
|
69
|
+
return {
|
|
70
|
+
grid: sampleAtomicOrbital(orbital, options),
|
|
71
|
+
nodeRadii: radialNodeRadii(hydrogenicParametersOf(orbital)),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** How a caller supplies the sampling — in process, or through a worker. */
|
|
76
|
+
export type AtomicSampler = (
|
|
77
|
+
request: AtomicSampleRequest,
|
|
78
|
+
) => Promise<AtomicSampleResult>;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The default sampler: {@link runAtomicSample}, yielded to the event loop so
|
|
82
|
+
* the spinner has painted before the main thread is taken.
|
|
83
|
+
* @param request - See {@link AtomicSampleRequest}.
|
|
84
|
+
* @returns The field, and where its radial nodes are.
|
|
85
|
+
*/
|
|
86
|
+
export const sampleInProcess: AtomicSampler = async (request) => {
|
|
87
|
+
await new Promise((resolve) => {
|
|
88
|
+
setTimeout(resolve, 0);
|
|
89
|
+
});
|
|
90
|
+
return runAtomicSample(request);
|
|
91
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slater's rules: how much of the nucleus an electron actually feels.
|
|
3
|
+
*
|
|
4
|
+
* A hydrogen-like orbital needs one number, the effective nuclear charge, and
|
|
5
|
+
* taking the full `Z` would draw carbon's 2p six times too small. Slater's rules
|
|
6
|
+
* estimate the shielding `S` from the other electrons, and `Z_eff = Z − S` is
|
|
7
|
+
* what the radial function is built with.
|
|
8
|
+
*
|
|
9
|
+
* They are an empirical fit from 1930, not a calculation — but they are the ones
|
|
10
|
+
* every general-chemistry course teaches, they reproduce the periodic trends
|
|
11
|
+
* that matter (Z_eff rises across a period, barely moves down a group), and a
|
|
12
|
+
* student can check the arithmetic here against the one they did by hand.
|
|
13
|
+
*
|
|
14
|
+
* The electrons are grouped as Slater grouped them, with s and p sharing a
|
|
15
|
+
* group and each d and f standing alone:
|
|
16
|
+
*
|
|
17
|
+
* ```
|
|
18
|
+
* [1s] [2s2p] [3s3p] [3d] [4s4p] [4d] [4f] [5s5p] [5d] [5f] [6s6p] [6d] [7s7p]
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { Subshell, SubshellOccupancy } from './electronConfiguration.ts';
|
|
23
|
+
|
|
24
|
+
/** The shielding an orbital feels, and what is left of the nuclear charge. */
|
|
25
|
+
export interface Screening {
|
|
26
|
+
/** Sum of the shielding contributions, `S`. */
|
|
27
|
+
shielding: number;
|
|
28
|
+
/** `Z − S`, the charge the hydrogen-like radial function is built with. */
|
|
29
|
+
effectiveCharge: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Apply Slater's rules to one subshell of one atom.
|
|
34
|
+
* @param atomicNumber - Proton count, the `Z` the shielding is subtracted from.
|
|
35
|
+
* @param configuration - Every occupied subshell of the atom.
|
|
36
|
+
* @param subshell - The subshell whose electron is being screened.
|
|
37
|
+
* @returns Its shielding and effective nuclear charge.
|
|
38
|
+
*/
|
|
39
|
+
export function slaterScreening(
|
|
40
|
+
atomicNumber: number,
|
|
41
|
+
configuration: readonly SubshellOccupancy[],
|
|
42
|
+
subshell: Subshell,
|
|
43
|
+
): Screening {
|
|
44
|
+
const rank = groupRank(subshell);
|
|
45
|
+
const isCore = subshell.n === 1;
|
|
46
|
+
const isDiffuse = subshell.l >= 2;
|
|
47
|
+
let shielding = 0;
|
|
48
|
+
for (const occupied of configuration) {
|
|
49
|
+
const otherRank = groupRank(occupied);
|
|
50
|
+
if (otherRank > rank) continue;
|
|
51
|
+
if (otherRank === rank) {
|
|
52
|
+
// An electron does not shield itself, so one is left out — but only from
|
|
53
|
+
// its *own* subshell. 2s and 2p share a group, and every one of carbon's
|
|
54
|
+
// two 2s electrons shields its 2p electron in full.
|
|
55
|
+
const others = Math.max(
|
|
56
|
+
0,
|
|
57
|
+
occupied.electrons - (sameSubshell(occupied, subshell) ? 1 : 0),
|
|
58
|
+
);
|
|
59
|
+
shielding += others * (isCore ? 0.3 : 0.35);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (isDiffuse) {
|
|
63
|
+
// A d or f electron is screened completely by everything inside it.
|
|
64
|
+
shielding += occupied.electrons;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
shielding +=
|
|
68
|
+
occupied.electrons * (occupied.n === subshell.n - 1 ? 0.85 : 1);
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
shielding,
|
|
72
|
+
effectiveCharge: atomicNumber - shielding,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Where a subshell sits in Slater's left-to-right ordering of groups.
|
|
78
|
+
*
|
|
79
|
+
* s and p of the same shell share a group, so they share a rank; d and f each
|
|
80
|
+
* get their own, placed after the s/p group of the same `n`.
|
|
81
|
+
* @param subshell - The subshell to rank.
|
|
82
|
+
* @returns A comparable rank; equal ranks mean the same Slater group.
|
|
83
|
+
*/
|
|
84
|
+
export function groupRank(subshell: Subshell): number {
|
|
85
|
+
return subshell.n * 10 + (subshell.l <= 1 ? 0 : subshell.l);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function sameSubshell(first: Subshell, second: Subshell): boolean {
|
|
89
|
+
return first.n === second.n && first.l === second.l;
|
|
90
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The molstar canvas showing one atomic orbital.
|
|
3
|
+
*
|
|
4
|
+
* `AtomicOrbitalViewer` reaches this through `React.lazy`, so a page that
|
|
5
|
+
* never shows an orbital never downloads molstar for it — which is the whole
|
|
6
|
+
* reason the two components are separate files.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { CSSProperties, ReactElement } from 'react';
|
|
10
|
+
import { useEffect, useRef, useState } from 'react';
|
|
11
|
+
|
|
12
|
+
import type { PhasePalette } from '../core/palette.ts';
|
|
13
|
+
import { PHASE_PALETTES } from '../core/palette.ts';
|
|
14
|
+
import type { AtomicSampler } from '../core/sample.ts';
|
|
15
|
+
import { sampleInProcess } from '../core/sample.ts';
|
|
16
|
+
|
|
17
|
+
import type { OrbitalViewer } from './viewer.ts';
|
|
18
|
+
import { createOrbitalViewer } from './viewer.ts';
|
|
19
|
+
|
|
20
|
+
/** Props of {@link AtomicOrbitalCanvas}. */
|
|
21
|
+
export interface AtomicOrbitalCanvasProps {
|
|
22
|
+
/** Proton count of the element on screen. */
|
|
23
|
+
atomicNumber: number;
|
|
24
|
+
/** Which orbital of it, e.g. `3dz2`. */
|
|
25
|
+
orbitalId: string;
|
|
26
|
+
/**
|
|
27
|
+
* Colours the two phases are drawn in.
|
|
28
|
+
* @default PHASE_PALETTES.textbook
|
|
29
|
+
*/
|
|
30
|
+
palette?: PhasePalette;
|
|
31
|
+
/**
|
|
32
|
+
* Samples along each edge of the cube; the cost is the cube of it.
|
|
33
|
+
* @default 56
|
|
34
|
+
*/
|
|
35
|
+
resolution?: number;
|
|
36
|
+
/**
|
|
37
|
+
* Whether the scene turns on its own, which is what makes a still screenshot
|
|
38
|
+
* of a 3D shape readable.
|
|
39
|
+
* @default false
|
|
40
|
+
*/
|
|
41
|
+
spinning?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* How the field is produced. Supply a worker-backed sampler to keep the main
|
|
44
|
+
* thread free; the default runs in process.
|
|
45
|
+
* @default sampleInProcess
|
|
46
|
+
*/
|
|
47
|
+
sample?: AtomicSampler;
|
|
48
|
+
/**
|
|
49
|
+
* Called with the radial node radii, ångström, each time an orbital is
|
|
50
|
+
* sampled — so the page can mark them on a radial plot beside the canvas.
|
|
51
|
+
* @default undefined
|
|
52
|
+
*/
|
|
53
|
+
onNodeRadii?: (radii: number[]) => void;
|
|
54
|
+
/**
|
|
55
|
+
* Called when an orbital cannot be drawn, with the reason.
|
|
56
|
+
* @default undefined
|
|
57
|
+
*/
|
|
58
|
+
onError?: (message: string) => void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Mount one molstar canvas and keep it showing the orbital named by the props.
|
|
63
|
+
* @param props - See {@link AtomicOrbitalCanvasProps}.
|
|
64
|
+
* @returns The canvas, with a progress note while it samples.
|
|
65
|
+
*/
|
|
66
|
+
export function AtomicOrbitalCanvas(
|
|
67
|
+
props: AtomicOrbitalCanvasProps,
|
|
68
|
+
): ReactElement {
|
|
69
|
+
const {
|
|
70
|
+
atomicNumber,
|
|
71
|
+
orbitalId,
|
|
72
|
+
palette = PHASE_PALETTES.textbook,
|
|
73
|
+
resolution = DEFAULT_RESOLUTION,
|
|
74
|
+
spinning = false,
|
|
75
|
+
sample = sampleInProcess,
|
|
76
|
+
onNodeRadii,
|
|
77
|
+
onError,
|
|
78
|
+
} = props;
|
|
79
|
+
|
|
80
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
81
|
+
const viewerRef = useRef<OrbitalViewer | null>(null);
|
|
82
|
+
const [drawn, setDrawn] = useState<string | null>(null);
|
|
83
|
+
|
|
84
|
+
// What the canvas is being asked to show. Comparing it with what it *is*
|
|
85
|
+
// showing gives the progress note without a state write on every prop change.
|
|
86
|
+
const wanted = `${atomicNumber}|${orbitalId}|${resolution}|${palette.id}`;
|
|
87
|
+
const busy = drawn !== wanted;
|
|
88
|
+
|
|
89
|
+
// Callbacks are read through refs so a caller passing an inline arrow does
|
|
90
|
+
// not re-sample the orbital on every render of its parent.
|
|
91
|
+
const callbacks = useRef({ onNodeRadii, onError });
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
callbacks.current = { onNodeRadii, onError };
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Created and disposed once per mount. React 19 runs this twice in
|
|
97
|
+
// development; `createOrbitalViewer` is synchronous so the first canvas is
|
|
98
|
+
// always disposed instead of leaking its WebGL context.
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
const container = containerRef.current;
|
|
101
|
+
if (container === null) return;
|
|
102
|
+
const viewer = createOrbitalViewer(container);
|
|
103
|
+
viewerRef.current = viewer;
|
|
104
|
+
const observer = new ResizeObserver(() => {
|
|
105
|
+
viewer.handleResize();
|
|
106
|
+
});
|
|
107
|
+
observer.observe(container);
|
|
108
|
+
return () => {
|
|
109
|
+
observer.disconnect();
|
|
110
|
+
viewerRef.current = null;
|
|
111
|
+
viewer.dispose();
|
|
112
|
+
};
|
|
113
|
+
}, []);
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
const viewer = viewerRef.current;
|
|
117
|
+
if (viewer === null) return;
|
|
118
|
+
let cancelled = false;
|
|
119
|
+
void sample({ atomicNumber, orbitalId, resolution })
|
|
120
|
+
.then(async (result) => {
|
|
121
|
+
if (cancelled) return;
|
|
122
|
+
callbacks.current.onNodeRadii?.(result.nodeRadii);
|
|
123
|
+
const reach = await viewer.showOrbital(result.grid, {
|
|
124
|
+
positiveColour: palette.positive,
|
|
125
|
+
negativeColour: palette.negative,
|
|
126
|
+
});
|
|
127
|
+
await viewer.frame(reach);
|
|
128
|
+
if (!cancelled) setDrawn(wanted);
|
|
129
|
+
})
|
|
130
|
+
.catch((error: unknown) => {
|
|
131
|
+
if (cancelled) return;
|
|
132
|
+
callbacks.current.onError?.(
|
|
133
|
+
error instanceof Error ? error.message : String(error),
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
return () => {
|
|
137
|
+
cancelled = true;
|
|
138
|
+
};
|
|
139
|
+
}, [atomicNumber, orbitalId, resolution, palette, sample, wanted]);
|
|
140
|
+
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
void viewerRef.current?.setSpin(spinning);
|
|
143
|
+
}, [spinning]);
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<div ref={containerRef} style={CANVAS_STYLE}>
|
|
147
|
+
{busy && <div style={BUSY_STYLE}>Sampling…</div>}
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Samples per edge; 56 resolves the radial node of a 3s in about 25 ms. */
|
|
153
|
+
const DEFAULT_RESOLUTION = 56;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A square, centred stage: an atomic orbital is as tall as it is wide, so a
|
|
157
|
+
* square wastes the least of the frame and keeps the lobes the same size when
|
|
158
|
+
* the surrounding column changes width.
|
|
159
|
+
*/
|
|
160
|
+
const CANVAS_STYLE: CSSProperties = {
|
|
161
|
+
position: 'relative',
|
|
162
|
+
width: '100%',
|
|
163
|
+
maxWidth: 'min(100%, 60vh)',
|
|
164
|
+
aspectRatio: '1 / 1',
|
|
165
|
+
margin: '0 auto',
|
|
166
|
+
minHeight: 260,
|
|
167
|
+
borderRadius: 3,
|
|
168
|
+
overflow: 'hidden',
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const BUSY_STYLE: CSSProperties = {
|
|
172
|
+
position: 'absolute',
|
|
173
|
+
top: 8,
|
|
174
|
+
left: 8,
|
|
175
|
+
zIndex: 1,
|
|
176
|
+
padding: '3px 8px',
|
|
177
|
+
borderRadius: 3,
|
|
178
|
+
background: 'rgba(255, 255, 255, 0.85)',
|
|
179
|
+
color: '#5f6b7c',
|
|
180
|
+
fontSize: 11,
|
|
181
|
+
};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One atomic orbital in three dimensions, and what to say when there cannot be
|
|
3
|
+
* one.
|
|
4
|
+
*
|
|
5
|
+
* Two precautions, both of which are the reason to use this component rather
|
|
6
|
+
* than the canvas directly: the WebGL probe runs *before* molstar is touched,
|
|
7
|
+
* because a locked-down school machine would otherwise get a blank rectangle
|
|
8
|
+
* with no explanation, and the canvas sits behind `React.lazy`, so a page that
|
|
9
|
+
* never reaches it never downloads molstar.
|
|
10
|
+
*
|
|
11
|
+
* ```tsx
|
|
12
|
+
* import { AtomicOrbitalViewer } from 'react-cheminfo/orbital';
|
|
13
|
+
*
|
|
14
|
+
* <AtomicOrbitalViewer atomicNumber={26} orbitalId="3dz2" />;
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { CSSProperties, ReactElement, ReactNode } from 'react';
|
|
19
|
+
import { Suspense, lazy, useState } from 'react';
|
|
20
|
+
|
|
21
|
+
import type { PhasePalette } from '../core/palette.ts';
|
|
22
|
+
import type { AtomicSampler } from '../core/sample.ts';
|
|
23
|
+
|
|
24
|
+
// Deep import on purpose: `capability.ts` imports nothing, while the sibling
|
|
25
|
+
// canvas pulls molstar in — which is exactly what this probe exists to avoid.
|
|
26
|
+
import type { ViewerCapability } from './capability.ts';
|
|
27
|
+
import { probeViewerCapability } from './capability.ts';
|
|
28
|
+
|
|
29
|
+
const AtomicOrbitalCanvas = lazy(async () => {
|
|
30
|
+
const module = await import('./AtomicOrbitalCanvas.tsx');
|
|
31
|
+
return { default: module.AtomicOrbitalCanvas };
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** Props of {@link AtomicOrbitalViewer}. */
|
|
35
|
+
export interface AtomicOrbitalViewerProps {
|
|
36
|
+
/** Proton count of the element, 1 to 118. */
|
|
37
|
+
atomicNumber: number;
|
|
38
|
+
/** Which orbital of it, e.g. `3dz2`; ids come from `atomicOrbitalsOf`. */
|
|
39
|
+
orbitalId: string;
|
|
40
|
+
/**
|
|
41
|
+
* Colours the two phases are drawn in.
|
|
42
|
+
* @default PHASE_PALETTES.textbook
|
|
43
|
+
*/
|
|
44
|
+
palette?: PhasePalette;
|
|
45
|
+
/**
|
|
46
|
+
* Samples along each edge of the cube; the cost is the cube of it.
|
|
47
|
+
* @default 56
|
|
48
|
+
*/
|
|
49
|
+
resolution?: number;
|
|
50
|
+
/**
|
|
51
|
+
* Whether the scene turns on its own.
|
|
52
|
+
* @default false
|
|
53
|
+
*/
|
|
54
|
+
spinning?: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* How the field is produced. Supply a worker-backed sampler to keep the main
|
|
57
|
+
* thread free; the default runs in process.
|
|
58
|
+
* @default sampleInProcess
|
|
59
|
+
*/
|
|
60
|
+
sample?: AtomicSampler;
|
|
61
|
+
/**
|
|
62
|
+
* Called with the radial node radii, ångström, each time an orbital is
|
|
63
|
+
* sampled.
|
|
64
|
+
* @default undefined
|
|
65
|
+
*/
|
|
66
|
+
onNodeRadii?: (radii: number[]) => void;
|
|
67
|
+
/**
|
|
68
|
+
* What to show while molstar is downloading.
|
|
69
|
+
* @default 'Loading the 3D viewer…'
|
|
70
|
+
*/
|
|
71
|
+
fallback?: ReactNode;
|
|
72
|
+
/**
|
|
73
|
+
* What to show when the machine cannot render at all. Receives the probe, so
|
|
74
|
+
* a site can word the refusal in its own voice; the default writes
|
|
75
|
+
* `capability.message`.
|
|
76
|
+
* @default undefined
|
|
77
|
+
*/
|
|
78
|
+
renderUnsupported?: (capability: ViewerCapability) => ReactNode;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The 3D atomic orbital.
|
|
83
|
+
* @param props - See {@link AtomicOrbitalViewerProps}.
|
|
84
|
+
* @returns The viewer, or an explanation of why this machine cannot show one.
|
|
85
|
+
*/
|
|
86
|
+
export function AtomicOrbitalViewer(
|
|
87
|
+
props: AtomicOrbitalViewerProps,
|
|
88
|
+
): ReactElement {
|
|
89
|
+
const {
|
|
90
|
+
fallback = 'Loading the 3D viewer…',
|
|
91
|
+
renderUnsupported,
|
|
92
|
+
...canvas
|
|
93
|
+
} = props;
|
|
94
|
+
const [capability] = useState(probeViewerCapability);
|
|
95
|
+
const [failure, setFailure] = useState<string | null>(null);
|
|
96
|
+
|
|
97
|
+
if (!capability.supported) {
|
|
98
|
+
return (
|
|
99
|
+
<div style={NOTE_STYLE}>
|
|
100
|
+
{renderUnsupported?.(capability) ?? capability.message}
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div style={ROOT_STYLE}>
|
|
107
|
+
<Suspense fallback={<div style={NOTE_STYLE}>{fallback}</div>}>
|
|
108
|
+
<AtomicOrbitalCanvas
|
|
109
|
+
{...canvas}
|
|
110
|
+
onError={(message) => {
|
|
111
|
+
setFailure(message);
|
|
112
|
+
}}
|
|
113
|
+
/>
|
|
114
|
+
</Suspense>
|
|
115
|
+
{failure !== null && (
|
|
116
|
+
<div style={FAILURE_STYLE}>
|
|
117
|
+
This orbital could not be drawn: {failure}
|
|
118
|
+
</div>
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const ROOT_STYLE: CSSProperties = {
|
|
125
|
+
display: 'flex',
|
|
126
|
+
flexDirection: 'column',
|
|
127
|
+
gap: 6,
|
|
128
|
+
minWidth: 0,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const NOTE_STYLE: CSSProperties = {
|
|
132
|
+
display: 'flex',
|
|
133
|
+
alignItems: 'center',
|
|
134
|
+
justifyContent: 'center',
|
|
135
|
+
minHeight: 260,
|
|
136
|
+
padding: 12,
|
|
137
|
+
borderRadius: 3,
|
|
138
|
+
background: 'rgb(241 245 249)',
|
|
139
|
+
color: '#5f6b7c',
|
|
140
|
+
fontSize: 13,
|
|
141
|
+
textAlign: 'center',
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const FAILURE_STYLE: CSSProperties = {
|
|
145
|
+
padding: '6px 9px',
|
|
146
|
+
borderRadius: 3,
|
|
147
|
+
background: '#fdeaea',
|
|
148
|
+
color: '#8c2b2b',
|
|
149
|
+
fontSize: 12,
|
|
150
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The camera moves an atomic orbital needs: frame it from an angle its lobes
|
|
3
|
+
* can be told apart at, and the slow spin that makes a flat screenshot of a 3D
|
|
4
|
+
* shape readable.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Vec3 } from 'molstar/lib/mol-math/linear-algebra.js';
|
|
8
|
+
import type { PluginContext } from 'molstar/lib/mol-plugin/context.js';
|
|
9
|
+
|
|
10
|
+
/** Transition length used when the caller does not pick one, milliseconds. */
|
|
11
|
+
export const DEFAULT_CAMERA_DURATION = 250;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Fraction of the bounding sphere kept as breathing room around the orbital.
|
|
15
|
+
*
|
|
16
|
+
* `camera.reset()` frames the scene with molstar's own margin, which suits a
|
|
17
|
+
* protein filling a wide viewport. One orbital in a square frame measured 20%
|
|
18
|
+
* of the pixels that way — it read as a speck. Framing the visible bounding
|
|
19
|
+
* sphere directly, with a small margin, is what makes it legible.
|
|
20
|
+
*/
|
|
21
|
+
const FRAMING_MARGIN = 0.08;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Frame everything currently in the scene.
|
|
25
|
+
* @param plugin - The molstar context.
|
|
26
|
+
* @param durationMs - Transition length. Pass 0 for an instant jump.
|
|
27
|
+
*/
|
|
28
|
+
export function resetCamera(
|
|
29
|
+
plugin: PluginContext,
|
|
30
|
+
durationMs = DEFAULT_CAMERA_DURATION,
|
|
31
|
+
): void {
|
|
32
|
+
const scene = plugin.canvas3d?.boundingSphereVisible;
|
|
33
|
+
if (scene === undefined || scene.radius <= 0) {
|
|
34
|
+
plugin.managers.camera.reset(undefined, durationMs);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
plugin.managers.camera.focusSphere(scene, {
|
|
38
|
+
extraRadius: scene.radius * FRAMING_MARGIN,
|
|
39
|
+
minRadius: 0.5,
|
|
40
|
+
durationMs,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Direction from the target to the camera for a single atom's orbital.
|
|
46
|
+
*
|
|
47
|
+
* An atomic orbital is aligned with the cartesian axes, so the default view
|
|
48
|
+
* looks straight down one of them and a `p_z` or a `d_z²` collapses into
|
|
49
|
+
* concentric rings — geometrically correct and completely unreadable. Coming in
|
|
50
|
+
* off-axis separates the lobes.
|
|
51
|
+
*/
|
|
52
|
+
const ORBITAL_VIEW_OFFSET = Vec3.create(0.8, -1, 0.45);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Screen up for that view: `+z`, so the z axis is vertical exactly as every
|
|
56
|
+
* textbook draws `p_z` and `d_z²`.
|
|
57
|
+
*/
|
|
58
|
+
const ORBITAL_VIEW_UP = Vec3.create(0, 0, 1);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Frame the scene from an oblique angle, for a lone atom rather than a molecule.
|
|
62
|
+
*
|
|
63
|
+
* `camera.focus` cannot do this: its `up` and `dir` arguments only *flip* the
|
|
64
|
+
* orientation the camera already has, so an explicit viewpoint has to be set
|
|
65
|
+
* through the snapshot.
|
|
66
|
+
* @param plugin - The molstar context.
|
|
67
|
+
* @param orbitalRadius - Extent of the drawn surface, ångström. Molstar sizes a
|
|
68
|
+
* volume representation's bounding sphere from the whole sampled box, which is
|
|
69
|
+
* far larger than the isosurface inside it, so framing that sphere leaves the
|
|
70
|
+
* orbital a quarter of the frame wide. Omit it to fall back on the scene.
|
|
71
|
+
* @param durationMs - Transition length. Pass 0 for an instant jump.
|
|
72
|
+
*/
|
|
73
|
+
export function frameOrbital(
|
|
74
|
+
plugin: PluginContext,
|
|
75
|
+
orbitalRadius?: number,
|
|
76
|
+
durationMs = DEFAULT_CAMERA_DURATION,
|
|
77
|
+
): void {
|
|
78
|
+
const canvas3d = plugin.canvas3d;
|
|
79
|
+
const scene = canvas3d?.boundingSphereVisible;
|
|
80
|
+
if (canvas3d === undefined || scene === undefined || scene.radius <= 0) {
|
|
81
|
+
resetCamera(plugin, durationMs);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const radius =
|
|
85
|
+
orbitalRadius !== undefined && orbitalRadius > 0
|
|
86
|
+
? orbitalRadius * (1 + FRAMING_MARGIN)
|
|
87
|
+
: scene.radius * (1 + FRAMING_MARGIN);
|
|
88
|
+
const offset = Vec3.setMagnitude(
|
|
89
|
+
Vec3.zero(),
|
|
90
|
+
ORBITAL_VIEW_OFFSET,
|
|
91
|
+
canvas3d.camera.getTargetDistance(radius),
|
|
92
|
+
);
|
|
93
|
+
// An atomic orbital is centred on its nucleus at the origin, so the drawn
|
|
94
|
+
// surface is symmetric about it even when the sampled box's sphere is not.
|
|
95
|
+
const centre = orbitalRadius === undefined ? scene.center : Vec3.zero();
|
|
96
|
+
canvas3d.camera.setState(
|
|
97
|
+
{
|
|
98
|
+
target: Vec3.clone(centre),
|
|
99
|
+
position: Vec3.add(Vec3.zero(), centre, offset),
|
|
100
|
+
up: Vec3.clone(ORBITAL_VIEW_UP),
|
|
101
|
+
radius,
|
|
102
|
+
},
|
|
103
|
+
durationMs,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Turn the automatic spin on or off.
|
|
109
|
+
* @param plugin - The molstar context.
|
|
110
|
+
* @param spinning - Whether the scene should keep turning.
|
|
111
|
+
* @param speed - Revolutions per minute-ish; molstar's own unit.
|
|
112
|
+
*/
|
|
113
|
+
export function setSpin(
|
|
114
|
+
plugin: PluginContext,
|
|
115
|
+
spinning: boolean,
|
|
116
|
+
speed = 1,
|
|
117
|
+
): void {
|
|
118
|
+
plugin.canvas3d?.setProps({
|
|
119
|
+
trackball: {
|
|
120
|
+
animate: spinning
|
|
121
|
+
? { name: 'spin', params: { speed, axis: Vec3.create(0, 1, 0) } }
|
|
122
|
+
: { name: 'off', params: {} },
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
}
|