@ccp-nc/crystvis-js 0.6.1 → 0.7.1

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 (64) hide show
  1. package/.mocharc.yml +4 -0
  2. package/.vscode/settings.json +5 -1
  3. package/CHANGELOG.md +54 -16
  4. package/README.md +75 -2
  5. package/audit.txt +12 -25
  6. package/changes.txt +1 -26
  7. package/demo/demo.css +15 -10
  8. package/demo/index.html +32 -5
  9. package/demo/main.js +149 -42
  10. package/lib/formats/magres.js +156 -1
  11. package/lib/model.js +31 -0
  12. package/lib/modelview.js +24 -0
  13. package/lib/render.js +97 -2
  14. package/lib/selbox.js +18 -4
  15. package/lib/visualizer.js +237 -1
  16. package/outdated.txt +9 -12
  17. package/package.json +7 -5
  18. package/test/data/hf_mu_test.magres +62 -0
  19. package/test/data/hf_test.magres +61 -0
  20. package/test/data/optimized_muon_65-hf.magres +1424 -0
  21. package/test/loader.js +115 -1
  22. package/test/model.js +118 -0
  23. package/test/setup-dom.cjs +147 -0
  24. package/test/visualizer.js +475 -0
  25. package/docs/data/search.json +0 -1
  26. package/docs/fonts/Inconsolata-Regular.ttf +0 -0
  27. package/docs/fonts/OpenSans-Regular.ttf +0 -0
  28. package/docs/fonts/WorkSans-Bold.ttf +0 -0
  29. package/docs/index.html +0 -10
  30. package/docs/lib_model.module_js-AtomImage.html +0 -3
  31. package/docs/lib_model.module_js-BondImage.html +0 -3
  32. package/docs/lib_model.module_js-Model.html +0 -3
  33. package/docs/lib_model.module_js.html +0 -3
  34. package/docs/lib_modelview.module_js-ModelView.html +0 -12
  35. package/docs/lib_modelview.module_js.html +0 -3
  36. package/docs/lib_visualizer.module_js-CrystVis.html +0 -3
  37. package/docs/lib_visualizer.module_js.html +0 -3
  38. package/docs/model.js.html +0 -2160
  39. package/docs/modelview.js.html +0 -449
  40. package/docs/scripts/core.js +0 -726
  41. package/docs/scripts/core.min.js +0 -23
  42. package/docs/scripts/resize.js +0 -90
  43. package/docs/scripts/search.js +0 -265
  44. package/docs/scripts/search.min.js +0 -6
  45. package/docs/scripts/third-party/Apache-License-2.0.txt +0 -202
  46. package/docs/scripts/third-party/fuse.js +0 -9
  47. package/docs/scripts/third-party/hljs-line-num-original.js +0 -369
  48. package/docs/scripts/third-party/hljs-line-num.js +0 -1
  49. package/docs/scripts/third-party/hljs-original.js +0 -5171
  50. package/docs/scripts/third-party/hljs.js +0 -1
  51. package/docs/scripts/third-party/popper.js +0 -5
  52. package/docs/scripts/third-party/tippy.js +0 -1
  53. package/docs/scripts/third-party/tocbot.js +0 -672
  54. package/docs/scripts/third-party/tocbot.min.js +0 -1
  55. package/docs/styles/clean-jsdoc-theme-base.css +0 -1159
  56. package/docs/styles/clean-jsdoc-theme-dark.css +0 -412
  57. package/docs/styles/clean-jsdoc-theme-light.css +0 -482
  58. package/docs/styles/clean-jsdoc-theme-scrollbar.css +0 -30
  59. package/docs/styles/clean-jsdoc-theme-without-scrollbar.min.css +0 -1
  60. package/docs/styles/clean-jsdoc-theme.min.css +0 -1
  61. package/docs/tutorial-Events.html +0 -13
  62. package/docs/tutorial-Queries.html +0 -16
  63. package/docs/tutorial-ThreejsMigration.html +0 -25
  64. package/docs/visualizer.js.html +0 -574
@@ -120,6 +120,146 @@ const MagresParsers = {
120
120
  isc: parseTwoAtomLine
121
121
  };
122
122
 
