react-cheminfo 0.18.0 → 0.20.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 (108) hide show
  1. package/lib/about/core/about.d.ts +15 -4
  2. package/lib/about/core/about.d.ts.map +1 -1
  3. package/lib/about/core/about.js +8 -1
  4. package/lib/about/core/about.js.map +1 -1
  5. package/lib/about/core/index.d.ts +1 -0
  6. package/lib/about/core/index.d.ts.map +1 -1
  7. package/lib/about/core/index.js +1 -0
  8. package/lib/about/core/index.js.map +1 -1
  9. package/lib/about/core/repository.d.ts +14 -0
  10. package/lib/about/core/repository.d.ts.map +1 -0
  11. package/lib/about/core/repository.js +21 -0
  12. package/lib/about/core/repository.js.map +1 -0
  13. package/lib/about/ui/AboutBuild.d.ts +5 -4
  14. package/lib/about/ui/AboutBuild.d.ts.map +1 -1
  15. package/lib/about/ui/AboutBuild.js +10 -10
  16. package/lib/about/ui/AboutBuild.js.map +1 -1
  17. package/lib/about/ui/AboutPage.d.ts.map +1 -1
  18. package/lib/about/ui/AboutPage.js +7 -8
  19. package/lib/about/ui/AboutPage.js.map +1 -1
  20. package/lib/about/ui/AboutVersion.d.ts +26 -0
  21. package/lib/about/ui/AboutVersion.d.ts.map +1 -0
  22. package/lib/about/ui/AboutVersion.js +54 -0
  23. package/lib/about/ui/AboutVersion.js.map +1 -0
  24. package/lib/about/ui/index.d.ts +2 -0
  25. package/lib/about/ui/index.d.ts.map +1 -1
  26. package/lib/about/ui/index.js +1 -0
  27. package/lib/about/ui/index.js.map +1 -1
  28. package/lib/build/core/buildInfo.d.ts +44 -0
  29. package/lib/build/core/buildInfo.d.ts.map +1 -1
  30. package/lib/build/core/buildInfo.js +63 -0
  31. package/lib/build/core/buildInfo.js.map +1 -1
  32. package/lib/build/core/index.d.ts +1 -1
  33. package/lib/build/core/index.d.ts.map +1 -1
  34. package/lib/build/core/index.js +1 -1
  35. package/lib/build/core/index.js.map +1 -1
  36. package/lib/build/vite/buildInfo.d.ts.map +1 -1
  37. package/lib/build/vite/buildInfo.js +2 -2
  38. package/lib/build/vite/buildInfo.js.map +1 -1
  39. package/lib/ecosystem/core/sites.d.ts.map +1 -1
  40. package/lib/ecosystem/core/sites.js +12 -0
  41. package/lib/ecosystem/core/sites.js.map +1 -1
  42. package/lib/ecosystem/core/types.d.ts +8 -0
  43. package/lib/ecosystem/core/types.d.ts.map +1 -1
  44. package/lib/molecule3d/core/camera.d.ts +89 -0
  45. package/lib/molecule3d/core/camera.d.ts.map +1 -0
  46. package/lib/molecule3d/core/camera.js +172 -0
  47. package/lib/molecule3d/core/camera.js.map +1 -0
  48. package/lib/molecule3d/core/index.d.ts +2 -0
  49. package/lib/molecule3d/core/index.d.ts.map +1 -1
  50. package/lib/molecule3d/core/index.js +1 -0
  51. package/lib/molecule3d/core/index.js.map +1 -1
  52. package/lib/molecule3d/ui/MoleculeCanvas3D.d.ts.map +1 -1
  53. package/lib/molecule3d/ui/MoleculeCanvas3D.js +14 -37
  54. package/lib/molecule3d/ui/MoleculeCanvas3D.js.map +1 -1
  55. package/lib/molecule3d/ui/camera.d.ts +35 -2
  56. package/lib/molecule3d/ui/camera.d.ts.map +1 -1
  57. package/lib/molecule3d/ui/camera.js +113 -4
  58. package/lib/molecule3d/ui/camera.js.map +1 -1
  59. package/lib/molecule3d/ui/captureScene.d.ts +14 -0
  60. package/lib/molecule3d/ui/captureScene.d.ts.map +1 -0
  61. package/lib/molecule3d/ui/captureScene.js +24 -0
  62. package/lib/molecule3d/ui/captureScene.js.map +1 -0
  63. package/lib/molecule3d/ui/createViewer.d.ts +18 -0
  64. package/lib/molecule3d/ui/createViewer.d.ts.map +1 -0
  65. package/lib/molecule3d/ui/createViewer.js +19 -0
  66. package/lib/molecule3d/ui/createViewer.js.map +1 -0
  67. package/lib/molecule3d/ui/moleculeViewer3DProps.d.ts +21 -0
  68. package/lib/molecule3d/ui/moleculeViewer3DProps.d.ts.map +1 -1
  69. package/lib/molecule3d/ui/useMoleculeScene.d.ts +47 -0
  70. package/lib/molecule3d/ui/useMoleculeScene.d.ts.map +1 -0
  71. package/lib/molecule3d/ui/useMoleculeScene.js +82 -0
  72. package/lib/molecule3d/ui/useMoleculeScene.js.map +1 -0
  73. package/lib/molecule3d/ui/useSharedCamera.d.ts +52 -0
  74. package/lib/molecule3d/ui/useSharedCamera.d.ts.map +1 -0
  75. package/lib/molecule3d/ui/useSharedCamera.js +82 -0
  76. package/lib/molecule3d/ui/useSharedCamera.js.map +1 -0
  77. package/lib/molecule3d/ui/viewer.d.ts +21 -15
  78. package/lib/molecule3d/ui/viewer.d.ts.map +1 -1
  79. package/lib/molecule3d/ui/viewer.js +41 -47
  80. package/lib/molecule3d/ui/viewer.js.map +1 -1
  81. package/lib/molecule3d/ui/viewerSpec.d.ts +17 -0
  82. package/lib/molecule3d/ui/viewerSpec.d.ts.map +1 -0
  83. package/lib/molecule3d/ui/viewerSpec.js +34 -0
  84. package/lib/molecule3d/ui/viewerSpec.js.map +1 -0
  85. package/package.json +1 -1
  86. package/src/about/core/about.ts +25 -5
  87. package/src/about/core/index.ts +1 -0
  88. package/src/about/core/repository.ts +22 -0
  89. package/src/about/ui/AboutBuild.tsx +13 -20
  90. package/src/about/ui/AboutPage.tsx +42 -28
  91. package/src/about/ui/AboutVersion.tsx +95 -0
  92. package/src/about/ui/index.ts +2 -0
  93. package/src/build/core/buildInfo.ts +67 -0
  94. package/src/build/core/index.ts +9 -1
  95. package/src/build/vite/buildInfo.ts +2 -2
  96. package/src/ecosystem/core/sites.ts +12 -0
  97. package/src/ecosystem/core/types.ts +8 -0
  98. package/src/molecule3d/core/camera.ts +212 -0
  99. package/src/molecule3d/core/index.ts +14 -0
  100. package/src/molecule3d/ui/MoleculeCanvas3D.tsx +14 -43
  101. package/src/molecule3d/ui/camera.ts +128 -7
  102. package/src/molecule3d/ui/captureScene.ts +31 -0
  103. package/src/molecule3d/ui/createViewer.ts +24 -0
  104. package/src/molecule3d/ui/moleculeViewer3DProps.ts +21 -0
  105. package/src/molecule3d/ui/useMoleculeScene.ts +141 -0
  106. package/src/molecule3d/ui/useSharedCamera.ts +121 -0
  107. package/src/molecule3d/ui/viewer.ts +45 -50
  108. package/src/molecule3d/ui/viewerSpec.ts +38 -0
