react-cheminfo 0.1.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.
Files changed (159) hide show
  1. package/README.md +50 -9
  2. package/lib/citation/core/formats.js +2 -3
  3. package/lib/citation/core/formats.js.map +1 -1
  4. package/lib/citation/core/segments.d.ts +8 -0
  5. package/lib/citation/core/segments.d.ts.map +1 -1
  6. package/lib/citation/core/segments.js +12 -3
  7. package/lib/citation/core/segments.js.map +1 -1
  8. package/lib/core.d.ts +1 -0
  9. package/lib/core.d.ts.map +1 -1
  10. package/lib/core.js +1 -0
  11. package/lib/core.js.map +1 -1
  12. package/lib/ecosystem/core/sites.d.ts +1 -1
  13. package/lib/ecosystem/core/sites.d.ts.map +1 -1
  14. package/lib/ecosystem/core/sites.js +45 -0
  15. package/lib/ecosystem/core/sites.js.map +1 -1
  16. package/lib/ecosystem/ui/EcosystemLinks.d.ts +33 -0
  17. package/lib/ecosystem/ui/EcosystemLinks.d.ts.map +1 -0
  18. package/lib/ecosystem/ui/EcosystemLinks.js +55 -0
  19. package/lib/ecosystem/ui/EcosystemLinks.js.map +1 -0
  20. package/lib/ecosystem/ui/EcosystemMenu.d.ts +3 -1
  21. package/lib/ecosystem/ui/EcosystemMenu.d.ts.map +1 -1
  22. package/lib/ecosystem/ui/EcosystemMenu.js +7 -84
  23. package/lib/ecosystem/ui/EcosystemMenu.js.map +1 -1
  24. package/lib/ecosystem/ui/SiteTile.d.ts +34 -0
  25. package/lib/ecosystem/ui/SiteTile.d.ts.map +1 -0
  26. package/lib/ecosystem/ui/SiteTile.js +78 -0
  27. package/lib/ecosystem/ui/SiteTile.js.map +1 -0
  28. package/lib/ecosystem/ui/glyphs.d.ts +16 -0
  29. package/lib/ecosystem/ui/glyphs.d.ts.map +1 -0
  30. package/lib/ecosystem/ui/glyphs.js +41 -0
  31. package/lib/ecosystem/ui/glyphs.js.map +1 -0
  32. package/lib/ecosystem/ui/index.d.ts +4 -0
  33. package/lib/ecosystem/ui/index.d.ts.map +1 -1
  34. package/lib/ecosystem/ui/index.js +2 -0
  35. package/lib/ecosystem/ui/index.js.map +1 -1
  36. package/lib/ecosystem/ui/marks.d.ts.map +1 -1
  37. package/lib/ecosystem/ui/marks.js +3 -28
  38. package/lib/ecosystem/ui/marks.js.map +1 -1
  39. package/lib/orbital/core/atomicGrid.d.ts +58 -0
  40. package/lib/orbital/core/atomicGrid.d.ts.map +1 -0
  41. package/lib/orbital/core/atomicGrid.js +69 -0
  42. package/lib/orbital/core/atomicGrid.js.map +1 -0
  43. package/lib/orbital/core/atomicOrbitals.d.ts +97 -0
  44. package/lib/orbital/core/atomicOrbitals.d.ts.map +1 -0
  45. package/lib/orbital/core/atomicOrbitals.js +135 -0
  46. package/lib/orbital/core/atomicOrbitals.js.map +1 -0
  47. package/lib/orbital/core/constants.d.ts +33 -0
  48. package/lib/orbital/core/constants.d.ts.map +1 -0
  49. package/lib/orbital/core/constants.js +21 -0
  50. package/lib/orbital/core/constants.js.map +1 -0
  51. package/lib/orbital/core/electronConfiguration.d.ts +106 -0
  52. package/lib/orbital/core/electronConfiguration.d.ts.map +1 -0
  53. package/lib/orbital/core/electronConfiguration.js +207 -0
  54. package/lib/orbital/core/electronConfiguration.js.map +1 -0
  55. package/lib/orbital/core/grid.d.ts +60 -0
  56. package/lib/orbital/core/grid.d.ts.map +1 -0
  57. package/lib/orbital/core/grid.js +81 -0
  58. package/lib/orbital/core/grid.js.map +1 -0
  59. package/lib/orbital/core/hydrogenic.d.ts +99 -0
  60. package/lib/orbital/core/hydrogenic.d.ts.map +1 -0
  61. package/lib/orbital/core/hydrogenic.js +181 -0
  62. package/lib/orbital/core/hydrogenic.js.map +1 -0
  63. package/lib/orbital/core/index.d.ts +22 -0
  64. package/lib/orbital/core/index.d.ts.map +1 -0
  65. package/lib/orbital/core/index.js +12 -0
  66. package/lib/orbital/core/index.js.map +1 -0
  67. package/lib/orbital/core/numerics.d.ts +39 -0
  68. package/lib/orbital/core/numerics.d.ts.map +1 -0
  69. package/lib/orbital/core/numerics.js +75 -0
  70. package/lib/orbital/core/numerics.js.map +1 -0
  71. package/lib/orbital/core/occupancy.d.ts +44 -0
  72. package/lib/orbital/core/occupancy.d.ts.map +1 -0
  73. package/lib/orbital/core/occupancy.js +85 -0
  74. package/lib/orbital/core/occupancy.js.map +1 -0
  75. package/lib/orbital/core/palette.d.ts +36 -0
  76. package/lib/orbital/core/palette.d.ts.map +1 -0
  77. package/lib/orbital/core/palette.js +37 -0
  78. package/lib/orbital/core/palette.js.map +1 -0
  79. package/lib/orbital/core/realHarmonics.d.ts +57 -0
  80. package/lib/orbital/core/realHarmonics.d.ts.map +1 -0
  81. package/lib/orbital/core/realHarmonics.js +144 -0
  82. package/lib/orbital/core/realHarmonics.js.map +1 -0
  83. package/lib/orbital/core/sample.d.ts +52 -0
  84. package/lib/orbital/core/sample.d.ts.map +1 -0
  85. package/lib/orbital/core/sample.js +54 -0
  86. package/lib/orbital/core/sample.js.map +1 -0
  87. package/lib/orbital/core/screening.d.ts +46 -0
  88. package/lib/orbital/core/screening.d.ts.map +1 -0
  89. package/lib/orbital/core/screening.js +72 -0
  90. package/lib/orbital/core/screening.js.map +1 -0
  91. package/lib/orbital/ui/AtomicOrbitalCanvas.d.ts +57 -0
  92. package/lib/orbital/ui/AtomicOrbitalCanvas.d.ts.map +1 -0
  93. package/lib/orbital/ui/AtomicOrbitalCanvas.js +105 -0
  94. package/lib/orbital/ui/AtomicOrbitalCanvas.js.map +1 -0
  95. package/lib/orbital/ui/AtomicOrbitalViewer.d.ts +73 -0
  96. package/lib/orbital/ui/AtomicOrbitalViewer.d.ts.map +1 -0
  97. package/lib/orbital/ui/AtomicOrbitalViewer.js +49 -0
  98. package/lib/orbital/ui/AtomicOrbitalViewer.js.map +1 -0
  99. package/lib/orbital/ui/camera.d.ts +36 -0
  100. package/lib/orbital/ui/camera.d.ts.map +1 -0
  101. package/lib/orbital/ui/camera.js +98 -0
  102. package/lib/orbital/ui/camera.js.map +1 -0
  103. package/lib/orbital/ui/capability.d.ts +28 -0
  104. package/lib/orbital/ui/capability.d.ts.map +1 -0
  105. package/lib/orbital/ui/capability.js +77 -0
  106. package/lib/orbital/ui/capability.js.map +1 -0
  107. package/lib/orbital/ui/index.d.ts +17 -0
  108. package/lib/orbital/ui/index.d.ts.map +1 -0
  109. package/lib/orbital/ui/index.js +13 -0
  110. package/lib/orbital/ui/index.js.map +1 -0
  111. package/lib/orbital/ui/renderVolume.d.ts +57 -0
  112. package/lib/orbital/ui/renderVolume.d.ts.map +1 -0
  113. package/lib/orbital/ui/renderVolume.js +120 -0
  114. package/lib/orbital/ui/renderVolume.js.map +1 -0
  115. package/lib/orbital/ui/viewer.d.ts +86 -0
  116. package/lib/orbital/ui/viewer.d.ts.map +1 -0
  117. package/lib/orbital/ui/viewer.js +155 -0
  118. package/lib/orbital/ui/viewer.js.map +1 -0
  119. package/lib/orbital/ui/volumeField.d.ts +49 -0
  120. package/lib/orbital/ui/volumeField.d.ts.map +1 -0
  121. package/lib/orbital/ui/volumeField.js +108 -0
  122. package/lib/orbital/ui/volumeField.js.map +1 -0
  123. package/lib/orbital.d.ts +11 -0
  124. package/lib/orbital.d.ts.map +1 -0
  125. package/lib/orbital.js +11 -0
  126. package/lib/orbital.js.map +1 -0
  127. package/package.json +13 -5
  128. package/src/citation/core/formats.ts +2 -3
  129. package/src/citation/core/segments.ts +11 -3
  130. package/src/core.ts +1 -0
  131. package/src/ecosystem/core/sites.ts +51 -1
  132. package/src/ecosystem/ui/EcosystemLinks.tsx +114 -0
  133. package/src/ecosystem/ui/EcosystemMenu.tsx +8 -135
  134. package/src/ecosystem/ui/SiteTile.tsx +148 -0
  135. package/src/ecosystem/ui/glyphs.tsx +246 -0
  136. package/src/ecosystem/ui/index.ts +4 -0
  137. package/src/ecosystem/ui/marks.tsx +5 -159
  138. package/src/orbital/core/atomicGrid.ts +115 -0
  139. package/src/orbital/core/atomicOrbitals.ts +212 -0
  140. package/src/orbital/core/constants.ts +36 -0
  141. package/src/orbital/core/electronConfiguration.ts +242 -0
  142. package/src/orbital/core/grid.ts +126 -0
  143. package/src/orbital/core/hydrogenic.ts +222 -0
  144. package/src/orbital/core/index.ts +72 -0
  145. package/src/orbital/core/numerics.ts +79 -0
  146. package/src/orbital/core/occupancy.ts +100 -0
  147. package/src/orbital/core/palette.ts +56 -0
  148. package/src/orbital/core/realHarmonics.ts +172 -0
  149. package/src/orbital/core/sample.ts +91 -0
  150. package/src/orbital/core/screening.ts +90 -0
  151. package/src/orbital/ui/AtomicOrbitalCanvas.tsx +181 -0
  152. package/src/orbital/ui/AtomicOrbitalViewer.tsx +150 -0
  153. package/src/orbital/ui/camera.ts +125 -0
  154. package/src/orbital/ui/capability.ts +102 -0
  155. package/src/orbital/ui/index.ts +17 -0
  156. package/src/orbital/ui/renderVolume.ts +190 -0
  157. package/src/orbital/ui/viewer.ts +186 -0
  158. package/src/orbital/ui/volumeField.ts +128 -0
  159. 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
+ }