@ccp-nc/crystvis-js 0.6.0 → 0.7.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 (51) hide show
  1. package/.github/dependabot.yml +69 -0
  2. package/.github/workflows/dependency-review.yml +91 -0
  3. package/.github/workflows/security-scan.yml +113 -0
  4. package/.github/workflows/test.yml +191 -0
  5. package/.github/workflows/update-dependencies.yml +214 -0
  6. package/.mocharc.yml +4 -0
  7. package/.vscode/settings.json +5 -1
  8. package/CHANGELOG.md +61 -14
  9. package/README.md +75 -2
  10. package/audit.txt +24 -0
  11. package/changes.txt +2 -0
  12. package/demo/demo.css +15 -10
  13. package/demo/index.html +32 -5
  14. package/demo/main.js +149 -42
  15. package/docs/data/search.json +1 -1
  16. package/docs/index.html +51 -2
  17. package/docs/lib_model.module_js-AtomImage.html +1 -1
  18. package/docs/lib_model.module_js-BondImage.html +1 -1
  19. package/docs/lib_model.module_js-Model.html +1 -1
  20. package/docs/lib_modelview.module_js-ModelView.html +1 -1
  21. package/docs/lib_visualizer.module_js-CrystVis.html +1 -1
  22. package/docs/model.js.html +31 -3
  23. package/docs/modelview.js.html +24 -0
  24. package/docs/visualizer.js.html +237 -1
  25. package/eslint.config.js +41 -0
  26. package/lib/assets/fonts/threebmfont.js +0 -1
  27. package/lib/formats/magres.js +156 -1
  28. package/lib/formats/xyz.js +149 -52
  29. package/lib/loader.js +28 -7
  30. package/lib/model.js +31 -3
  31. package/lib/modelview.js +24 -0
  32. package/lib/primitives/ellipsoid.js +0 -4
  33. package/lib/primitives/geometries.js +0 -0
  34. package/lib/primitives/isosurface.js +1 -4
  35. package/lib/query.js +3 -28
  36. package/lib/render.js +97 -2
  37. package/lib/selbox.js +18 -4
  38. package/lib/tensor.js +0 -1
  39. package/lib/visualizer.js +237 -1
  40. package/outdated.txt +9 -0
  41. package/package.json +15 -15
  42. package/test/data/ethanol_with_tensors.xyz +33 -0
  43. package/test/data/hf_mu_test.magres +62 -0
  44. package/test/data/hf_test.magres +61 -0
  45. package/test/data/optimized_muon_65-hf.magres +1424 -0
  46. package/test/loader.js +283 -9
  47. package/test/model.js +120 -4
  48. package/test/setup-dom.cjs +147 -0
  49. package/test/visualizer.js +475 -0
  50. package/.eslintrc.json +0 -16
  51. package/.github/workflows/test-mocha.yml +0 -30