@@ -1,5 +1,7 @@
1
1
  export type { AboutBuildProps } from './AboutBuild.tsx';
2
2
  export { AboutBuild } from './AboutBuild.tsx';
3
+ export type { AboutVersionProps } from './AboutVersion.tsx';
4
+ export { AboutVersion } from './AboutVersion.tsx';
3
5
  export type { AboutCitationsProps } from './AboutCitations.tsx';
4
6
  export { AboutCitations } from './AboutCitations.tsx';
5
7
  export type { AboutPageProps } from './AboutPage.tsx';
@@ -25,6 +25,72 @@ export interface BuildInfo {
25
25
  reactCheminfo?: string;
26
26
  }
27
27
 
28
+ /**
29
+ * The version a build reports when the repository has never been released.
30
+ *
31
+ * release-please writes the version into the root `package.json`, so a site
32
+ * whose first release has not happened yet is built from the `0.0.0` it was
33
+ * scaffolded with. That is not a version anybody can look up, so nothing shows
34
+ * it: a page saying `0.0.0` reads as a bug rather than as a release.
35
+ */
36
+ export const UNRELEASED_VERSION = '0.0.0';
37
+
38
+ /**
39
+ * The version to show a reader, if there is one.
40
+ * @param build - What the build published about itself, or `undefined`.
41
+ * @returns The released version, or `undefined` when the build published none
42
+ * or has never been released.
43
+ */
44
+ export function releasedVersion(
45
+ build: BuildInfo | undefined,
46
+ ): string | undefined {
47
+ const version = build?.version;
48
+ if (version === undefined || version === UNRELEASED_VERSION) return undefined;
49
+ return version;
50
+ }
51
+
52
+ /**
53
+ * What the build is called on the page: the release when there is one, and the
54
+ * commit when there is not.
55
+ *
56
+ * A site that has never been released still has to be identifiable in a
57
+ * report, and `0.0.0` identifies nothing while the commit it was built from
58
+ * identifies exactly one build.
59
+ * @param build - What the build published about itself, or `undefined`.
60
+ * @returns The label, or `undefined` when the build published neither.
61
+ */
62
+ export function buildLabel(build: BuildInfo | undefined): string | undefined {
63
+ const version = releasedVersion(build);
64
+ if (version !== undefined) return version;
65
+ const commit = build?.commit;
66
+ return commit === undefined ? undefined : shortCommit(commit);
67
+ }
68
+
69
+ /**
70
+ * When the build was made, as it is read on the badge: the day and the minute.
71
+ *
72
+ * A build a reader is asked to quote is worth little without a date, and two
73
+ * builds of the same day are told apart by the minute. The seconds are left to
74
+ * the hover, where the whole record is.
75
+ * @param builtAt - The ISO instant the build wrote.
76
+ * @returns The instant, in UTC, e.g. `2026-09-16 09:41 UTC`.
77
+ */
78
+ export function buildStamp(builtAt: string): string {
79
+ return `${builtAt.slice(0, MINUTE_LENGTH).replace('T', ' ')} UTC`;
80
+ }
81
+
82
+ /**
83
+ * The whole record in one line, for the hover of a badge that shows one part
84
+ * of it.
85
+ * @param build - What the build published about itself.
86
+ * @returns e.g. `Built 2026-09-16 09:41:07 UTC from commit a1b2c3d`.
87
+ */
88
+ export function buildSummary(build: BuildInfo): string {
89
+ const built = `Built ${formatBuiltAt(build.builtAt)}`;
90
+ if (build.commit === undefined) return built;
91
+ return `${built} from commit ${shortCommit(build.commit)}`;
92
+ }
93
+
28
94
  /**
29
95
  * The commit as it is written for a reader.
30
96
  * @param commit - The full hash.
@@ -45,4 +111,5 @@ export function formatBuiltAt(builtAt: string): string {
45
111
  }
46
112
 
47
113
  const SHORT_COMMIT_LENGTH = 7;
114
+ const MINUTE_LENGTH = 16;
48
115
  const SECOND_LENGTH = 19;
@@ -1,2 +1,10 @@
1
1
  export type { BuildInfo } from './buildInfo.ts';
2
- export { formatBuiltAt, shortCommit } from './buildInfo.ts';
2
+ export {
3
+ UNRELEASED_VERSION,
4
+ buildLabel,
5
+ buildStamp,
6
+ buildSummary,
7
+ formatBuiltAt,
8
+ releasedVersion,
9
+ shortCommit,
10
+ } from './buildInfo.ts';
@@ -15,6 +15,7 @@ import { dirname, join, resolve } from 'node:path';
15
15
  import type { Plugin } from 'vite';
16
16
 
17
17
  import type { BuildInfo } from '../core/buildInfo.ts';
18
+ import { UNRELEASED_VERSION } from '../core/buildInfo.ts';
18
19
 
19
20
  import { commitFromEnvironment, readGitHead } from './gitHead.ts';
20
21
 
@@ -92,7 +93,7 @@ export function resolveBuildInfo(root: string): BuildInfo {
92
93
  const repository = findRepositoryRoot(root);
93
94
 
94
95
  return {
95
- version: readVersion(repository) ?? readVersion(root) ?? UNRELEASED,
96
+ version: readVersion(repository) ?? readVersion(root) ?? UNRELEASED_VERSION,
96
97
  builtAt: buildInstant(),
97
98
  commit: readGitHead(repository) ?? commitFromEnvironment(),
98
99
  reactCheminfo: readOwnVersion(),
@@ -174,5 +175,4 @@ function buildInstant(): string {
174
175
 
175
176
  const RESOLVED_MODULE = `\0${BUILD_INFO_MODULE}`;
176
177
  const OWN_NAME = 'react-cheminfo';
177
- const UNRELEASED = '0.0.0';
178
178
  const SECOND_LENGTH = 19;
@@ -62,6 +62,7 @@ export const ECOSYSTEM_SITES: readonly EcosystemSite[] = [
62
62
  name: { lead: 'openbabel', alt: 'cheminfo', dot: true },
63
63
  host: 'openbabel.cheminfo.org',
64
64
  repository: 'https://github.com/cheminfo/openbabel.cheminfo.org',
65
+ publicRepository: true,
65
66
  tagline: 'Convert a structure between any pair of chemical file formats.',
66
67
  group: 'structures',
67
68
  brand: '#0f766e',
@@ -84,6 +85,7 @@ export const ECOSYSTEM_SITES: readonly EcosystemSite[] = [
84
85
  name: { lead: 'dbe', alt: 'cheminfo', dot: true },
85
86
  host: 'dbe.cheminfo.org',
86
87
  repository: 'https://github.com/cheminfo/dbe.cheminfo.org',
88
+ publicRepository: true,
87
89
  tagline: 'Count rings and double bonds from a formula or a structure.',
88
90
  group: 'basics',
89
91
  brand: '#63123f',
@@ -97,6 +99,7 @@ export const ECOSYSTEM_SITES: readonly EcosystemSite[] = [
97
99
  name: { lead: 'NMR', alt: 'ium' },
98
100
  host: 'www.nmrium.org',
99
101
  repository: 'https://github.com/cheminfo/nmrium',
102
+ publicRepository: true,
100
103
  tagline: 'Process and assign NMR spectra in the browser.',
101
104
  group: 'spectra',
102
105
  brand: '#ea580c',
@@ -133,6 +136,7 @@ export const ECOSYSTEM_SITES: readonly EcosystemSite[] = [
133
136
  name: { lead: 'surge', alt: 'cheminfo', dot: true },
134
137
  host: 'surge.cheminfo.org',
135
138
  repository: 'https://github.com/cheminfo/surge.cheminfo.org',
139
+ publicRepository: true,
136
140
  tagline: 'Every constitutional isomer of a molecular formula.',
137
141
  group: 'structures',
138
142
  brand: '#4338ca',
@@ -144,6 +148,7 @@ export const ECOSYSTEM_SITES: readonly EcosystemSite[] = [
144
148
  name: { lead: 'tex', alt: 'cheminfo', dot: true },
145
149
  host: 'tex.cheminfo.org',
146
150
  repository: 'https://github.com/cheminfo/tex.cheminfo.org',
151
+ publicRepository: true,
147
152
  tagline: 'LaTeX formulas rendered to SVG or PNG from a URL.',
148
153
  group: 'computing',
149
154
  brand: '#a21caf',
@@ -166,6 +171,7 @@ export const ECOSYSTEM_SITES: readonly EcosystemSite[] = [
166
171
  name: { lead: 'regexp', alt: 'cheminfo', dot: true },
167
172
  host: 'regexp.cheminfo.org',
168
173
  repository: 'https://github.com/cheminfo/regexp.cheminfo.org',
174
+ publicRepository: true,
169
175
  tagline: 'Learn regular expressions in a live playground.',
170
176
  group: 'computing',
171
177
  brand: '#1e3a8a',
@@ -177,6 +183,7 @@ export const ECOSYSTEM_SITES: readonly EcosystemSite[] = [
177
183
  name: { lead: 'pdb', alt: 'cheminfo', dot: true },
178
184
  host: 'pdb.cheminfo.org',
179
185
  repository: 'https://github.com/cheminfo/pdb.cheminfo.org',
186
+ publicRepository: true,
180
187
  tagline: 'A fast look at any Protein Data Bank entry.',
181
188
  group: 'research',
182
189
  brand: '#2563eb',
@@ -188,6 +195,7 @@ export const ECOSYSTEM_SITES: readonly EcosystemSite[] = [
188
195
  name: { lead: 'elucidation', alt: 'cheminfo', dot: true },
189
196
  host: 'elucidation.cheminfo.org',
190
197
  repository: 'https://github.com/cheminfo/elucidation.cheminfo.org',
198
+ publicRepository: true,
191
199
  tagline: 'A structure from a 1H NMR spectrum and a formula.',
192
200
  group: 'spectra',
193
201
  brand: '#7e22ce',
@@ -210,6 +218,7 @@ export const ECOSYSTEM_SITES: readonly EcosystemSite[] = [
210
218
  name: { lead: 'Poly', alt: 'Carp' },
211
219
  host: 'polycarp.cheminfo.org',
212
220
  repository: 'https://github.com/cheminfo/polycarp.cheminfo.org',
221
+ publicRepository: true,
213
222
  tagline: 'Predict the microstructure of a radical copolymerisation.',
214
223
  group: 'research',
215
224
  brand: '#701a75',
@@ -243,6 +252,7 @@ export const ECOSYSTEM_SITES: readonly EcosystemSite[] = [
243
252
  name: { lead: 'database', alt: 'cheminfo', dot: true },
244
253
  host: 'database.cheminfo.org',
245
254
  repository: 'https://github.com/cheminfo/database.cheminfo.org',
255
+ publicRepository: true,
246
256
  tagline: 'Query a real chemical dataset in SQL and in Mango.',
247
257
  group: 'research',
248
258
  brand: '#7c2d12',
@@ -254,6 +264,7 @@ export const ECOSYSTEM_SITES: readonly EcosystemSite[] = [
254
264
  name: { lead: 'Symme', alt: 'Try' },
255
265
  host: 'symmetry.cheminfo.org',
256
266
  repository: 'https://github.com/cheminfo/symmetry.cheminfo.org',
267
+ publicRepository: true,
257
268
  tagline: 'Find the symmetry of a molecule, a crystal or a pattern.',
258
269
  group: 'basics',
259
270
  brand: '#9f1239',
@@ -267,6 +278,7 @@ export const ECOSYSTEM_SITES: readonly EcosystemSite[] = [
267
278
  name: { lead: 'osiris', alt: 'cheminfo', dot: true },
268
279
  host: 'osiris.cheminfo.org',
269
280
  repository: 'https://github.com/cheminfo/osiris.cheminfo.org',
281
+ publicRepository: true,
270
282
  tagline: 'Predicted toxicity risks and properties, one molecule or many.',
271
283
  group: 'research',
272
284
  brand: '#854d0e',
@@ -60,6 +60,14 @@ export interface EcosystemSite {
60
60
  host: string;
61
61
  /** Where the sources live, and what a Source link opens. */
