react-cheminfo 0.17.0 → 0.19.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 (187) hide show
  1. package/lib/about/core/about.d.ts +9 -0
  2. package/lib/about/core/about.d.ts.map +1 -1
  3. package/lib/about/core/about.js +1 -0
  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 +25 -0
  21. package/lib/about/ui/AboutVersion.d.ts.map +1 -0
  22. package/lib/about/ui/AboutVersion.js +31 -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 +16 -0
  29. package/lib/build/core/buildInfo.d.ts.map +1 -1
  30. package/lib/build/core/buildInfo.js +21 -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/lib/structure/core/editorGuide.d.ts +69 -0
  86. package/lib/structure/core/editorGuide.d.ts.map +1 -0
  87. package/lib/structure/core/editorGuide.js +162 -0
  88. package/lib/structure/core/editorGuide.js.map +1 -0
  89. package/lib/structure/core/editorToolbar.d.ts +45 -0
  90. package/lib/structure/core/editorToolbar.d.ts.map +1 -0
  91. package/lib/structure/core/editorToolbar.js +132 -0
  92. package/lib/structure/core/editorToolbar.js.map +1 -0
  93. package/lib/structure/core/editorToolbarGeometry.d.ts +36 -0
  94. package/lib/structure/core/editorToolbarGeometry.d.ts.map +1 -0
  95. package/lib/structure/core/editorToolbarGeometry.js +61 -0
  96. package/lib/structure/core/editorToolbarGeometry.js.map +1 -0
  97. package/lib/structure/core/index.d.ts +6 -0
  98. package/lib/structure/core/index.d.ts.map +1 -1
  99. package/lib/structure/core/index.js +3 -0
  100. package/lib/structure/core/index.js.map +1 -1
  101. package/lib/structure/core/pendingCall.d.ts +21 -0
  102. package/lib/structure/core/pendingCall.d.ts.map +1 -0
  103. package/lib/structure/core/pendingCall.js +37 -0
  104. package/lib/structure/core/pendingCall.js.map +1 -0
  105. package/lib/structure/ui/EditorCanvas.d.ts +1 -16
  106. package/lib/structure/ui/EditorCanvas.d.ts.map +1 -1
  107. package/lib/structure/ui/EditorCanvas.js +5 -18
  108. package/lib/structure/ui/EditorCanvas.js.map +1 -1
  109. package/lib/structure/ui/EditorHelpButton.d.ts +22 -0
  110. package/lib/structure/ui/EditorHelpButton.d.ts.map +1 -0
  111. package/lib/structure/ui/EditorHelpButton.js +38 -0
  112. package/lib/structure/ui/EditorHelpButton.js.map +1 -0
  113. package/lib/structure/ui/KeyCaps.d.ts +12 -0
  114. package/lib/structure/ui/KeyCaps.d.ts.map +1 -0
  115. package/lib/structure/ui/KeyCaps.js +29 -0
  116. package/lib/structure/ui/KeyCaps.js.map +1 -0
  117. package/lib/structure/ui/StructureEditor.d.ts +9 -2
  118. package/lib/structure/ui/StructureEditor.d.ts.map +1 -1
  119. package/lib/structure/ui/StructureEditor.js +17 -24
  120. package/lib/structure/ui/StructureEditor.js.map +1 -1
  121. package/lib/structure/ui/StructureEditorHelp.d.ts +18 -0
  122. package/lib/structure/ui/StructureEditorHelp.d.ts.map +1 -0
  123. package/lib/structure/ui/StructureEditorHelp.js +68 -0
  124. package/lib/structure/ui/StructureEditorHelp.js.map +1 -0
  125. package/lib/structure/ui/ToolbarTooltip.d.ts +20 -0
  126. package/lib/structure/ui/ToolbarTooltip.d.ts.map +1 -0
  127. package/lib/structure/ui/ToolbarTooltip.js +49 -0
  128. package/lib/structure/ui/ToolbarTooltip.js.map +1 -0
  129. package/lib/structure/ui/editorChange.d.ts +52 -0
  130. package/lib/structure/ui/editorChange.d.ts.map +1 -0
  131. package/lib/structure/ui/editorChange.js +34 -0
  132. package/lib/structure/ui/editorChange.js.map +1 -0
  133. package/lib/structure/ui/index.d.ts +3 -1
  134. package/lib/structure/ui/index.d.ts.map +1 -1
  135. package/lib/structure/ui/index.js +1 -0
  136. package/lib/structure/ui/index.js.map +1 -1
  137. package/lib/structure/ui/useToolbarFloor.d.ts.map +1 -1
  138. package/lib/structure/ui/useToolbarFloor.js +6 -45
  139. package/lib/structure/ui/useToolbarFloor.js.map +1 -1
  140. package/lib/structure/ui/useToolbarHover.d.ts +28 -0
  141. package/lib/structure/ui/useToolbarHover.d.ts.map +1 -0
  142. package/lib/structure/ui/useToolbarHover.js +96 -0
  143. package/lib/structure/ui/useToolbarHover.js.map +1 -0
  144. package/lib/structure/ui/watchToolbar.d.ts +17 -0
  145. package/lib/structure/ui/watchToolbar.d.ts.map +1 -0
  146. package/lib/structure/ui/watchToolbar.js +56 -0
  147. package/lib/structure/ui/watchToolbar.js.map +1 -0
  148. package/package.json +1 -1
  149. package/src/about/core/about.ts +11 -0
  150. package/src/about/core/index.ts +1 -0
  151. package/src/about/core/repository.ts +22 -0
  152. package/src/about/ui/AboutBuild.tsx +13 -20
  153. package/src/about/ui/AboutPage.tsx +30 -22
  154. package/src/about/ui/AboutVersion.tsx +67 -0
  155. package/src/about/ui/index.ts +2 -0
  156. package/src/build/core/buildInfo.ts +24 -0
  157. package/src/build/core/index.ts +6 -1
  158. package/src/build/vite/buildInfo.ts +2 -2
  159. package/src/ecosystem/core/sites.ts +12 -0
  160. package/src/ecosystem/core/types.ts +8 -0
  161. package/src/molecule3d/core/camera.ts +212 -0
  162. package/src/molecule3d/core/index.ts +14 -0
  163. package/src/molecule3d/ui/MoleculeCanvas3D.tsx +14 -43
  164. package/src/molecule3d/ui/camera.ts +128 -7
  165. package/src/molecule3d/ui/captureScene.ts +31 -0
  166. package/src/molecule3d/ui/createViewer.ts +24 -0
  167. package/src/molecule3d/ui/moleculeViewer3DProps.ts +21 -0
  168. package/src/molecule3d/ui/useMoleculeScene.ts +141 -0
  169. package/src/molecule3d/ui/useSharedCamera.ts +121 -0
  170. package/src/molecule3d/ui/viewer.ts +45 -50
  171. package/src/molecule3d/ui/viewerSpec.ts +38 -0
  172. package/src/structure/core/editorGuide.ts +221 -0
  173. package/src/structure/core/editorToolbar.ts +175 -0
  174. package/src/structure/core/editorToolbarGeometry.ts +80 -0
  175. package/src/structure/core/index.ts +29 -0
  176. package/src/structure/core/pendingCall.ts +59 -0
  177. package/src/structure/ui/EditorCanvas.tsx +9 -35
  178. package/src/structure/ui/EditorHelpButton.tsx +70 -0
  179. package/src/structure/ui/KeyCaps.tsx +40 -0
  180. package/src/structure/ui/StructureEditor.tsx +39 -21
  181. package/src/structure/ui/StructureEditorHelp.tsx +136 -0
  182. package/src/structure/ui/ToolbarTooltip.tsx +95 -0
  183. package/src/structure/ui/editorChange.ts +81 -0
  184. package/src/structure/ui/index.ts +5 -1
  185. package/src/structure/ui/useToolbarFloor.ts +7 -46
  186. package/src/structure/ui/useToolbarHover.ts +134 -0
  187. package/src/structure/ui/watchToolbar.ts +59 -0