@@ -0,0 +1,475 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Tests for CrystVis.dispose() and the isDisposed guard.
5
+ *
6
+ * These tests run in Node.js (no DOM / WebGL) by constructing a CrystVis
7
+ * instance via Object.create so we can supply lightweight mock objects in
8
+ * place of the real THREE.WebGLRenderer and OrbitControls.
9
+ */
10
+
11
+ import * as chai from 'chai';
12
+ import { CrystVis } from '../lib/visualizer.js';
13
+
14
+ const expect = chai.expect;
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Helpers
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /**
21
+ * Build the bare minimum mock Renderer that dispose() expects to find on
22
+ * this._renderer. Each call-count field starts at 0 so tests can assert
23
+ * that the matching method was called exactly once.
24
+ */
25
+ function makeMockRenderer() {
26
+ const renderer = {
27
+ _disposed: false,
28
+ _disposeCalls: 0,
29
+ _setCameraStateCalls: [],
30
+ _cameraChangeHandlers: [],
31
+ _cameraState: { position: { x: 0, y: 0, z: 10 }, target: { x: 0, y: 0, z: 0 }, zoom: 1 },
32
+ dispose() {
33
+ this._disposed = true;
34
+ this._disposeCalls++;
35
+ },
36
+ clear() {},
37
+ addNotifications() {},
38
+ clearNotifications() {},
39
+ resetOrbitCenter() {},
40
+ add() {},
41
+ remove() {},
42
+ getCameraState() {
43
+ return Object.assign({}, this._cameraState,
44
+ { position: Object.assign({}, this._cameraState.position),
45
+ target: Object.assign({}, this._cameraState.target) });
46
+ },
47
+ setCameraState(state) {
48
+ this._setCameraStateCalls.push(state);
49
+ if (state) Object.assign(this._cameraState, state);
50
+ },
51
+ onCameraChange(cb) {
52
+ this._cameraChangeHandlers.push(cb);
53
+ return () => {
54
+ this._cameraChangeHandlers =
55
+ this._cameraChangeHandlers.filter(h => h !== cb);
56
+ };
57
+ },
58
+ /** Trigger all registered camera-change handlers (test helper). */
59
+ _fireCameraChange() {
60
+ const state = this.getCameraState();
61
+ this._cameraChangeHandlers.forEach(h => h(state));
62
+ },
63
+ };
64
+ return renderer;
65
+ }
66
+
67
+ /**
68
+ * Return a CrystVis instance whose constructor has been bypassed so that
69
+ * no DOM element or WebGL context is needed. The internal _renderer is
70
+ * replaced with the supplied mock (or a fresh mock if none is given).
71
+ */
72
+ function makeMockVis(mockRenderer) {
73
+ const r = mockRenderer || makeMockRenderer();
74
+
75
+ // Bypass the real constructor to avoid DOM / WebGL requirements
76
+ const vis = Object.create(CrystVis.prototype);
77
+
78
+ vis._isDisposed = false;
79
+ vis._renderer = r;
80
+ vis._loader = { load() { return {}; } };
81
+ vis._models = {};
82
+ vis._current_model = null;
83
+ vis._current_mname = null;
84
+ vis._displayed = null;
85
+ vis._selected = null;
86
+ vis._notifications = [];
87
+ vis._atom_click_events = {};
88
+ vis._atom_click_defaults = {};
89
+ vis._atom_box_event = null;
90
+ vis._hsel = false;
91
+ vis.cifsymtol = 1e-2;
92
+
93
+ // New state added by this batch of features
94
+ vis._model_sources = {};
95
+ vis._model_parameters = {};
96
+ vis._model_meta = {};
97
+ vis._model_list_change_cbs = [];
98
+ vis._display_change_cbs = [];
99
+ vis._camera_change_cbs = [];
100
+ // Wire the renderer's camera-change signal to the vis callback array,
101
+ // mirroring what the real constructor does.
102
+ vis._camera_unsub = r.onCameraChange((state) => {
103
+ vis._camera_change_cbs.forEach(cb => cb(state));
104
+ });
105
+
106
+ return { vis, renderer: r };
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Tests: dispose()
111
+ // ---------------------------------------------------------------------------
112
+
113
+ describe('CrystVis#dispose', function () {
114
+
115
+ it('should set isDisposed to true', function () {
116
+ const { vis } = makeMockVis();
117
+ expect(vis.isDisposed).to.be.false;
118
+ vis.dispose();
119
+ expect(vis.isDisposed).to.be.true;
120
+ });
121
+
122
+ it('should call renderer.dispose() exactly once', function () {
123
+ const { vis, renderer } = makeMockVis();
124
+ vis.dispose();
125
+ expect(renderer._disposeCalls).to.equal(1);
126
+ });
127
+
128
+ it('should null the internal _renderer reference', function () {
129
+ const { vis } = makeMockVis();
130
+ vis.dispose();
131
+ expect(vis._renderer).to.be.null;
132
+ });
133
+
134
+ it('should null _current_model and _current_mname', function () {
135
+ const { vis } = makeMockVis();
136
+ vis._current_mname = 'test';
137
+ vis.dispose();
138
+ expect(vis._current_model).to.be.null;
139
+ expect(vis._current_mname).to.be.null;
140
+ });
141
+
142
+ it('should clear the _models map', function () {
143
+ const { vis } = makeMockVis();
144
+ vis._models = { a: {}, b: {} };
145
+ vis.dispose();
146
+ expect(Object.keys(vis._models)).to.have.length(0);
147
+ });
148
+
149
+ it('should clear model source / parameter / meta stores', function () {
150
+ const { vis } = makeMockVis();
151
+ vis._model_sources = { a: { text: 'x', extension: 'cif' } };
152
+ vis._model_parameters = { a: { supercell: [1,1,1] } };
153
+ vis._model_meta = { a: { prefix: 'cif', originalName: 'a' } };
154
+ vis.dispose();
155
+ expect(Object.keys(vis._model_sources)).to.have.length(0);
156
+ expect(Object.keys(vis._model_parameters)).to.have.length(0);
157
+ expect(Object.keys(vis._model_meta)).to.have.length(0);
158
+ });
159
+
160
+ it('should clear lifecycle and camera-change callback arrays', function () {
161
+ const { vis } = makeMockVis();
162
+ vis._model_list_change_cbs.push(() => {});
163
+ vis._display_change_cbs.push(() => {});
164
+ vis._camera_change_cbs.push(() => {});
165
+ vis.dispose();
166
+ expect(vis._model_list_change_cbs).to.have.length(0);
167
+ expect(vis._display_change_cbs).to.have.length(0);
168
+ expect(vis._camera_change_cbs).to.have.length(0);
169
+ });
170
+
171
+ it('should unsubscribe from renderer camera-change on dispose', function () {
172
+ const { vis, renderer } = makeMockVis();
173
+ // One handler was registered by makeMockVis wiring
174
+ expect(renderer._cameraChangeHandlers).to.have.length(1);
175
+ vis.dispose();
176
+ expect(renderer._cameraChangeHandlers).to.have.length(0);
177
+ });
178
+
179
+ it('should clear atom event callbacks', function () {
180
+ const { vis } = makeMockVis();
181
+ vis._atom_click_events = { 1: () => {} };
182
+ vis._atom_box_event = () => {};
183
+ vis.dispose();
184
+ expect(Object.keys(vis._atom_click_events)).to.have.length(0);
185
+ expect(vis._atom_box_event).to.be.null;
186
+ });
187
+
188
+ it('should be idempotent: calling dispose() twice is harmless', function () {
189
+ const { vis, renderer } = makeMockVis();
190
+ vis.dispose();
191
+ vis.dispose();
192
+ // renderer.dispose() should only have been called the first time
193
+ expect(renderer._disposeCalls).to.equal(1);
194
+ });
195
+
196
+ });
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // Tests: isDisposed guard
200
+ // ---------------------------------------------------------------------------
201
+
202
+ describe('CrystVis#isDisposed guard', function () {
203
+
204
+ it('loadModels() should throw after disposal', function () {
205
+ const { vis } = makeMockVis();
206
+ vis.dispose();
207
+ expect(() => vis.loadModels('data', 'xyz')).to.throw(
208
+ /cannot call loadModels\(\) on a disposed instance/
209
+ );
210
+ });
211
+
212
+ it('displayModel() should throw after disposal', function () {
213
+ const { vis } = makeMockVis();
214
+ vis.dispose();
215
+ expect(() => vis.displayModel('mymodel')).to.throw(
216
+ /cannot call displayModel\(\) on a disposed instance/
217
+ );
218
+ });
219
+
220
+ it('reloadModel() should throw after disposal', function () {
221
+ const { vis } = makeMockVis();
222
+ vis.dispose();
223
+ expect(() => vis.reloadModel('mymodel')).to.throw(
224
+ /cannot call reloadModel\(\) on a disposed instance/
225
+ );
226
+ });
227
+
228
+ it('unloadAll() should throw after disposal', function () {
229
+ const { vis } = makeMockVis();
230
+ vis.dispose();
231
+ expect(() => vis.unloadAll()).to.throw(
232
+ /cannot call unloadAll\(\) on a disposed instance/
233
+ );
234
+ });
235
+
236
+ it('isDisposed getter returns false before disposal', function () {
237
+ const { vis } = makeMockVis();
238
+ expect(vis.isDisposed).to.be.false;
239
+ });
240
+
241
+ it('isDisposed getter returns true after disposal', function () {
242
+ const { vis } = makeMockVis();
243
+ vis.dispose();
244
+ expect(vis.isDisposed).to.be.true;
245
+ });
246
+
247
+ });
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // Tests: camera state (§1)
251
+ // ---------------------------------------------------------------------------
252
+
253
+ describe('CrystVis#getCameraState / setCameraState', function () {
254
+
255
+ it('getCameraState() delegates to the renderer', function () {
256
+ const { vis, renderer } = makeMockVis();
257
+ const state = vis.getCameraState();
258
+ expect(state).to.deep.equal(renderer._cameraState);
259
+ });
260
+
261
+ it('getCameraState() returns a snapshot (not the live object)', function () {
262
+ const { vis, renderer } = makeMockVis();
263
+ const state = vis.getCameraState();
264
+ // Mutate the returned snapshot – the renderer's state must not change
265
+ state.zoom = 999;
266
+ expect(renderer._cameraState.zoom).to.not.equal(999);
267
+ });
268
+
269
+ it('setCameraState() delegates to the renderer', function () {
270
+ const { vis, renderer } = makeMockVis();
271
+ const newState = { position: { x: 1, y: 2, z: 3 }, target: { x: 0, y: 0, z: 0 }, zoom: 2 };
272
+ vis.setCameraState(newState);
273
+ expect(renderer._setCameraStateCalls).to.have.length(1);
274
+ expect(renderer._setCameraStateCalls[0]).to.equal(newState);
275
+ });
276
+
277
+ });
278
+
279
+ describe('CrystVis#onCameraChange', function () {
280
+
281
+ it('registers a callback that fires when the renderer emits a camera change', function () {
282
+ const { vis, renderer } = makeMockVis();
283
+ const received = [];
284
+ vis.onCameraChange(state => received.push(state));
285
+ renderer._fireCameraChange();
286
+ expect(received).to.have.length(1);
287
+ expect(received[0]).to.have.keys(['position', 'target', 'zoom']);
288
+ });
289
+
290
+ it('supports multiple subscribers', function () {
291
+ const { vis, renderer } = makeMockVis();
292
+ let countA = 0, countB = 0;
293
+ vis.onCameraChange(() => countA++);
294
+ vis.onCameraChange(() => countB++);
295
+ renderer._fireCameraChange();
296
+ expect(countA).to.equal(1);
297
+ expect(countB).to.equal(1);
298
+ });
299
+
300
+ it('returned unsubscribe function stops future callbacks', function () {
301
+ const { vis, renderer } = makeMockVis();
302
+ const received = [];
303
+ const unsub = vis.onCameraChange(state => received.push(state));
304
+ renderer._fireCameraChange(); // fires once
305
+ unsub();
306
+ renderer._fireCameraChange(); // should NOT reach the callback
307
+ expect(received).to.have.length(1);
308
+ });
309
+
310
+ });
311
+
312
+ // ---------------------------------------------------------------------------
313
+ // Tests: model source / parameters / metadata (§2, §3)
314
+ // ---------------------------------------------------------------------------
315
+
316
+ describe('CrystVis#getModelSource / getModelParameters / getModelMeta', function () {
317
+
318
+ it('getModelSource() returns null for unknown model', function () {
319
+ const { vis } = makeMockVis();
320
+ expect(vis.getModelSource('missing')).to.be.null;
321
+ });
322
+
323
+ it('getModelSource() returns a cloned copy of stored source', function () {
324
+ const { vis } = makeMockVis();
325
+ vis._model_sources['m'] = { text: 'data...', extension: 'cif' };
326
+ const src = vis.getModelSource('m');
327
+ expect(src).to.deep.equal({ text: 'data...', extension: 'cif' });
328
+ // Mutating the returned copy must not affect the store
329
+ src.text = 'modified';
330
+ expect(vis._model_sources['m'].text).to.equal('data...');
331
+ });
332
+
333
+ it('getModelParameters() returns null for unknown model', function () {
334
+ const { vis } = makeMockVis();
335
+ expect(vis.getModelParameters('missing')).to.be.null;
336
+ });
337
+
338
+ it('getModelParameters() returns a cloned copy of stored parameters', function () {
339
+ const { vis } = makeMockVis();
340
+ vis._model_parameters['m'] = { supercell: [2, 2, 1], molecularCrystal: true };
341
+ const params = vis.getModelParameters('m');
342
+ expect(params).to.deep.equal({ supercell: [2, 2, 1], molecularCrystal: true });
343
+ params.supercell[0] = 99;
344
+ expect(vis._model_parameters['m'].supercell[0]).to.equal(2);
345
+ });
346
+
347
+ it('getModelMeta() returns null for unknown model', function () {
348
+ const { vis } = makeMockVis();
349
+ expect(vis.getModelMeta('missing')).to.be.null;
350
+ });
351
+
352
+ it('getModelMeta() returns a cloned copy of stored metadata', function () {
353
+ const { vis } = makeMockVis();
354
+ vis._model_meta['m'] = { prefix: 'cif', originalName: 'struct' };
355
+ const meta = vis.getModelMeta('m');
356
+ expect(meta).to.deep.equal({ prefix: 'cif', originalName: 'struct' });
357
+ meta.prefix = 'changed';
358
+ expect(vis._model_meta['m'].prefix).to.equal('cif');
359
+ });
360
+
361
+ });
362
+
363
+ // ---------------------------------------------------------------------------
364
+ // Tests: lifecycle events (§4)
365
+ // ---------------------------------------------------------------------------
366
+
367
+ describe('CrystVis#onModelListChange', function () {
368
+
369
+ it('fires with the current model-name list', function () {
370
+ const { vis } = makeMockVis();
371
+ vis._models = { a: {}, b: {} };
372
+ const received = [];
373
+ vis.onModelListChange(names => received.push(names));
374
+ vis._emitModelListChange();
375
+ expect(received).to.have.length(1);
376
+ expect(received[0].sort()).to.deep.equal(['a', 'b']);
377
+ });
378
+
379
+ it('fires when deleteModel is called', function () {
380
+ const { vis } = makeMockVis();
381
+ // Inject a stub model that deleteModel won't choke on
382
+ vis._models = { m: {} };
383
+ // displayModel() (called inside deleteModel when current) calls renderer.clear()
384
+ // and _emitDisplayChange – fine with our mock
385
+ const received = [];
386
+ vis.onModelListChange(names => received.push(names.slice()));
387
+ vis.deleteModel('m');
388
+ expect(received).to.have.length(1);
389
+ expect(received[0]).to.deep.equal([]);
390
+ });
391
+
392
+ it('returned unsubscribe function prevents future callbacks', function () {
393
+ const { vis } = makeMockVis();
394
+ vis._models = { a: {} };
395
+ const received = [];
396
+ const unsub = vis.onModelListChange(names => received.push(names));
397
+ vis._emitModelListChange();
398
+ unsub();
399
+ vis._emitModelListChange();
400
+ expect(received).to.have.length(1);
401
+ });
402
+
403
+ });
404
+
405
+ describe('CrystVis#onDisplayChange', function () {
406
+
407
+ it('fires with null when displayModel() is called with no args', function () {
408
+ const { vis } = makeMockVis();
409
+ const received = [];
410
+ vis.onDisplayChange(name => received.push(name));
411
+ vis.displayModel(); // clear
412
+ expect(received).to.have.length(1);
413
+ expect(received[0]).to.be.null;
414
+ });
415
+
416
+ it('returned unsubscribe function prevents future callbacks', function () {
417
+ const { vis } = makeMockVis();
418
+ const received = [];
419
+ const unsub = vis.onDisplayChange(name => received.push(name));
420
+ vis.displayModel();
421
+ unsub();
422
+ vis.displayModel();
423
+ expect(received).to.have.length(1);
424
+ });
425
+
426
+ });
427
+
428
+ // ---------------------------------------------------------------------------
429
+ // Tests: unloadAll (§5)
430
+ // ---------------------------------------------------------------------------
431
+
432
+ describe('CrystVis#unloadAll', function () {
433
+
434
+ it('clears all model stores', function () {
435
+ const { vis } = makeMockVis();
436
+ vis._models = { a: {}, b: {} };
437
+ vis._model_sources = { a: { text: 'x', extension: 'cif' }, b: { text: 'y', extension: 'xyz' } };
438
+ vis._model_parameters = { a: {}, b: {} };
439
+ vis._model_meta = { a: {}, b: {} };
440
+
441
+ vis.unloadAll();
442
+
443
+ expect(Object.keys(vis._models)).to.have.length(0);
444
+ expect(Object.keys(vis._model_sources)).to.have.length(0);
445
+ expect(Object.keys(vis._model_parameters)).to.have.length(0);
446
+ expect(Object.keys(vis._model_meta)).to.have.length(0);
447
+ });
448
+
449
+ it('emits onModelListChange with an empty list', function () {
450
+ const { vis } = makeMockVis();
451
+ vis._models = { a: {} };
452
+ const received = [];
453
+ vis.onModelListChange(names => received.push(names.slice()));
454
+ vis.unloadAll();
455
+ expect(received).to.have.length(1);
456
+ expect(received[0]).to.deep.equal([]);
457
+ });
458
+
459
+ it('emits onDisplayChange with null', function () {
460
+ const { vis } = makeMockVis();
461
+ vis._models = { a: {} };
462
+ const received = [];
463
+ vis.onDisplayChange(name => received.push(name));
464
+ vis.unloadAll();
465
+ expect(received).to.have.length(1);
466
+ expect(received[0]).to.be.null;
467
+ });
468
+
469
+ it('throws after disposal', function () {
470
+ const { vis } = makeMockVis();
471
+ vis.dispose();
472
+ expect(() => vis.unloadAll()).to.throw(/cannot call unloadAll\(\) on a disposed instance/);
473
+ });
474
+
475
+ });
package/.eslintrc.json DELETED
@@ -1,16 +0,0 @@
1
- {
2
- "env": {
3
- "browser": true,
4
- "es2021": true
5
- },
6
- "extends": "eslint:recommended",
7
- "parserOptions": {
8
- "ecmaVersion": 12,
9
- "sourceType": "module"
10
- },
11
- "rules": {
12
- "no-unused-vars": ["error", {
13
- "args": "none"
14
- }]
15
- }
16
- }
@@ -1,30 +0,0 @@
1
- # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
2
- # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
3
-
4
- name: Node.js CI
5
-
6
- on:
7
- push:
8
- branches: [ master ]
9
- pull_request:
10
- branches: [ master ]
11
-
12
- jobs:
13
- build:
14
-
15
- runs-on: ubuntu-latest
16
-
17
- strategy:
18
- matrix:
19
- node-version: [20.x, 22.x, 24.x]
20
- # See supported Node.js release schedule at https://nodejs.org/en/about/releases/
21
-
22
- steps:
23
- - uses: actions/checkout@v4
24
- - name: Use Node.js ${{ matrix.node-version }}
25
- uses: actions/setup-node@v4
26
- with:
27
- node-version: ${{ matrix.node-version }}
28
- - run: npm ci
29
- - run: npm run build --if-present
30
- - run: npm test