62
62
  repository: string;
63
+ /**
64
+ * Whether a visitor can open that repository. A private one is named
65
+ * nowhere: no licence-and-source section, no link from the version, no issue
66
+ * tracker a reader cannot reach. It is stated rather than assumed, so a site
67
+ * whose record forgets it advertises nothing rather than a dead link.
68
+ * @default false
69
+ */
70
+ publicRepository?: boolean;
63
71
  /** One line on what the site does. */
64
72
  tagline: string;
65
73
  /**
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Where the camera stands, in a form a link can carry.
3
+ *
4
+ * A position in ångström would not survive the trip: the molecule a link names
5
+ * is built again on the other side, and a distance that frames a benzene leaves
6
+ * a peptide off screen. So the camera is stored against the scene rather than
7
+ * against the coordinates — a rotation from the front view, a zoom against the
8
+ * distance that frames the whole model, and a target as an offset from the
9
+ * scene centre in bounding-sphere radii. All three read the same whatever the
10
+ * molecule and whatever the size of the canvas showing it.
11
+ */
12
+
13
+ import { clamp } from '../../format/core/clamp.ts';
14
+ import { roundTo } from '../../format/core/roundTo.ts';
15
+ // Deep import on purpose: the codec is the one way a link carries a camera, and
16
+ // `share/core` knows nothing of molecules, so nothing is pulled in by it.
17
+ import type { ShareParamCodec } from '../../share/core/params.ts';
18
+
19
+ /** A rotation, as a unit quaternion `[x, y, z, w]`. */
20
+ export type Molecule3DRotation = readonly [number, number, number, number];
21
+
22
+ /** A point or a direction, `[x, y, z]`. */
23
+ export type Molecule3DVector = readonly [number, number, number];
24
+
25
+ /** Where the camera stands, against the scene rather than its coordinates. */
26
+ export interface Molecule3DCamera {
27
+ /**
28
+ * Rotation of the camera from the front view — looking down -z with y up —
29
+ * as a unit quaternion.
30
+ */
31
+ rotation: Molecule3DRotation;
32
+ /**
33
+ * How close the camera is. `1` frames the whole model, as the reset button
34
+ * does; `2` is twice as close, so the model is drawn twice as large.
35
+ */
36
+ zoom: number;
37
+ /**
38
+ * Where the camera looks, as an offset from the centre of the model, in
39
+ * bounding-sphere radii. `[0, 0, 0]` looks at the centre.
40
+ */
41
+ offset: Molecule3DVector;
42
+ }
43
+
44
+ /** Looking at the centre of the model from the front, framing all of it. */
45
+ export const DEFAULT_MOLECULE_3D_CAMERA: Molecule3DCamera = {
46
+ rotation: [0, 0, 0, 1],
47
+ zoom: 1,
48
+ offset: [0, 0, 0],
49
+ };
50
+
51
+ /** The closest and furthest a link may ask the camera to stand. */
52
+ export const MOLECULE_3D_ZOOM_RANGE = { minimum: 0.01, maximum: 100 } as const;
53
+
54
+ /** Furthest a link may push the target from the centre, in radii. */
55
+ const MAX_OFFSET = 100;
56
+
57
+ /** Decimals kept of a quaternion component: 0.0001 is under a hundredth of a degree. */
58
+ const ROTATION_DECIMALS = 4;
59
+
60
+ /** Decimals kept of the zoom and of the target offset. */
61
+ const POSITION_DECIMALS = 3;
62
+
63
+ /**
64
+ * Write a camera as the text a link carries.
65
+ *
66
+ * The four components of the rotation come first, then the zoom, then the
67
+ * target offset — which is left out entirely while the camera looks at the
68
+ * centre of the model, as it does until someone pans.
69
+ * @param camera - The camera to write.
70
+ * @returns Comma-separated numbers: five, or eight when the camera is panned.
71
+ */
72
+ export function formatMolecule3DCamera(camera: Molecule3DCamera): string {
73
+ const { rotation, zoom, offset } = normalizeMolecule3DCamera(camera);
74
+ const parts: string[] = [];
75
+ for (let index = 0; index < 4; index++) {
76
+ parts.push(String(roundTo(rotation[index] ?? 0, ROTATION_DECIMALS)));
77
+ }
78
+ parts.push(String(roundTo(zoom, POSITION_DECIMALS)));
79
+ if (offset[0] !== 0 || offset[1] !== 0 || offset[2] !== 0) {
80
+ for (const value of offset) {
81
+ parts.push(String(roundTo(value, POSITION_DECIMALS)));
82
+ }
83
+ }
84
+ return parts.join(',');
85
+ }
86
+
87
+ /**
88
+ * Read a camera out of the text a link carries.
89
+ *
90
+ * A rotation that is not a rotation — four zeros, a stray word — is what makes
91
+ * the whole value unusable; everything else is brought back into range rather
92
+ * than rejected, so a link written against an older range still opens.
93
+ * @param raw - The text of the parameter.
94
+ * @returns The camera, or `null` when the text does not describe one.
95
+ */
96
+ export function parseMolecule3DCamera(raw: string): Molecule3DCamera | null {
97
+ const parts = raw.split(',');
98
+ if (parts.length !== 5 && parts.length !== 8) return null;
99
+ const numbers: number[] = [];
100
+ for (const part of parts) {
101
+ const value = Number(part.trim());
102
+ if (!Number.isFinite(value)) return null;
103
+ numbers.push(value);
104
+ }
105
+ const rotation = normalizeRotation([
106
+ numbers[0] ?? 0,
107
+ numbers[1] ?? 0,
108
+ numbers[2] ?? 0,
109
+ numbers[3] ?? 1,
110
+ ]);
111
+ if (rotation === null) return null;
112
+ return {
113
+ rotation,
114
+ zoom: clampZoom(numbers[4] ?? 1),
115
+ offset: [
116
+ clampOffset(numbers[5] ?? 0),
117
+ clampOffset(numbers[6] ?? 0),
118
+ clampOffset(numbers[7] ?? 0),
119
+ ],
120
+ };
121
+ }
122
+
123
+ /**
124
+ * A camera with its rotation made a unit quaternion and its numbers in range.
125
+ * @param camera - The camera to repair.
126
+ * @returns A camera safe to hand the viewer; the default when the rotation is
127
+ * degenerate.
128
+ */
129
+ export function normalizeMolecule3DCamera(
130
+ camera: Molecule3DCamera,
131
+ ): Molecule3DCamera {
132
+ const rotation = normalizeRotation(camera.rotation);
133
+ if (rotation === null) return DEFAULT_MOLECULE_3D_CAMERA;
134
+ return {
135
+ rotation,
136
+ zoom: clampZoom(camera.zoom),
137
+ offset: [
138
+ clampOffset(camera.offset[0]),
139
+ clampOffset(camera.offset[1]),
140
+ clampOffset(camera.offset[2]),
141
+ ],
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Whether two cameras are the same once written into a link.
147
+ *
148
+ * What a link carries is rounded, so two cameras a drag apart by less than the
149
+ * rounding are one camera as far as the address is concerned — which is what
150
+ * keeps a settling animation from rewriting it a dozen times.
151
+ * @param first - One camera, or `null` for none.
152
+ * @param second - The other.
153
+ * @returns True when a link would carry the same text for both.
154
+ */
155
+ export function sameMolecule3DCamera(
156
+ first: Molecule3DCamera | null,
157
+ second: Molecule3DCamera | null,
158
+ ): boolean {
159
+ if (first === null || second === null) return first === second;
160
+ return formatMolecule3DCamera(first) === formatMolecule3DCamera(second);
161
+ }
162
+
163
+ /**
164
+ * The codec a site mirrors the camera in the address with.
165
+ *
166
+ * The default framing is written as nothing at all, so the address of a page
167
+ * nobody has turned yet stays a plain link.
168
+ * @returns The codec, whose value is `null` while the camera is where a reset
169
+ * would leave it.
170
+ */
171
+ export function molecule3DCameraParam(): ShareParamCodec<Molecule3DCamera | null> {
172
+ return {
173
+ parse(raw) {
174
+ return raw === null ? null : parseMolecule3DCamera(raw);
175
+ },
176
+ serialize(value) {
177
+ if (value === null) return null;
178
+ const text = formatMolecule3DCamera(value);
179
+ return text === formatMolecule3DCamera(DEFAULT_MOLECULE_3D_CAMERA)
180
+ ? null
181
+ : text;
182
+ },
183
+ };
184
+ }
185
+
186
+ function normalizeRotation(
187
+ rotation: Molecule3DRotation,
188
+ ): Molecule3DRotation | null {
189
+ let squared = 0;
190
+ for (let index = 0; index < 4; index++) {
191
+ const value = rotation[index] ?? 0;
192
+ if (!Number.isFinite(value)) return null;
193
+ squared += value * value;
194
+ }
195
+ if (squared <= 0) return null;
196
+ const scale = 1 / Math.sqrt(squared);
197
+ return [
198
+ rotation[0] * scale,
199
+ rotation[1] * scale,
200
+ rotation[2] * scale,
201
+ rotation[3] * scale,
202
+ ];
203
+ }
204
+
205
+ function clampZoom(zoom: number): number {
206
+ const { minimum, maximum } = MOLECULE_3D_ZOOM_RANGE;
207
+ return clamp(zoom, minimum, maximum, 1);
208
+ }
209
+
210
+ function clampOffset(value: number): number {
211
+ return clamp(value, -MAX_OFFSET, MAX_OFFSET, 0);
212
+ }
@@ -1,3 +1,17 @@
1
+ export type {
2
+ Molecule3DCamera,
3
+ Molecule3DRotation,
4
+ Molecule3DVector,
5
+ } from './camera.ts';
6
+ export {
7
+ DEFAULT_MOLECULE_3D_CAMERA,
8
+ MOLECULE_3D_ZOOM_RANGE,
9
+ formatMolecule3DCamera,
10
+ molecule3DCameraParam,
11
+ normalizeMolecule3DCamera,
12
+ parseMolecule3DCamera,
13
+ sameMolecule3DCamera,
14
+ } from './camera.ts';
1
15
  export type { ImageSize } from './exportImage.ts';
2
16
  export { dataUriBytes, rasterSvgMarkup } from './exportImage.ts';
3
17
  export type { Molecule3DGesture, Molecule3DGestureKey } from './gestures.ts';
@@ -6,10 +6,9 @@
6
6
  */
7
7
 
8
8
  import type { CSSProperties, ReactElement } from 'react';
9
- import { useCallback, useEffect, useRef, useState } from 'react';
9
+ import { useEffect, useRef, useState } from 'react';
10
10
 
11
11
  import type { Measurement, MeasurementKind } from '../core/measurement.ts';
12
- import type { Molecule3DFile } from '../core/settings.ts';
13
12
  import {
14
13
  normalizeMolecule3DSettings,
15
14
  resolveMolecule3DTools,
@@ -19,13 +18,13 @@ import { Molecule3DExport } from './Molecule3DExport.tsx';
19
18
  import { Molecule3DOptions } from './Molecule3DOptions.tsx';
20
19
  import { Molecule3DToolbar } from './Molecule3DToolbar.tsx';
21
20
  import { DEFAULT_SPIN_SPEED } from './camera.ts';
22
- import { drawScene } from './drawScene.ts';
21
+ import { createMolecule3DViewer } from './createViewer.ts';
23
22
  import type { MoleculeViewer3DProps } from './moleculeViewer3DProps.ts';
24
23
  import { useControlledState } from './useControlledState.ts';
25
24
  import { useImageExport } from './useImageExport.ts';
25
+ import { useMoleculeScene } from './useMoleculeScene.ts';
26
26
  import { usePolarSurfaceArea } from './usePolarSurfaceArea.ts';
27
27
  import type { Molecule3DViewer } from './viewer.ts';
28
- import { createMolecule3DViewer } from './viewer.ts';
29
28
 
30
29
  /** Props of {@link MoleculeCanvas3D}. */
31
30
  export interface MoleculeCanvas3DProps extends Omit<
@@ -84,11 +83,6 @@ export function MoleculeCanvas3D(props: MoleculeCanvas3DProps): ReactElement {
84
83
  // effect keyed on it runs again, in order, after the viewer exists.
85
84
  const [container, setContainer] = useState<HTMLDivElement | null>(null);
86
85
  const viewerRef = useRef<Molecule3DViewer | null>(null);
87
- const framedRef = useRef<{
88
- molfile: Molecule3DFile;
89
- viewer: Molecule3DViewer;
90
- } | null>(null);
91
-
92
86
  // The viewer is created once, so what its callbacks read is kept fresh here.
93
87
  const latest = useRef({
94
88
  measurements,
@@ -126,37 +120,6 @@ export function MoleculeCanvas3D(props: MoleculeCanvas3DProps): ReactElement {
126
120
  };
127
121
  }, [container]);
128
122
 
129
- // One animation frame of coalescing, so dragging a slider costs one rebuild.
130
- useEffect(() => {
131
- const viewer = viewerRef.current;
132
- if (container === null || viewer === null) return;
133
- let cancelled = false;
134
- const frame = requestAnimationFrame(() => {
135
- // Framed on a new molecule or a new viewer, never on a restyle: a slider
136
- // that snapped the camera back would undo the reader's orientation.
137
- const framed = framedRef.current;
138
- const isNew =
139
- molfile !== null &&
140
- (framed?.molfile !== molfile || framed.viewer !== viewer);
141
- framedRef.current = molfile === null ? null : { molfile, viewer };
142
- const frameCamera = isNew ? frameNewMolecule : 'none';
143
- void drawScene(viewer, molfile, settings, measurements, frameCamera)
144
- .then(() => {
145
- if (!cancelled) latest.current.onFailureChange(null);
146
- })
147
- .catch((error: unknown) => {
148
- if (cancelled) return;
149
- latest.current.onFailureChange(
150
- error instanceof Error ? error.message : String(error),
151
- );
152
- });
153
- });
154
- return () => {
155
- cancelled = true;
156
- cancelAnimationFrame(frame);
157
- };
158
- }, [container, molfile, frameNewMolecule, settings, measurements]);
159
-
160
123
  useEffect(() => {
161
124
  void viewerRef.current?.setSpin(spinning, spinSpeed);
162
125
  }, [container, spinning, spinSpeed]);
@@ -165,9 +128,17 @@ export function MoleculeCanvas3D(props: MoleculeCanvas3DProps): ReactElement {
165
128
  void viewerRef.current?.setMeasureKind(measureKind);
166
129
  }, [container, measureKind]);
167
130
 
168
- const resetView = useCallback(() => {
169
- void viewerRef.current?.resetCamera();
170
- }, []);
131
+ const { resetView } = useMoleculeScene({
132
+ container,
133
+ viewerRef,
134
+ molfile,
135
+ frameNewMolecule,
136
+ settings,
137
+ measurements,
138
+ spinning,
139
+ onFailureChange,
140
+ camera: props,
141
+ });
171
142
 
172
143
  const { canvasSize, exportImage } = useImageExport(
173
144
  container,