123
+ /**
124
+ * Parse the magres_old block to extract hyperfine (HF) tensors and the
125
+ * magnetogyric ratios table.
126
+ *
127
+ * The magres_old block is free-text output produced by older CASTEP versions.
128
+ * Each atom section looks like:
129
+ *
130
+ * ============
131
+ * Atom: H 1
132
+ * ============
133
+ * H 1 Coordinates ...
134
+ *
135
+ * TOTAL tensor
136
+ *
137
+ * a11 a12 a13
138
+ * a21 a22 a23
139
+ * a31 a32 a33
140
+ *
141
+ * H 1 Eigenvalue A_xx ...
142
+ * ...
143
+ * H 1 Iso: ... (MHz)
144
+ *
145
+ * The tensor values are in MHz.
146
+ *
147
+ * The header table looks like either:
148
+ * H Isotope 1 GAMMA = 2.6752E+08 rad T-1 s-1
149
+ * H:Mu User defined. GAMMA = 8.5162E+08 rad T-1 s-1
150
+ *
151
+ * @param {string} text Contents of the magres_old block
152
+ * @param {Array} mlabels Array of [species, index] pairs for each atom
153
+ * @param {int} N Number of atoms
154
+ * @returns {Object} { hf_data: Array<TensorData|null>,
155
+ * gamma_ratios: Object<string, {isotope, gamma}> }
156
+ */
157
+ function parseMagresOldBlock(text, mlabels, N) {
158
+ const lines = text.split('\n');
159
+ const hf_data = new Array(N).fill(null);
160
+ const gamma_ratios = {}; // species label → { isotope: int|null, gamma: float }
161
+
162
+ let current_sp = null;
163
+ let current_idx = null;
164
+ // 'idle' | 'reading_rows' | 'waiting_eigenvalue_label'
165
+ let state = 'idle';
166
+ let tensor_rows = [];
167
+ let pending_tensor = null;
168
+
169
+ for (let i = 0; i < lines.length; i++) {
170
+ const line = lines[i].trim();
171
+
172
+ // Parse magnetogyric ratio table lines:
173
+ // "H Isotope 1 GAMMA = 2.6752E+08 rad T-1 s-1"
174
+ // "H:Mu User defined. GAMMA = 8.5162E+08 rad T-1 s-1"
175
+ const gamma_iso_match = line.match(/^(\S+)\s+Isotope\s+(\d+)\s+GAMMA\s*=\s*([+-]?[\d.]+E[+-]?\d+)/i);
176
+ if (gamma_iso_match) {
177
+ gamma_ratios[gamma_iso_match[1]] = {
178
+ isotope: parseInt(gamma_iso_match[2]),
179
+ gamma: parseFloat(gamma_iso_match[3])
180
+ };
181
+ continue;
182
+ }
183
+ const gamma_user_match = line.match(/^(\S+)\s+User defined\.\s+GAMMA\s*=\s*([+-]?[\d.]+E[+-]?\d+)/i);
184
+ if (gamma_user_match) {
185
+ gamma_ratios[gamma_user_match[1]] = {
186
+ isotope: null,
187
+ gamma: parseFloat(gamma_user_match[2])
188
+ };
189
+ continue;
190
+ }
191
+
192
+ // Look for "Atom: ELEM INDEX"
193
+ const atom_match = line.match(/^Atom:\s+(\S+)\s+(\d+)$/);
194
+ if (atom_match) {
195
+ current_sp = atom_match[1];
196
+ current_idx = parseInt(atom_match[2]);
197
+ state = 'idle';
198
+ tensor_rows = [];
199
+ pending_tensor = null;
200
+ continue;
201
+ }
202
+
203
+ // Look for the "TOTAL tensor" header (exact match – not "TOTAL Shielding Tensor" etc.)
204
+ if (line === 'TOTAL tensor') {
205
+ state = 'reading_rows';
206
+ tensor_rows = [];
207
+ pending_tensor = null;
208
+ continue;
209
+ }
210
+
211
+ if (state === 'reading_rows' && current_sp !== null) {
212
+ if (line === '') continue;
213
+
214
+ const parts = line.split(/\s+/).filter(s => s !== '');
215
+ const nums = parts.map(parseFloat);
216
+
217
+ if (nums.length === 3 && nums.every(n => !isNaN(n))) {
218
+ tensor_rows.push(nums);
219
+ if (tensor_rows.length === 3) {
220
+ // Matrix complete – wait for Eigenvalue label to confirm it's HF
221
+ pending_tensor = tensor_rows.slice();
222
+ state = 'waiting_eigenvalue_label';
223
+ tensor_rows = [];
224
+ }
225
+ } else {
226
+ // Unexpected non-numeric line – abort
227
+ state = 'idle';
228
+ tensor_rows = [];
229
+ }
230
+ continue;
231
+ }
232
+
233
+ if (state === 'waiting_eigenvalue_label' && current_sp !== null) {
234
+ if (line === '') continue;
235
+
236
+ // Look for "SPECIES INDEX Eigenvalue LABEL VALUE"
237
+ // HF tensors use A_xx / A_yy / A_zz.
238
+ // EFG uses V_xx, shielding uses sigma_xx – we skip those.
239
+ const eval_match = line.match(/Eigenvalue\s+(\S+)/);
240
+ if (eval_match) {
241
+ const label = eval_match[1]; // e.g. A_xx, V_xx, sigma_xx
242
+ if (label.startsWith('A_')) {
243
+ // Confirmed hyperfine – store the tensor
244
+ const idx = _.findIndex(mlabels, x =>
245
+ x[0] === current_sp && x[1] === current_idx
246
+ );
247
+ if (idx >= 0) {
248
+ hf_data[idx] = new TensorData(pending_tensor);
249
+ }
250
+ }
251
+ // Any other label (V_xx, sigma_xx, …) – silently discard
252
+ state = 'idle';
253
+ pending_tensor = null;
254
+ }
255
+ // Non-eigenvalue lines (could be blank) – keep waiting
256
+ continue;
257
+ }
258
+ }
259
+
260
+ return { hf_data, gamma_ratios };
261
+ }
262
+
123
263
  function load(contents, filename='magres') {
124
264
 
125
265
  const known_blocks = ['atoms', 'magres'];
@@ -275,7 +415,10 @@ function load(contents, filename='magres') {
275
415
  for (let i = 0; i < ablock.atom.lines.length; ++i) {
276
416
  let l = ablock.atom.lines[i];
277
417
 
278
- elems.push(l[0]);
418
+ // CASTEP custom species use the form ELEMENT:LABEL (e.g. H:Mu).
419
+ // Strip the colon-suffix to recover a valid element symbol while
420
+ // keeping the full species identifier in mlabels/labels for lookup.
421
+ elems.push(l[0].split(':')[0]);
279
422
  mlabels.push([l[1], parseInt(l[2])]);
280
423
  labels.push(l[1]);
281
424
  pos.push(_.map(l.slice(3,6), function(x) {
@@ -360,6 +503,18 @@ function load(contents, filename='magres') {
360
503
 
361
504
  }
362
505
 
506
+ // Parse hyperfine tensors and gamma ratios from magres_old block
507
+ const magres_old_text = blocks['magres_old'];
508
+ if (magres_old_text && typeof magres_old_text === 'string') {
509
+ const { hf_data, gamma_ratios } = parseMagresOldBlock(magres_old_text, mlabels, N);
510
+ if (hf_data.some(d => d !== null)) {
511
+ atoms.set_array('hf', hf_data);
512
+ }
513
+ if (Object.keys(gamma_ratios).length > 0) {
514
+ atoms.info['hf-gyromagnetic-ratios'] = gamma_ratios;
515
+ }
516
+ }
517
+
363
518
  let structs = {};
364
519
  structs[filename] = atoms;
365
520
 
package/lib/model.js CHANGED
@@ -1776,6 +1776,37 @@ class Model {
1776
1776
  return new ModelView(this, indices);
1777
1777
  }
1778
1778
 
1779
+ /**
1780
+ * Reconstruct a {@link ModelView} from a plain array of atom-image indices,
1781
+ * as produced by {@link ModelView#toIndices}. This is the stable public API
1782
+ * for deserialising a saved selection.
1783
+ *
1784
+ * @param {number[]} indices
1785
+ * @return {ModelView}
1786
+ */
1787
+ viewFromIndices(indices = []) {
1788
+ return this.view(indices);
1789
+ }
1790
+
1791
+ /**
1792
+ * Reconstruct a {@link ModelView} from an array of crystallographic site
1793
+ * labels (`crystLabel`), as produced by {@link ModelView#toLabels}. This
1794
+ * is resilient to atom-index reordering across reloads.
1795
+ *
1796
+ * @param {string[]} labels
1797
+ * @return {ModelView}
1798
+ */
1799
+ viewFromLabels(labels = []) {
1800
+ const wanted = new Set(labels);
1801
+ const indices = [];
1802
+ for (let i = 0; i < this._atom_images.length; ++i) {
1803
+ if (wanted.has(this._atom_images[i].crystLabel)) {
1804
+ indices.push(i);
1805
+ }
1806
+ }
1807
+ return this.view(indices);
1808
+ }
1809
+
1779
1810
  /**
1780
1811
  * Set a property on a series of atom images
1781
1812
  *
package/lib/modelview.js CHANGED
@@ -440,6 +440,30 @@ class ModelView {
440
440
  return this;
441
441
  }
442
442
 
443
+ // ─── Serialisation helpers ───────────────────────────────────────────────────
444
+
445
+ /**
446
+ * Return a plain array of the atom-image indices in this view.
447
+ * Use this to serialise a selection; restore it with
448
+ * {@link Model#viewFromIndices}.
449
+ *
450
+ * @return {number[]}
451
+ */
452
+ toIndices() {
453
+ return Array.from(this._indices);
454
+ }
455
+
456
+ /**
457
+ * Return the crystallographic site labels (`crystLabel`) of the atoms
458
+ * in this view. Use this to serialise a selection in a way that is
459
+ * robust to atom reordering; restore with {@link Model#viewFromLabels}.
460
+ *
461
+ * @return {string[]}
462
+ */
463
+ toLabels() {
464
+ return this._images.map(a => a.crystLabel);
465
+ }
466
+
443
467
  }
444
468
 
445
469
  export {
package/lib/render.js CHANGED
@@ -116,7 +116,8 @@ class Renderer {
116
116
 
117
117
  // Raycast for clicks
118
118
  this._rcastlist = [];
119
- this._r.domElement.addEventListener('pointerdown', this._raycastClick.bind(this));
119
+ this._boundRaycastClick = this._raycastClick.bind(this);
120
+ this._r.domElement.addEventListener('pointerdown', this._boundRaycastClick);
120
121
 
121
122
  // Groups
122
123
  this._groups = {
@@ -162,7 +163,7 @@ class Renderer {
162
163
  }
163
164
 
164
165
  _animate() {
165
- requestAnimationFrame(this._animate.bind(this));
166
+ this._animFrameId = requestAnimationFrame(this._animate.bind(this));
166
167
  this._render();
167
168
  }
168
169
 
@@ -335,6 +336,48 @@ class Renderer {
335
336
  _.pull(this._sboxlist, sbl);
336
337
  }
337
338
 
339
+ /**
340
+ * Tear down the renderer, cancelling the animation loop, removing all canvas
341
+ * event listeners (orbit controls, raycaster, selection box), disconnecting any
342
+ * ResizeObserver, and releasing the WebGL context. After this call the instance
343
+ * must not be used again.
344
+ */
345
+ dispose() {
346
+ // 1. Stop the animation loop
347
+ if (this._animFrameId != null) {
348
+ cancelAnimationFrame(this._animFrameId);
349
+ this._animFrameId = null;
350
+ }
351
+
352
+ // 2. Remove the raycaster's own pointerdown listener
353
+ this._r.domElement.removeEventListener('pointerdown', this._boundRaycastClick);
354
+ this._rcastlist = [];
355
+ this._sboxlist = [];
356
+
357
+ // 3. Dispose OrbitControls (removes its own listeners from the canvas)
358
+ if (this._oc) {
359
+ this._oc.dispose();
360
+ this._oc = null;
361
+ }
362
+
363
+ // 4. Dispose the selection box helper (removes its listeners and overlay)
364
+ if (this._sboxhelp) {
365
+ this._sboxhelp.dispose();
366
+ this._sboxhelp = null;
367
+ }
368
+
369
+ // 5. Stop watching for container resize
370
+ if (this._resizeObs) {
371
+ this._resizeObs.disconnect();
372
+ this._resizeObs = null;
373
+ }
374
+
375
+ // 6. Release the WebGL context and GPU resources
376
+ this._r.dispose();
377
+ this._r.domElement.remove();
378
+ this._r = null;
379
+ }
380
+
338
381
  /**
339
382
  * Set properties of ambient light
340
383
  *
@@ -413,6 +456,58 @@ class Renderer {
413
456
  return this._oc.target;
414
457
  }
415
458
 
459
+ /**
460
+ * Return a plain serialisable snapshot of the current camera state.
461
+ *
462
+ * @return {{ position: {x,y,z}, target: {x,y,z}, zoom: number }}
463
+ */
464
+ getCameraState() {
465
+ const pos = this._c.position;
466
+ const tgt = this._oc.target;
467
+ return {
468
+ position: { x: pos.x, y: pos.y, z: pos.z },
469
+ target: { x: tgt.x, y: tgt.y, z: tgt.z },
470
+ zoom: this._c.zoom,
471
+ };
472
+ }
473
+
474
+ /**
475
+ * Restore a camera snapshot produced by {@link Renderer#getCameraState}.
476
+ *
477
+ * @param {{ position?: {x,y,z}, target?: {x,y,z}, zoom?: number }} state
478
+ */
479
+ setCameraState(state) {
480
+ if (!state) return;
481
+
482
+ if (state.position) {
483
+ this._c.position.set(state.position.x, state.position.y, state.position.z);
484
+ }
485
+ if (state.target) {
486
+ this._oc.target.set(state.target.x, state.target.y, state.target.z);
487
+ }
488
+ if (typeof state.zoom === 'number') {
489
+ this._c.zoom = state.zoom;
490
+ this._c.updateProjectionMatrix();
491
+ }
492
+ this._oc.update();
493
+ }
494
+
495
+ /**
496
+ * Subscribe to camera-change events fired by OrbitControls whenever
497
+ * the user rotates, pans, or zooms. The callback receives a camera
498
+ * state snapshot identical to the one returned by {@link Renderer#getCameraState}.
499
+ *
500
+ * @param {Function} callback `callback(cameraState)` called on each change
501
+ * @return {Function} Unsubscribe function — call it to remove the listener
502
+ */
503
+ onCameraChange(callback) {
504
+ const handler = () => callback(this.getCameraState());
505
+ this._oc.addEventListener('change', handler);
506
+ return () => {
507
+ if (this._oc) this._oc.removeEventListener('change', handler);
508
+ };
509
+ }
510
+
416
511
 
417
512
  /**
418
513
  * Remove all currently rendered objects.
package/lib/selbox.js CHANGED
@@ -256,6 +256,10 @@ var SelectionHelper = (function() {
256
256
  this.isDown = false;
257
257
  this.selectOverCallback = null;
258
258
 
259
+ // AbortController lets us remove all listeners in one call via dispose()
260
+ this._ac = new AbortController();
261
+ const signal = this._ac.signal;
262
+
259
263
  this.domElement.addEventListener('pointerdown', function(event) {
260
264
 
261
265
  if (event.shiftKey) {
@@ -263,7 +267,7 @@ var SelectionHelper = (function() {
263
267
  this.onSelectStart(event);
264
268
  }
265
269
 
266
- }.bind(this), false);
270
+ }.bind(this), { signal });
267
271
 
268
272
  this.domElement.addEventListener('pointermove', function(event) {
269
273
 
@@ -273,7 +277,7 @@ var SelectionHelper = (function() {
273
277
 
274
278
  }
275
279
 
276
- }.bind(this), false);
280
+ }.bind(this), { signal });
277
281
 
278
282
  this.domElement.addEventListener('pointerup', function(event) {
279
283
 
@@ -282,7 +286,7 @@ var SelectionHelper = (function() {
282
286
  this.onSelectOver(event);
283
287
  }
284
288
 
285
- }.bind(this), false);
289
+ }.bind(this), { signal });
286
290
 
287
291
  this.domElement.addEventListener('keyup', function(event) {
288
292
 
@@ -291,10 +295,20 @@ var SelectionHelper = (function() {
291
295
  this.onSelectOver(event);
292
296
  }
293
297
 
294
- }.bind(this), false);
298
+ }.bind(this), { signal });
295
299
 
296
300
  }
297
301
 
302
+ SelectionHelper.prototype.dispose = function() {
303
+ // Remove all canvas event listeners registered by this helper
304
+ this._ac.abort();
305
+ // Remove the overlay element from the DOM
306
+ if (this.element) {
307
+ this.element.remove();
308
+ this.element = null;
309
+ }
310
+ };
311
+
298
312
  SelectionHelper.prototype.onSelectStart = function(event) {
299
313
 
300
314
  this.element.css({