@@ -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,
@@ -1,11 +1,17 @@
1
1
  /**
2
- * The two camera moves the molecule viewer needs: frame whatever is on screen,
3
- * and the slow spin that makes a still picture of a 3D molecule readable.
2
+ * The camera moves the molecule viewer needs: frame whatever is on screen, the
3
+ * slow spin that makes a still picture of a 3D molecule readable, and the two
4
+ * directions between molstar's camera and the scene-relative camera a link
5
+ * carries.
4
6
  */
5
7
 
6
- import { Vec3 } from 'molstar/lib/mol-math/linear-algebra.js';
8
+ import type { Camera } from 'molstar/lib/mol-canvas3d/camera.js';
9
+ import { Quat, Vec3 } from 'molstar/lib/mol-math/linear-algebra.js';
7
10
  import type { PluginContext } from 'molstar/lib/mol-plugin/context.js';
8
11
 
12
+ import type { Molecule3DCamera } from '../core/camera.ts';
13
+ import { normalizeMolecule3DCamera } from '../core/camera.ts';
14
+
9
15
  /** Transition length used when the caller does not pick one, milliseconds. */
10
16
  export const DEFAULT_CAMERA_DURATION = 250;
11
17
 
@@ -32,6 +38,21 @@ const MIN_FRAMING_RADIUS = 0.5;
32
38
  const FRONT_UP = Vec3.create(0, 1, 0);
33
39
  const FRONT_DIRECTION = Vec3.create(0, 0, -1);
34
40
 
41
+ /** The camera's own axes, which the stored rotation turns into world ones. */
42
+ const CAMERA_UP = Vec3.create(0, 1, 0);
43
+ const CAMERA_BACK = Vec3.create(0, 0, 1);
44
+
45
+ /**
46
+ * The radius a reset frames, which is the length every stored camera is
47
+ * measured against — so reading a camera and applying it are the same move in
48
+ * opposite directions.
49
+ * @param radius - Radius of the scene's visible bounding sphere.
50
+ * @returns The framed radius.
51
+ */
52
+ function framedRadius(radius: number): number {
53
+ return Math.max(radius * (1 + FRAMING_MARGIN), MIN_FRAMING_RADIUS);
54
+ }
55
+
35
56
  /**
36
57
  * Frame everything currently in the scene.
37
58
  *
@@ -54,10 +75,7 @@ export function resetCamera(
54
75
  durationMs: durationMilliseconds,
55
76
  snapshot: (scene, camera) => {
56
77
  const { center, radius } = scene.boundingSphereVisible;
57
- const framed = Math.max(
58
- radius * (1 + FRAMING_MARGIN),
59
- MIN_FRAMING_RADIUS,
60
- );
78
+ const framed = framedRadius(radius);
61
79
  return fromFront
62
80
  ? camera.getFocus(center, framed, FRONT_UP, FRONT_DIRECTION)
63
81
  : camera.getFocus(center, framed);
@@ -84,3 +102,106 @@ export function setSpin(
84
102
  },
85
103
  });
86
104
  }
105
+
106
+ /**
107
+ * Where the camera stands, against the scene rather than its coordinates.
108
+ *
109
+ * An empty scene has no camera to read: a canvas showing nothing still has one,
110
+ * pointing at the origin from molstar's default distance, and measuring that
111
+ * against a sphere of no radius describes a view that means nothing — which
112
+ * would then be applied to the first molecule drawn.
113
+ * @param plugin - The molstar context.
114
+ * @returns The camera, or `null` when there is no canvas or nothing to measure
115
+ * it against.
116
+ */
117
+ export function readCamera(plugin: PluginContext): Molecule3DCamera | null {
118
+ const canvas = plugin.canvas3d;
119
+ if (canvas === undefined) return null;
120
+ const { center, radius } = canvas.boundingSphereVisible;
121
+ if (!(radius > 0)) return null;
122
+ const framed = framedRadius(radius);
123
+ const camera = canvas.camera;
124
+ const { position, target, up } = camera.getSnapshot();
125
+ const back = Vec3.sub(Vec3.zero(), position, target);
126
+ const distance = Vec3.magnitude(back);
127
+ if (distance <= 0) return null;
128
+ Vec3.scale(back, back, 1 / distance);
129
+ // The trackball keeps up and back close to square, never exactly so.
130
+ const trueUp = Vec3.scaleAndAdd(Vec3.zero(), up, back, -Vec3.dot(up, back));
131
+ if (Vec3.magnitude(trueUp) <= 0) return null;
132
+ Vec3.normalize(trueUp, trueUp);
133
+ const right = Vec3.cross(Vec3.zero(), trueUp, back);
134
+ const rotation = Quat.fromBasis(Quat.identity(), right, trueUp, back);
135
+ const offset = Vec3.sub(Vec3.zero(), target, center);
136
+ Vec3.scale(offset, offset, 1 / framed);
137
+ return normalizeMolecule3DCamera({
138
+ rotation: [
139
+ rotation[0] ?? 0,
140
+ rotation[1] ?? 0,
141
+ rotation[2] ?? 0,
142
+ rotation[3] ?? 1,
143
+ ],
144
+ zoom: camera.getTargetDistance(framed) / distance,
145
+ offset: [offset[0] ?? 0, offset[1] ?? 0, offset[2] ?? 0],
146
+ });
147
+ }
148
+
149
+ /**
150
+ * Put the camera back where a link says it stood.
151
+ *
152
+ * Ordered through `requestCameraReset` for the same reason {@link resetCamera}
153
+ * is: the bounding sphere the camera is measured against is only refreshed when
154
+ * the canvas commits the scene, so the move is computed then rather than now.
155
+ * @param plugin - The molstar context.
156
+ * @param camera - Where to stand.
157
+ * @param durationMilliseconds - Transition length. Pass 0 for an instant jump.
158
+ */
159
+ export function applyCamera(
160
+ plugin: PluginContext,
161
+ camera: Molecule3DCamera,
162
+ durationMilliseconds = 0,
163
+ ): void {
164
+ const { rotation, zoom, offset } = normalizeMolecule3DCamera(camera);
165
+ const turn = Quat.create(rotation[0], rotation[1], rotation[2], rotation[3]);
166
+ plugin.canvas3d?.requestCameraReset({
167
+ durationMs: durationMilliseconds,
168
+ snapshot: (scene, current) => {
169
+ const { center, radius } = scene.boundingSphereVisible;
170
+ const framed = framedRadius(radius);
171
+ const up = Vec3.transformQuat(Vec3.zero(), CAMERA_UP, turn);
172
+ const back = Vec3.transformQuat(Vec3.zero(), CAMERA_BACK, turn);
173
+ const target = Vec3.create(
174
+ (center[0] ?? 0) + offset[0] * framed,
175
+ (center[1] ?? 0) + offset[1] * framed,
176
+ (center[2] ?? 0) + offset[2] * framed,
177
+ );
178
+ const distance = current.getTargetDistance(framed) / zoom;
179
+ const position = Vec3.scaleAndAdd(Vec3.zero(), target, back, distance);
180
+ return { target, position, up, radius: framed };
181
+ },
182
+ });
183
+ }
184
+
185
+ /**
186
+ * Follow the camera, however it moves — a drag, the reset button, the spin.
187
+ * @param plugin - The molstar context.
188
+ * @param listener - Called after every move, with the camera it left.
189
+ * @returns A function that stops calling the listener.
190
+ */
191
+ export function watchCamera(
192
+ plugin: PluginContext,
193
+ listener: (camera: Molecule3DCamera | null) => void,
194
+ ): () => void {
195
+ const camera: Camera | undefined = plugin.canvas3d?.camera;
196
+ if (camera === undefined) return noop;
197
+ const subscription = camera.changed.subscribe(() => {
198
+ listener(readCamera(plugin));
199
+ });
200
+ return () => {
201
+ subscription.unsubscribe();
202
+ };
203
+ }
204
+
205
+ function noop(): void {
206
+ // Nothing was subscribed, so there is nothing to unsubscribe.
207
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * A picture of the scene, rendered off screen at a size of its own.
3
+ */
4
+
5
+ import type { PluginContext } from 'molstar/lib/mol-plugin/context.js';
6
+
7
+ import type { ImageSize } from '../core/exportImage.ts';
8
+
9
+ /**
10
+ * Render the scene without its selection highlights or its axes.
11
+ * @param plugin - The molstar context.
12
+ * @param size - Pixel size of the picture.
13
+ * @returns The PNG as a data URI.
14
+ * @throws {Error} When the plugin has no screenshot helper.
15
+ */
16
+ export async function captureScene(
17
+ plugin: PluginContext,
18
+ size: ImageSize,
19
+ ): Promise<string> {
20
+ const helper = plugin.helpers.viewportScreenshot;
21
+ if (helper === undefined) {
22
+ throw new Error('This viewer cannot take a picture of its scene.');
23
+ }
24
+ helper.behaviors.values.next({
25
+ ...helper.values,
26
+ resolution: { name: 'custom', params: size },
27
+ axes: { name: 'off', params: {} },
28
+ transparent: false,
29
+ });
30
+ return helper.getImageDataUri();
31
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * How a molecule viewer comes into being, and why it does so synchronously.
3
+ *
4
+ * React 19 runs an effect, its cleanup and the effect again on every mount in
5
+ * development, so an awaited constructor hands the cleanup nothing to dispose
6
+ * and leaks a WebGL context per mount. Returning the handle immediately means
7
+ * `dispose()` can always be called; the work is queued behind `ready`.
8
+ */
9
+
10
+ import { Molecule3DViewer } from './viewer.ts';
11
+ import type { Molecule3DViewerOptions } from './viewerTypes.ts';
12
+
13
+ /**
14
+ * Create a viewer inside `container` and start initialising it.
15
+ * @param container - A positioned element; molstar inserts its canvas into it.
16
+ * @param options - See {@link Molecule3DViewerOptions}.
17
+ * @returns A handle that is safe to dispose immediately.
18
+ */
19
+ export function createMolecule3DViewer(
20
+ container: HTMLElement,
21
+ options: Molecule3DViewerOptions = {},
22
+ ): Molecule3DViewer {
23
+ return new Molecule3DViewer(container, options);
24
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ReactNode } from 'react';
2
2
 
3
3
  import type { ViewerCapability } from '../../orbital/ui/capability.ts';
4
+ import type { Molecule3DCamera } from '../core/camera.ts';
4
5
  import type { Measurement } from '../core/measurement.ts';
5
6
  import type {
6
7
  Molecule3DFile,
@@ -78,6 +79,26 @@ export interface MoleculeViewer3DProps {
78
79
  * @default undefined
79
80
  */
80
81
  onMeasurementsChange?: (measurements: readonly Measurement[]) => void;
82
+ /**
83
+ * Where the camera stands when the first molecule is framed — what a shared
84
+ * link carries. The camera belongs to whoever is dragging it afterwards, so
85
+ * this is read once and a later change is ignored; the viewer reports every
86
+ * move through `onCameraChange` instead.
87
+ * @default null — the molecule is framed as `frameNewMolecule` says
88
+ */
89
+ initialCamera?: Molecule3DCamera | null;
90
+ /**
91
+ * Called once the camera has come to rest, with where it stands. Not called
92
+ * while the model is spinning: a spinning camera has no orientation to keep.
93
+ * @default undefined
94
+ */
95
+ onCameraChange?: (camera: Molecule3DCamera) => void;
96
+ /**
97
+ * How long the camera must be still before `onCameraChange` is called,
98
+ * milliseconds, so one drag is one report rather than one per frame.
99
+ * @default 400
100
+ */
101
+ cameraSettleDelay?: number;
81
102
  /**
82
103
  * Name of an exported image, without its extension.
83
104
  * @default 'molecule'