Compare commits

...

226 Commits

Author SHA1 Message Date
dsehnal
14e619d6d2 experimental sequence theme 2025-08-28 06:26:52 +02:00
Victoria Doshchenko
42d969bbeb MVS: example story improvements (#1632)
* add intro scene

* fixes

* add author name
2025-08-26 18:55:34 +02:00
dsehnal
fdc33e44dc 5.0.0-dev.10 2025-08-26 17:43:06 +02:00
David Sehnal
b0aa889a0a MVS: Animation improvements (#1631)
* allow interpolation "keyframing"

* animation fixes
2025-08-26 17:41:21 +02:00
David Sehnal
4d7bd53231 Additional markdown commands (#1630) 2025-08-26 06:58:40 +02:00
David Sehnal
c11cf665c9 Additional markdown extensions (#1629)
* additional markdown extensions

* fixes
2025-08-25 20:12:10 +02:00
dsehnal
a4b09d3a0c 5.0.0-dev.9 2025-08-25 17:00:54 +02:00
David Sehnal
6e488b0f80 MotM1 Story tweaks (#1627)
* tweak story

* bugfixes & tweaks

* linting

* support "discrete" scalar transform

* tweak audio path

* tweak ui
2025-08-25 08:22:36 +02:00
ludovic autin
6164281a50 initial work on MOM number 1 with audio comments (#1624)
* initial work on MOM number 1 with audio comments

* add some TODO comments

* separate audio as mp3. Do coloring with DG scheme

* move audio in root example folder. test some query in mol-script

* salt bridge.

* better coloring

* support for entry-id test in MolScriptBuilder.

* lint

* update audio, sync animation

* cleanup

* clean up and sync audio/anim

* add reference to MOM1
2025-08-25 07:14:46 +02:00
Alexander Rose
2db7171e2a Merge pull request #1625 from molstar/trackball-state-tweaks
Trackball & Snapshot handling tweaks
2025-08-24 13:42:24 -07:00
Alexander Rose
edfc094952 re-add the !isBusy check
Co-authored-by: David Sehnal <dsehnal@users.noreply.github.com>
2025-08-24 13:35:37 -07:00
David Sehnal
b3e1e2900b Fix Markdown Commands query focus (#1626) 2025-08-24 13:18:36 +02:00
Alexander Rose
1e498d535a add SnapshotControls behavior 2025-08-23 10:44:50 -07:00
Alexander Rose
6ed969cd1b don't save behaviors in me snapshots 2025-08-23 10:42:05 -07:00
Alexander Rose
27bb4f4bca remove unused trackball param 2025-08-23 10:41:21 -07:00
Alexander Rose
6ce2139272 add canvas3d/trackball attribs
- attribs are configurable but are not saved in the state like props
2025-08-23 10:41:04 -07:00
dsehnal
13cf6613a6 fix typo 2025-08-23 19:18:21 +02:00
David Sehnal
c5bb13e295 Execute markdown commands on snapshot load (#1622) 2025-08-22 18:25:24 +02:00
dsehnal
34c8257848 5.0.0-dev.8 2025-08-22 16:05:31 +02:00
David Sehnal
fcbf39c935 Markdown & MVS: Audio support (#1621)
* audio playback markdown commands

* trigger markdown commands from MVS primitives

* docs

* fix usage of mutative
2025-08-22 16:00:30 +02:00
dsehnal
46c8150b2b 5.0.0-dev.7 2025-08-21 14:05:04 +02:00
David Sehnal
af1a864daa replace immer with mutative, optimize MVS animation loading (#1618) 2025-08-21 14:03:22 +02:00
David Sehnal
3babd9399a MVS: Add backbone and line representation types (#1617) 2025-08-20 17:33:03 +02:00
David Sehnal
e57564486f mol-state: fix param normalization (#1616)
* mol-state: fix param normalization

* headers
2025-08-19 19:33:01 +02:00
midlik
464a91ac29 Add PluginConfig.Viewport.ShowReset (#1615)
* Add PluginConfig.Viewport.ShowReset

* Update CHANGELOG
2025-08-18 12:02:31 +02:00
Alexander Rose
27fa50a5de Merge pull request #1610 from giagitom/dpoit-monolayer-transparency
Add Monolayer transparency (exploiting dpoit)
2025-08-16 14:37:34 -07:00
David Sehnal
1e323f18f7 MVS: additional formats and trajectory support (#1613)
* MVS: additional formats and trajectory support

* refactoring

* support lammpstrj

* tweaks

* remove test code
2025-08-15 20:10:13 +02:00
midlik
2685b2b77d MVSX use Murmur hash (#1612) 2025-08-15 14:11:53 +02:00
David Sehnal
d71b47a515 MVS Stories and related updates (#1609)
* mvs-stories updates, better snapshot playback if transition is present

* Add createMVSX

* mvs: support trackball animation

* tweak

* more fine grained speed of camera spin animation

* update TrackballControlsParams.animate.spin.speed

* story-session-url arg support
2025-08-15 13:27:34 +02:00
giagitom
88cc720dd2 fix render without postprocessing 2025-08-14 23:34:45 +02:00
giagitom
201433cc91 Lint fix 2025-08-14 19:53:36 +02:00
giagitom
8582303491 Add Monolayer transparency (exploiting dpoit) 2025-08-14 19:40:24 +02:00
dsehnal
655c3edadd 5.0.0-dev.6 2025-08-14 11:37:40 +02:00
David Sehnal
a4323a4bd8 MVS animation improvements (#1608) 2025-08-14 11:33:50 +02:00
dsehnal
1b5a7d9546 5.0.0-dev.5 2025-08-13 18:44:02 +02:00
David Sehnal
f165cc4629 MVS: Animations (#1606)
* object hash

* hashed StateTransform.version

* StateTree.reuseTransformParams

* State animation data model

* mvs animation tree schema and builder

* generate animation

* snapshot animation ui

* async animation generation

* ui tweak

* ui tweak

* wrap loadMVS in task

* state snapshots animation

* snapshot transition animation

* autoplay transition

* vector and rotation matrix interpolation

* local rotation transform

* fixes and better demo

* unused import

* tweak

* changelog

* headers

* type => kind

* mat4 interpolation

* use proper time in animation loop

* animated label opacity

* typo

* add postprocessing to demo

* fix mvs postprocessing params

* add transform_matrix interpolation

* tweak

* generalize vector interpolation

* Color.interpolateHcl

* resetCanvasProps

* rename from/to to start/end

* transform def

* add frequency param

* update interpolations

* cache rotation, do not apply noise to last frame

* local_rotation => rotation_center

* changelog

* fix build

* add animation.duration_ms

* PR feedback

* add hsl color space

* default canvas bg

* scalar list interpolation

* color interpolation props
2025-08-13 18:41:56 +02:00
Alexander Rose
db247d6fbd remove mat4 allocation 2025-08-12 22:49:14 -07:00
Alexander Rose
138796862b Merge pull request #1589 from giagitom/dot-volume-improvements
Dot volume representation improvements
2025-08-10 10:44:39 -07:00
Alexander Rose
1b236f1ae5 cleanup 2025-08-10 10:42:17 -07:00
Alexander Rose
b6c2e25395 cleanup 2025-08-10 10:37:44 -07:00
giagitom
b7816986aa lint-fix 2025-08-10 13:07:54 +02:00
giagitom
437c70a75a Apply suggestions 2025-08-10 13:05:08 +02:00
giagitom
de85e0fbae Add extractBasis helper to mat4 2025-08-10 12:49:09 +02:00
giagitom
c527b59782 optimization 2025-08-09 21:01:33 +02:00
giagitom
3bbbac66c7 lint fix 2025-08-09 12:36:29 +02:00
giagitom
c0980bf18a Improvements in perturbation obtainment 2025-08-08 23:21:54 +02:00
giagitom
45eab19493 refactor 2025-08-08 16:57:56 +02:00
giagitom
1e2a5a5bfd - Rename property and refactor
- handle  non-orthogonal cell
2025-08-08 16:45:22 +02:00
giagitom
45edfa8014 Merge branch 'master' of https://github.com/molstar/molstar into dot-volume-improvements 2025-08-08 14:38:06 +02:00
David Sehnal
899203c855 MVS: surface_type option (#1603) 2025-08-07 11:15:44 +02:00
김주호
ef823b066b Change PDB parsing to use four-letter residue names (#1602)
* Add is4LetterResidueName option for parsing .pdb

* update metadata for request pullrequest

* Always parse PDB files using four-letter residue names. (#1601) (#1602)

* Write changelog about parsing resname 4-letter

* Update src/mol-plugin-state/formats/trajectory.ts

* Update src/mol-model-formats/structure/pdb/to-cif.ts

---------

Co-authored-by: David Sehnal <dsehnal@users.noreply.github.com>
2025-08-06 07:36:20 +02:00
dsehnal
33dc2015df 5.0.0-dev.4 2025-08-05 09:55:55 +02:00
dsehnal
fcf5ea420b npm audit 2025-08-05 09:54:34 +02:00
dsehnal
8d97327f8d MVS: fix passing custom primitive params 2025-08-05 09:53:58 +02:00
midlik
abc7ebba3e MVS: Fix MVSInlinePrimitiveData param type (#1592) 2025-08-03 17:48:19 +02:00
David Sehnal
73d593907e MVS cavnas molstar_postprocessing (#1598) 2025-08-03 12:04:53 +02:00
dsehnal
0dc05e1138 5.0.0-dev.3 2025-08-02 18:21:14 +02:00
David Sehnal
dd11cacae4 Markdown Commands and MVS improvements (#1597)
* add query command to markdown extensions

* fix typo

* better postprocessing param support in MVS

* molstar_mesh/label/line_params
2025-08-02 18:19:01 +02:00
David Sehnal
b503259758 another io-ts import fix (#1595) 2025-08-01 06:28:26 +02:00
zachcp
1e98741e16 Update field-schema.ts (#1594) 2025-08-01 05:38:41 +02:00
dsehnal
f879519700 5.0.0-dev.2 2025-07-31 18:31:41 +02:00
zachcp
c6e175e5da Update field-schema.ts (#1593)
Follow on from #1587  to make `JS` explicit.
2025-07-31 18:29:28 +02:00
dsehnal
add75bf9c9 5.0.0-dev.1 2025-07-28 16:37:17 +02:00
dsehnal
57cbcd5fbf npm audit 2025-07-28 16:34:08 +02:00
giagitom
50a820b0ae - Add perturbatePositions property
- Fixes and improvements
2025-07-28 16:10:52 +02:00
zachcp
0a33936e06 Update field-schema.ts to point directly to PathReporter (#1587) 2025-07-28 07:48:00 +02:00
Alexander Rose
7291025e09 Merge pull request #1585 from molstar/scene-scale
Scene scale
2025-07-27 17:18:57 -07:00
giagitom
0cb2c3621b Dot volume representation improvements 2025-07-28 02:07:54 +02:00
Alexander Rose
86da258280 Merge branch 'master' of https://github.com/molstar/molstar into scene-scale 2025-07-26 17:42:54 -07:00
Alexander Rose
477a80d1ca fix post-processing params hideIf logic 2025-07-26 17:40:14 -07:00
Alexander Rose
86b68018a9 add scene scaling support 2025-07-26 17:39:44 -07:00
Alexander Rose
da095d6ef9 handling move/dragt before resolving pickData breaks ray-picking 2025-07-26 16:46:08 -07:00
Alexander Rose
dc304b9e08 Merge pull request #1583 from molstar/fix-async-buffer
fix async buffer issues
2025-07-26 16:22:43 -07:00
Alexander Rose
c905fa17c4 tweak 2025-07-26 16:22:33 -07:00
Alexander Rose
a06c64e8e0 Merge pull request #1584 from molstar/pp-switch
add `enable` param for post-processing effects
2025-07-26 16:05:25 -07:00
Alexander Rose
f5441290dd Merge pull request #1567 from giagitom/box3d-spec
add tests for box3D nearestIntersectionWithRay3D
2025-07-26 16:04:59 -07:00
Alexander Rose
9f23124317 move ray box intersection code to Ray3D 2025-07-26 15:59:40 -07:00
dsehnal
8299cd638c tweaks 2025-07-26 20:37:42 +02:00
Alexander Rose
50cb08e74d Merge branch 'master' of https://github.com/molstar/molstar into fix-async-buffer 2025-07-26 07:55:21 -07:00
Alexander Rose
89552652ba Merge branch 'master' of https://github.com/molstar/molstar into pp-switch 2025-07-26 07:55:02 -07:00
Alexander Rose
37ce577813 fix text shader 2025-07-26 07:54:28 -07:00
Alexander Rose
4d9a003141 add enable param for post-processing effects
- If false, no effects are applied.
2025-07-26 07:45:59 -07:00
Alexander Rose
6f0311a53f fix async buffer issues
- mark pick-helper dirty when async pick failed
- add pixel-pack buffer wrapper
- recover pixel-pack buffer after context loss (pick buffer, hi-z pass)
2025-07-26 07:30:54 -07:00
Alexander Rose
bfd2d6b055 text shader: head rotation tweak 2025-07-26 07:23:49 -07:00
Alexander Rose
3072e60709 Merge pull request #1582 from molstar/revert-1581-fix-async-identify
Revert "fix async identify"
2025-07-26 07:22:33 -07:00
Alexander Rose
62ed8d10e3 Revert "fix async identify (#1581)"
This reverts commit 13d3c34864.
2025-07-26 07:22:12 -07:00
David Sehnal
13d3c34864 fix async identify (#1581) 2025-07-25 18:42:29 +02:00
David Sehnal
cac433efca MVS Stories: Add "Download MVS State" link (#1580) 2025-07-25 14:31:07 +02:00
dsehnal
b25ffe7151 Canvas3dInteractionHelper fix 2025-07-25 10:53:02 +02:00
David Sehnal
31074dc74c fix inv het rotation uniform (#1578) 2025-07-25 10:34:13 +02:00
giagitom
c98c01a076 fix names 2025-07-23 18:24:56 +02:00
giagitom
8966fc9396 refactor 2025-07-23 18:23:12 +02:00
dsehnal
fdbdc551e8 fix web component syntax 2025-07-22 15:25:44 +02:00
dsehnal
bb232ac3a4 pass format in mvs stories app 2025-07-22 14:37:51 +02:00
giagitom
735c25ef8d Merge branch 'master' of https://github.com/molstar/molstar into box3d-spec 2025-07-22 14:13:07 +02:00
Alexander Rose
298043313a head rotation handling tweaks 2025-07-20 22:07:37 -07:00
Alexander Rose
77cd181b91 add addCylinderFromRay3D helper function 2025-07-20 09:02:31 -07:00
Alexander Rose
b5bee042e8 add groupCount argument to Shape.create 2025-07-20 08:48:43 -07:00
Alexander Rose
4faf17ddc7 Merge pull request #1576 from molstar/headrotation
Add head rotation support
2025-07-20 08:45:15 -07:00
Alexander Rose
28774b2277 fix scale issues in cylinders & spheres shaders 2025-07-19 16:13:48 -07:00
Alexander Rose
6a7444f44e add head rotation support
- handle skybox
- handle sphere & text billboards
2025-07-19 16:13:00 -07:00
Alexander Rose
15bfa8416a Merge pull request #1575 from molstar/async-ray-picking
add async & ray picking
2025-07-19 15:16:01 -07:00
Alexander Rose
e6895ec833 cleanup, simplify AsyncPickData 2025-07-19 15:08:46 -07:00
Alexander Rose
2099ad728a add async & ray picking 2025-07-19 09:13:49 -07:00
dsehnal
72ae3fae65 5.0.0-dev.0 2025-07-19 09:30:04 +02:00
dsehnal
bb5ad78681 eslint fix 2025-07-19 09:28:44 +02:00
dsehnal
f10e88612f npm audit 2025-07-19 09:26:55 +02:00
David Sehnal
a2e582d4a9 update MVS Stories app and deploy scripts (#1574)
* update MVS Stories app and deploy scripts

* reorder changelog
2025-07-19 09:25:32 +02:00
David Sehnal
572874f4ae Rename SymmetryOperator.canonicalName to instanceId (#1571) 2025-07-19 07:46:43 +02:00
David Sehnal
b9c0347497 MVS: grid-slice volume representation, label improvements, state transitions via 3D interactions, instacing (#1570)
* MVS: grid-slice volume representation

* tweak

* type fix

* label tether support

* snapshot_key support

* custom MVSShapeRepresentation3D

* renaming

* structure and volume instancing

* fix mixin
2025-07-19 07:46:01 +02:00
midlik
089148198f MVS operator_name (#1561)
* Change symmetry operator naming

* MVS operator_name selector for inline component, color, label, tooltip

* MVS operator_name selector in annotations (component/color/label/tooltip_from_uri/source)

* Revert changes to operatorName, add canonicalOperatorName instead, rename MVS selector field operator_name -> instance_id

* Update CHANGELOG

* Remove polyfill.io in mkdocs

* MVS: MultilayerColorThemeName decide granularity smartly

* MVS: MultilayerColorThemeName refactor
2025-07-11 20:45:22 +02:00
giagitom
6fc04c3294 add tests for box3D nearestIntersectionWithRay3D 2025-07-07 18:46:06 +02:00
Alexander Rose
dc55577e22 chanelog 2025-07-06 15:28:43 -07:00
Alexander Rose
f7ba7c0511 add Ray3D and math fixes/improvements 2025-07-06 15:24:47 -07:00
Alexander Rose
ed5374fab9 improve volume visual group count update 2025-07-06 10:10:02 -07:00
Alexander Rose
9a04b4f0df instanced volume (#1557)
* wip, instanced volume

* add Orderset.isEmpty and Interval.offset

* add Box3D.addBox3D

* support volume instances

- add Volume.instances
- add Volume.InstanceIndex and Volume.SegmentIndex types
- volume loci improvements

* add volume-instance color theme

* add VolumeInstances xform

* breaking note

* trailing space

* remove setting that breaks ESlint in VSCode

* tweak angle param

* reuse volume visuals when only instance transforms change

* tweaks

---------

Co-authored-by: dsehnal <david.sehnal@gmail.com>
2025-07-06 19:04:03 +02:00
Alexander Rose
9350e539b6 Merge pull request #1566 from molstar/fix-group-count
Fix group count calculation on geometry update
2025-07-06 09:48:20 -07:00
Alexander Rose
c38377af46 Merge pull request #1564 from molstar/mol2-improvements
Mol2 Reader improvements
2025-07-05 16:57:00 -07:00
Alexander Rose
9804febd95 Merge branch 'master' into mol2-improvements 2025-07-05 16:56:51 -07:00
Alexander Rose
7936dc1840 Fix wrong instance index in calcMeshColorSmoothing 2025-07-05 15:53:08 -07:00
Alexander Rose
a033a8be36 Fix group count calculation on geometry update 2025-07-04 23:24:39 -07:00
Alexander Rose
4b84c6dcba fix typo MarchinCubes -> MarchingCubes 2025-07-04 23:22:38 -07:00
Alexander Rose
309d792fdb fix shader error when clipping flags are set without clip objects present 2025-07-04 17:51:59 -07:00
Alexander Rose
c437254680 add substructure spec 2025-07-04 16:15:55 -07:00
Alexander Rose
6fbf7c7a22 fix spec 2025-07-04 16:01:55 -07:00
Alexander Rose
86a7520b90 Mol2 Reader improvements
- Fix column count parsing
- Add support for substructure
2025-07-04 15:11:43 -07:00
David Sehnal
cd10043447 MVS: clip node support (#1553)
* MVS: clip node support

* rename transform to point_transform

* fix vec3/mat4 control overflow

* refactor mvs clipping

* unused var

* tweaks
2025-07-04 18:03:37 +02:00
David Sehnal
146e95cb23 Snapshot Markdown Improvements (#1555)
* basic markdown commands

* markdown renderers

* support markdown tables

* fix style

* indicate external links in markdown

* simplify the api

* load image from MVSX

* lint

* docs

* typo

* custom color palette support

* move manager to mol-plugin-state

* customize args parser

* better custom args parser support
2025-07-04 10:29:03 +02:00
David Sehnal
13b1e5d59c Async Viewer Init (#1394)
* async viewer init

* changelog

* tweak changelog

* make context init functions async
2025-07-01 09:52:45 +02:00
Alexander Rose
ae3efa53d6 Merge pull request #1556 from molstar/coarsegrained-unit-trait
Avoid calculating rings for coarse-grained structures
2025-06-29 22:16:32 -07:00
Alexander Rose
2e67fbe870 Merge branch 'master' into coarsegrained-unit-trait 2025-06-29 22:16:20 -07:00
dsehnal
56df6f82a7 docs tweak 2025-06-29 21:16:34 +02:00
Alexander Rose
fdd874b7a6 Merge pull request #1554 from giagitom/isosurface-fix
Fix isosurface compute shader normals when transformation matrix is applied to volume
2025-06-28 17:22:38 -07:00
Alexander Rose
f142c3ef1b lint 2025-06-28 17:20:04 -07:00
Alexander Rose
978b53e7d8 Avoid calculating rings for coarse-grained structures
- add `Unit.Traits.CoarseGrained`
2025-06-28 17:06:44 -07:00
Alexander Rose
2f3197479d Merge pull request #1550 from giagitom/illumination-fix
Fix outlines on opaque elements using illumination mode
2025-06-28 16:12:31 -07:00
Alexander Rose
6536d0ab91 Merge branch 'master' into illumination-fix 2025-06-28 16:12:09 -07:00
Alexander Rose
3bee224e7d Merge pull request #1549 from molstar/gl-refactor
WebGL related refactoring
2025-06-28 15:56:00 -07:00
Alexander Rose
3e63137977 Merge branch 'master' of https://github.com/molstar/molstar into gl-refactor 2025-06-28 15:53:29 -07:00
Alexander Rose
38d6bc6c27 tweak 2025-06-28 15:52:43 -07:00
Alexander Rose
fafe22d56b Apply suggestions from code review
Co-authored-by: David Sehnal <dsehnal@users.noreply.github.com>
2025-06-28 15:45:32 -07:00
giagitom
a6a92bcf91 lint fix 2025-06-28 23:03:39 +02:00
giagitom
82c681f445 improve performances 2025-06-28 23:01:04 +02:00
giagitom
fbbd58b4db Fix isosurface compute shader normals when transformation matrix is applied to volume 2025-06-28 17:34:52 +02:00
David Sehnal
2dc13f082c Add custom extensions to MVS (#1547)
* Add custom extensions to MVS

* typo

* lint
2025-06-24 17:04:01 +02:00
midlik
ab5eb5993d MVS generic color themes (#1530)
* MVS: CategoricalPalette draft

* MVS: tweak param validation for union, nullable

* MVSAnnotationColorTheme: support categorical palette

* MVS: color theme categorical with list or mapping

* MVS: color theme categorical with named palette

* Add missing color lists

* Sort color lists

* MVS: color theme categorical tidyup

* MVS: color theme continuous params

* MVS: color theme continuous impl

* refactor

* MVS: color theme continuous - reverse, auto overflow_color

* MVS: color theme discrete

* file reorg

* MVS: param union does not need []

* MVS: refactor typing object params

* MVS: color theme - palette defaults in one place

* MVS: declare fields_remapping param

* MVS: implement fields_remapping param

* MVS: docs

* Update CHANGELOG

* MVS: rename fields_remapping -> field_remapping

* PR feedback

* MVS: Generic color themes - case_insensitive param

* MVS: SecondaryStructure named color dict

* Remove accidentaly added file

* Update color map descriptions

* Revert color scheme renaming, keep for v5

* Revert "Revert color scheme renaming, keep for v5"

This reverts commit 12e25c20fe.

* Added color list type "cyclical"

* Color palettes - show description in UI tooltips

* Fixed docstrings
2025-06-24 12:48:39 +02:00
Alexander Rose
2384003f5d add https support for dev server (#1548) 2025-06-24 12:41:07 +02:00
David Sehnal
3675c0afe0 Update Representation.Empty creation (#1546) 2025-06-24 12:40:50 +02:00
giagitom
d9bae488e9 Fix outlines on opaque elements using illumination mode 2025-06-23 17:58:53 +02:00
Alexander Rose
e31e5321ba refactor (abstract) webgl drawing buffer handling 2025-06-22 16:43:22 -07:00
Alexander Rose
8c7f8b8a56 remove unused WebGLContext.getDrawingBufferPixelData 2025-06-22 16:38:18 -07:00
Alexander Rose
e4dfb5148c add support for webgl multiview2 extension 2025-06-22 16:35:13 -07:00
Alexander Rose
39e2591b60 improve webgl error handling 2025-06-22 16:31:51 -07:00
David Sehnal
f8a5237024 Improve production build (#1542)
* production build using esbuild

* build browser tests with esbuild

* use tsc-alias

* remove webpack

* changelog

* update eslint to v9

* pr feedback

* update build

* include src map by default
2025-06-17 18:15:10 +02:00
MadCatX
6c2d5b9da7 Do not display NtC Tube and Confal pyramids Loci labels as verbatim text (#1543) 2025-06-16 10:45:08 +02:00
midlik
e128d85356 StringLike type include string explicitely (#1539) 2025-06-12 18:08:56 +02:00
Alexander Rose
08a929bb2f 4.18.0 2025-06-08 13:40:21 -07:00
Alexander Rose
5a54b3ef66 changelog 2025-06-08 13:37:31 -07:00
Alexander Rose
a0c897547a schema updates 2025-06-08 13:37:25 -07:00
Alexander Rose
89ce8394fd package updates 2025-06-08 13:34:08 -07:00
Alexander Rose
ea0331e95c Merge branch 'master' of https://github.com/molstar/molstar 2025-06-08 13:28:12 -07:00
Alexander Rose
9f220b55c2 fix mc scalar field diff (@giagitom) 2025-06-08 13:28:10 -07:00
Alexander Rose
acf248d58f Merge pull request #1535 from molstar/arbitrary-plane-sampling
Support sampling from arbitrary planes
2025-06-08 13:26:34 -07:00
Alexander Rose
c83b859766 type tweak 2025-06-08 13:26:21 -07:00
Alexander Rose
33a2564893 Merge branch 'master' into arbitrary-plane-sampling 2025-06-08 13:25:58 -07:00
David Sehnal
d409c4f5ea Fix SASS @import depraction warnings (#1534)
* refactor SASS to not use @import

* changelog

* typo
2025-06-08 09:33:39 +02:00
Alexander Rose
ab61e31230 header 2025-06-07 16:29:28 -07:00
Alexander Rose
ae9c2dd9d8 Support sampling from arbitrary planes
- structure plane and volume slice representations
2025-06-07 16:27:37 -07:00
David Sehnal
c17edb4928 isolatedModules and fix turbopack build (#1533)
* isolated modules tsconfig & fix errors

* fix mol-math imports

* fix turbopack builds

* fix typo

* tweak

* undo gl-shim change
2025-06-02 18:59:42 +02:00
Alexander Rose
528377eb47 Merge pull request #1532 from molstar/xray-picking
Support `pickingAlphaThreshold` when `xrayShaded` is enabled
2025-06-01 08:48:01 -07:00
Alexander Rose
c9819369d0 header 2025-05-31 11:33:18 -07:00
Alexander Rose
cdbbbfa6dd Support pickingAlphaThreshold when xrayShaded is enabled 2025-05-31 11:29:58 -07:00
David Sehnal
a1e31c79e9 MVS: FoV adjusted position Camera Info & MVSX assets in multi-snapshot states (#1531)
* FoV adjusted position Camera Info

* Fix MVSX file assets being disposed in multi-snapshot states

* pr feedback
2025-05-30 19:19:29 +02:00
midlik
e027fe46c1 MVS: Support for label_comp_id and auth_comp_id in annotations (#1529)
* MVS: Support for label_comp_id and auth_comp_id in annotations

* MVS: Primitives recognize empty substructures, distance_measurement refactor

* MVS: Primitives skipped when empty substructure, nicer default arrow caps

* MVS: Primitive angle_measurement added vector_radius param
2025-05-23 16:17:26 +02:00
dsehnal
05c4006e9d 4.17.0 2025-05-22 07:01:43 +02:00
dsehnal
191ea65c9d changelog 2025-05-22 06:58:02 +02:00
David Sehnal
3c1ee16376 remove salt bridge interaction kind (#1528) 2025-05-22 06:56:15 +02:00
David Sehnal
9ac34ee13b Add mvs-stories app (#1523)
* mvs-stories app

* update mvs-stories example

* fix build

* fix UI bug

* support search params in stories app

* merge fixes

* PR feedback

* customize build filenames

* mvs-stories loading state & dev build script fixes

* multiple context example
2025-05-22 06:49:17 +02:00
midlik
6778452d07 NodeJS ajaxGet support gzip (#1516)
* Retype string to StringLike in parsers

* Define minimal CustomString interface

* ChunkedBigString

* Test ChunkedBigString with cif2bcif

* benchmarking

* ChunkedBigString access optimization

* ChunkedBigString .length optimization

* ChunkedBigString.indexOf, tests

* ChunkedBigString remove [] in favor of charAt

* ChunkedBigString tidy up

* ChunkedBigString .substring optimization

* ChunkedBigString for browser

* ChunkedBigString for drag-and-drop

* ChunkedBigString fixes

* Simplify readFromFileInternal

* Correctly type DataResponse<'string'> as StringLike

* Update CHANGELOG

* PR feedback

* Workaround for ajaxGet in NodeJS when content gzipped

* Workaround for ajaxGet in NodeJS when content gzipped - allow aborting

* ajaxGetInternal_file_NodeJS - async read file

* Eliminate xhr2

* Remove xhr2 dependency

* Update file headers
2025-05-21 14:49:54 +02:00
Alexander Rose
7e01af1e0d 4.16.0 2025-05-20 21:07:34 -07:00
Alexander Rose
85469cbf28 changelog 2025-05-20 21:04:22 -07:00
Alexander Rose
299bdc72cd Merge pull request #1525 from molstar/mp4-export-fix-25-5-20
Fix camera interpolation during animation export
2025-05-20 21:01:41 -07:00
dsehnal
ae9f879139 use isContextLost flag instead of pausing/resuming animation 2025-05-21 05:53:26 +02:00
dsehnal
b50d83d6ea replace behavior subject with subject 2025-05-20 18:02:40 +02:00
dsehnal
2d99d8a1d0 do not pause animation during context loss 2025-05-20 17:32:45 +02:00
midlik
ea00cca1c8 MVS single state loading (#1524)
* MVS: Load single state as if multistate

* MVS: join keepCamera and keepSnapshotCamera options

* MVS: remove replaceExisting, addappendSnapshots option
2025-05-20 17:24:25 +02:00
midlik
27c3b4e698 Big strings (#1479)
* Retype string to StringLike in parsers

* Define minimal CustomString interface

* ChunkedBigString

* Test ChunkedBigString with cif2bcif

* benchmarking

* ChunkedBigString access optimization

* ChunkedBigString .length optimization

* ChunkedBigString.indexOf, tests

* ChunkedBigString remove [] in favor of charAt

* ChunkedBigString tidy up

* ChunkedBigString .substring optimization

* ChunkedBigString for browser

* ChunkedBigString for drag-and-drop

* ChunkedBigString fixes

* Simplify readFromFileInternal

* Correctly type DataResponse<'string'> as StringLike

* Update CHANGELOG

* PR feedback

* PR feedback 2
2025-05-20 11:55:20 +02:00
Alexander Rose
52942e7021 4.15.0 2025-05-19 19:31:09 -07:00
Alexander Rose
5904f694b5 changelog 2025-05-19 19:28:29 -07:00
Alexander Rose
92c0b82784 pckage updates 2025-05-19 19:28:16 -07:00
Alexander Rose
e1226fa384 type fix 2025-05-19 19:27:59 -07:00
Alexander Rose
ac2f7d1c38 lint 2025-05-19 19:27:48 -07:00
Alexander Rose
ae1742f68e Merge pull request #1520 from molstar/fix-contextlost-handling
WebGL ContextLost handling improvements
2025-05-18 14:47:36 -07:00
Alexander Rose
04e2da86fd typo 2025-05-17 16:39:06 -07:00
Alexander Rose
510182ff60 WebGL ContextLost handling improvements
- Fix missing framebuffer & drawbuffer re-attachments
- Fix missing cube texture re-initialization
- Fix missing extensions reset
- Fix timer clearing edge case
- Add reset support for geometry generated on he GPU
2025-05-17 16:36:20 -07:00
Alexander Rose
4e1da19bdd Merge pull request #1506 from sbittrich/master
IHM improvements: Enable assembly symmetry, disable volume streaming and validation report visualization
2025-05-17 13:46:26 -07:00
Alexander Rose
a3eae15446 Merge branch 'master' into master 2025-05-17 13:15:04 -07:00
David Sehnal
4334f4d1fa JSON CIF format and ligand editor example (#1510)
* wip data model and writer

* parser & test

* minimal editor example

* molstar_bond_site cif category support

* ligand graph, deletion, simple undo

* jest config

* bond editing and graph change summary

* readme

* ts config

* basic atom addition

* undo path aliases because tsc doesn't transform them

* tweak package json

* basic rgroup support

* mol parsing test and fixes

* refactoring

* molfile conversion

* refactoring and UI polish

* molfile export tweaks

* move jsonCifToMolfile

* tweak

* refactoring

* error reporting

* geometry edits

* hide selection controls

* refactoring

* tweaks

* changelog

* ligand graph tests

* SingleTaskQueue tweak

* revert Column changes

* pr feedback

* PR Feedback
2025-05-13 10:51:39 +02:00
Alexander Rose
e33ed54121 Merge pull request #1513 from midlik/fix-transparent-ssao-nodejs
Fix transparency rendering with occlusion in NodeJS
2025-05-10 08:51:41 -07:00
Adam Midlik
ae8f037192 Fix transparency rendering with occlusion in NodeJS 2025-05-09 14:12:31 +01:00
Alexander Rose
01271941dd Merge pull request #1509 from molstar/tweak-auto-quality-surface-res
adjust max resolution for auto quality
2025-05-08 22:08:33 -07:00
dsehnal
7f8be5b8c6 4.14.1 2025-05-09 07:02:52 +02:00
David Sehnal
2ab6e4b2e7 No error in Transformer.create (#1512)
* No error in Transformer.create

* header
2025-05-09 06:59:15 +02:00
Sebastian Bittrich
aa22840b12 Merge remote-tracking branch 'upstream/master'
# Conflicts:
#	CHANGELOG.md
2025-05-08 10:36:30 -07:00
Sebastian Bittrich
c1e33fac94 PR feedback: adjust assembly symmetry logic 2025-05-08 10:23:58 -07:00
Sebastian Bittrich
a7336095ca name IHM assemblies "deposited" 2025-05-07 09:47:26 -07:00
dsehnal
4a88546181 changelog 2025-05-07 16:08:17 +02:00
dsehnal
edbc70cf6e 4.14.0 2025-05-07 16:07:49 +02:00
dsehnal
c22ad2910c npm audit 2025-05-07 16:04:36 +02:00
dsehnal
28a2b52e3c changelog 2025-05-07 16:03:53 +02:00
David Sehnal
449d572ed5 Merge branch 'master' into tweak-auto-quality-surface-res 2025-05-07 14:50:09 +02:00
Gianluca Tomasello
470227af43 Avoid grid expansion when requiring unit cell on volume server (#1502)
* Avoid grid expansion when requiring unit cell on volume server

* Increment version and use specific changelog

* change header
2025-05-07 14:49:08 +02:00
dsehnal
a0ccf46939 remove extra import 2025-05-05 08:10:03 +02:00
dsehnal
0ce8931fc5 Fix switching representation type in Volume UI 2025-05-04 18:28:19 +02:00
David Sehnal
3ddb29fc6f Add format selection option to image export UI (#1504)
* Add format selection option to image export UI

* check if webp is supported
2025-05-04 14:54:17 +02:00
Alexander Rose
1a0c65df21 changelog 2025-05-03 08:26:01 -07:00
Alexander Rose
daad1923ea adjust max resolution for auto quality (#1501) 2025-05-03 08:22:56 -07:00
David Sehnal
f34f879cf1 MVS: support updating transform states (#1505)
* MVS: support updating transform states

* changelog

* changelog

* pr feedback

* util functions & is_hidden extension
2025-05-02 12:53:38 +02:00
Sebastian Bittrich
f47b76c8af Merge remote-tracking branch 'upstream/master'
# Conflicts:
#	CHANGELOG.md
2025-05-01 11:17:52 -07:00
Sebastian Bittrich
6ee9eb8b60 IHM: disable validation report 2025-05-01 11:16:53 -07:00
Sebastian Bittrich
915703a46d IHM: enable assembly symmetry 2025-05-01 11:13:46 -07:00
Sebastian Bittrich
61c3c19ae3 IHM: disable volume streaming 2025-05-01 11:11:50 -07:00
David Sehnal
6da9557531 Fix StructConn.residueCantorPairs (#1500) 2025-05-01 11:29:24 +02:00
Paul Lewallen
29e6d69d21 Update package.json (#1497)
Add engines field to specify required Node.js version (>=18.0.0)
2025-04-26 07:46:50 +02:00
David Sehnal
6b2b87e6c5 fix Viewer.loadTrajectory (#1496) 2025-04-24 10:44:59 +02:00
dsehnal
5299d5c0c4 changelog 2025-04-14 18:37:52 +02:00
428 changed files with 24266 additions and 21242 deletions

View File

@@ -1,4 +0,0 @@
node_modules/*
build/*
docs/site/*
lib/*

View File

@@ -1,122 +0,0 @@
{
"env": {
"browser": true,
"node": true
},
"parserOptions": {
"ecmaVersion": 2018,
"sourceType": "module",
"ecmaFeatures": {
"impliedStrict": true
}
},
"rules": {
"indent": "off",
"arrow-parens": [
"off",
"as-needed"
],
"brace-style": "off",
"comma-spacing": "off",
"space-infix-ops": "off",
"comma-dangle": "off",
"eqeqeq": [
"error",
"smart"
],
"import/order": "off",
"no-eval": "warn",
"no-new-wrappers": "warn",
"no-trailing-spaces": "error",
"no-unsafe-finally": "warn",
"no-var": "error",
"spaced-comment": "error",
"semi": "warn",
"no-restricted-syntax": [
"error",
{
"selector": "ExportDefaultDeclaration",
"message": "Default exports are not allowed"
}
],
"no-throw-literal": "error",
"key-spacing": "error",
"object-curly-spacing": ["error", "always"],
"array-bracket-spacing": "error",
"space-in-parens": "error",
"computed-property-spacing": "error",
"prefer-const": ["error", {
"destructuring": "all",
"ignoreReadBeforeAssign": false
}],
"space-before-function-paren": "off",
"func-call-spacing": "off",
"no-multi-spaces": "error",
"block-spacing": "error",
"keyword-spacing": "off",
"space-before-blocks": "error",
"semi-spacing": "error",
"no-constant-binary-expression": "error"
},
"overrides": [
{
"files": ["**/*.ts", "**/*.tsx"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": ["tsconfig.json", "tsconfig.commonjs.json"],
"sourceType": "module"
},
"plugins": [
"@typescript-eslint"
],
"rules": {
"@typescript-eslint/ban-types": "off",
"@typescript-eslint/class-name-casing": "off",
"@typescript-eslint/indent": [
"error",
4
],
"@typescript-eslint/member-delimiter-style": [
"off",
{
"multiline": {
"delimiter": "none",
"requireLast": true
},
"singleline": {
"delimiter": "semi",
"requireLast": false
}
}
],
"@typescript-eslint/prefer-namespace-keyword": "warn",
"@typescript-eslint/quotes": [
"error",
"single",
{
"avoidEscape": true,
"allowTemplateLiterals": true
}
],
"@typescript-eslint/semi": [
"off",
null
],
"@typescript-eslint/type-annotation-spacing": "error",
"@typescript-eslint/brace-style": [
"error",
"1tbs", { "allowSingleLine": true }
],
"@typescript-eslint/comma-spacing": "error",
"@typescript-eslint/space-infix-ops": "error",
"@typescript-eslint/space-before-function-paren": ["error", {
"anonymous": "always",
"named": "never",
"asyncArrow": "always"
}],
"@typescript-eslint/func-call-spacing": ["error"],
"@typescript-eslint/keyword-spacing": ["error"]
}
}
]
}

7
.gitignore vendored
View File

@@ -1,4 +1,5 @@
build/
deploy/
lib/
docs/site/
@@ -11,4 +12,8 @@ tsconfig.commonjs.tsbuildinfo
*.sublime-workspace
.idea
.DS_Store
.DS_Store
tmp/
dev.pem
dev-key.pem

View File

@@ -6,9 +6,4 @@
"*.vert.ts": "glsl",
"*.gql.ts": "graphql"
},
"eslint.options": {
"overrideConfig": {
"ignorePatterns": ["webpack.config.js", "scripts/*"],
},
}
}

View File

@@ -4,7 +4,175 @@ All notable changes to this project will be documented in this file, following t
Note that since we don't clearly distinguish between a public and private interfaces there will be changes in non-major versions that are potentially breaking. If we make breaking changes to less used interfaces we will highlight it in here.
## [Unreleased]
- Support `--host` option for build-dev.mjs script.
- [Breaking] Renamed some color schemes ('inferno' -> 'inferno-no-black', 'magma' -> 'magma-no-black', 'turbo' -> 'turbo-no-black', 'rainbow' -> 'simple-rainbow')
- [Breaking] `Box3D.nearestIntersectionWithRay` -> `Ray3D.intersectBox3D`
- [Breaking] `Plane3D.distanceToSpher3D` -> `distanceToSphere3D` (fix spelling)
- [Breaking] fix typo `MarchinCubes` -> `MarchingCubes`
- [Breaking] `PluginContext.initViewer/initContainer/mount` are now async and have been renamed to include `Async` postfix
- [Breaking] Add `Volume.instances` support and a `VolumeInstances` transform to dynamically assign it
- This change is breaking because all volume objects require the `instances` field now.
- [Breaking] `Canvas3D.identify` now expects `Vec2` or `Ray3D`
- [Breaking] `TrackballControlsParams.animate.spin.speed` now means "Number of rotations per second" instead of "radians per second"
- [Breaking] `PluginStateSnapshotManager.play` now accepts an options object instead of a single boolean value
- Update production build to use `esbuild`
- Emit explicit paths in `import`s in `lib/`
- Fix outlines on opaque elements using illumination mode
- Change `Representation.Empty` to a lazy property to avoid issue with some bundlers
- MolViewSpec extension:
- Generic color schemes (`palette` parameter for color_from_* nodes)
- Annotation field remapping (`field_remapping` parameter for color_from_* nodes)
- `representation` node: support custom property `molstar_representation_params`
- Add `backbone` and `line` representation types
- `primitives` node: support custom property `molstar_mesh/label/line_params`
- `canvas` node: support custom property `molstar_postprocessing` with the ability to customize outline, depth of field, bloom, shadow, occlusion (SSAO), and fog
- `clip` node support for structure and volume representations
- `grid_slice` representation support for volumes
- Support tethers and background for primitive labels
- Support `snapshot_key` parameter on primitives that enables transition between states via clicking on 3D objects
- Inline selectors and MVS annotations support `instance_id`
- Support `matrix` on transform params
- Support `surface_type` (`molecular` / `gaussian`) on for `surface` representation nodes
- Add `instance` node type
- Add `transform.rotation_center` property that enables rotating an object around its centroid or a specific point
- Support transforming and instancing of structures, components, and volumes
- Use params hash for node version for more performant tree diffs
- Add `Snapshot.animation` support that enables animating almost every property in a given tree
- Add `createMVSX` helper function
- Support Mol* trackball animation via `animation.custom.molstar_trackball`
- MVSX - use Murmur hash instead of FNV in archive URI
- Support additional file formats (pdbqt, gro, xyz, mol, sdf, mol2, xtc, lammpstrj)
- Support loading trajectory coordinates from separate nodes
- Trigger markdown commands from primitives using `molstar_markdown_commands` custom extensions
- Support `molstar_on_load_markdown_commands` custom state on the `root` node
- Added new color schemes, synchronized with D3.js ('inferno', 'magma', 'turbo', 'rainbow', 'sinebow', 'warm', 'cool', 'cubehelix-default', 'category-10', 'observable-10', 'tableau-10')
- Snapshot Markdown improvements
- Add `MarkdownExtensionManager` (`PluginContext.managers.markdownExtensions`)
- Support custom markdown commands to control the plugin via the `[link](!command)` pattern
- Support rendering custom elements via the `![alt](!parameters)` pattern
- Support tables
- Support loading images and audio from MVSX files
- Indicate external links with ⤴
- Audio support
- Add `PluginState.Snapshot.onLoadMarkdownCommands`
- Avoid calculating rings for coarse-grained structures
- Fix isosurface compute shader normals when transformation matrix is applied to volume
- Symmetry operator naming for spacegroup symmetry - parenthesize multi-character indices (1_111-1 -> 1_(11)1(-1))
- Add `SymmetryOperator.instanceId` that corresponds to a canonical operator name (e.g. ASM-1, ASM-X0-1 for assemblies, 1_555, 1_(11)1(-1) for crystals)
- Mol2 Reader
- Fix column count parsing
- Add support for substructure
- Fix shader error when clipping flags are set without clip objects present
- Fix wrong group count calculation on geometry update (#1562)
- Fix wrong instance index in `calcMeshColorSmoothing`
- Add `Ray3D` object and helpers
- Volume slice representation: add `relativeX/Y/Z` options for dimension
- Add `StructureInstances` transform
- `mvs-stories` app
- Add `story-id` URL arg support
- Add `story-session-url` URL arg support
- Add "Download MVS State" link
- Add "Open in Mol*" link
- Add "Edit in MolViewStories" link for story states
- Add ray-based picking
- Render narrow view of scene scene from ray origin & direction to a few pixel sized viewport
- Cast ray on every input as opposed to the standard "whole screen" picking
- Can be enabled with new `Canvas3dInteractionHelperParams.convertCoordsToRay` param
- Allows to have input methods that are 3D pointers in the scene
- Add `ray: Ray3D` property to `DragInput`, `ClickInput`, and `MoveInput`
- Add async, non-blocking picking (only WebGL2)
- Refactor `Canvas3dInteractionHelper` internals to use async picking for move events
- Add `enable` param for post-processing effects. If false, no effects are applied.
- Dot volume representation improvements
- Add positional perturbation to avoid camera artifacts
- Fix handling of negative isoValues by considering only volume cells with values lower than isoValue (#1559)
- Fix volume-value size theme
- Change the parsing of residue names in PDB files from 3-letter to 4-letter.
- Support versioning transform using a hash function in `mol-state`
- Support for "state snapshot transitions"
- Add `PluginState.Snapshot.transition` that enables associating a state snapshot with a list states that can be animated
- Add `AnimateStateSnapshotTransition` animation
- Update the snapshots UI to support this feature
- Use "proper time" in the animation loop to prevent animation skips during blocking operations (e.g., shader complication)
- Add `Hsl` and (normalized) `Rgb` color spaces
- Add `Color.interpolateHsl`
- Add `rotationCenter` property to `TransformParam`
- Add Monolayer transparency (exploiting dpoit).
- Add plugin config item ShowReset (shows/hides "Reset Zoom" button)
- Fix transform params not being normalized when used together with param hash version
- Replace `immer` with `mutative`
## [v4.18.0] - 2025-06-08
- MolViewSpec extension:
- Support for label_comp_id and auth_comp_id in annotations
- Geometric primitives - do not render if position refers to empty substructure
- Primitive arrow - nicer default cap size (relative to tube_radius)
- Primitive angle_measurement - added vector_radius param
- Fix MVSX file assets being disposed in multi-snapshot states
- Add `mol-utils/camera.ts` with `fovAdjustedPosition` and `fovNormalizedCameraPosition`
- Show FOV normalized position in `CameraInfo` UI and use it in "Copy MVS State"
- Support static resources in `AssetManager`
- General:
- Use `isolatedModules` tsconfig flag
- Fix TurboPack build when using ES6 modules
- Support `pickingAlphaThreshold` when `xrayShaded` is enabled
- Support sampling from arbitrary planes for structure plane and volume slice representations
- Refactor SCSS to not use `@import` (fixes deprecation warnings)
## [v4.17.0] - 2025-05-22
- Remove `xhr2` dependency for NodeJS, use `fetch`
- Add `mvs-stories` app included in the `molstar` NPM package
- Use the app in the corresponding example
- Interactions extension: remove `salt-bridge` interaction kind (since `ionic` is supported too)
## [v4.16.0] - 2025-05-20
- Load potentially big text files as `StringLike` to bypass string size limit
- MolViewSpec extension:
- Load single-state MVS as if it were multi-state with one state
- Merged `loadMVS` options `keepCamera` and `keepSnapshotCamera` -> `keepCamera`
- Removed `loadMVS` option `replaceExisting` (is now default)
- Added `loadMVS` option `appendSnapshots`
- Fix camera not being interpolated in MP4 export due to updates in WebGL ContextLost handling
## [v4.15.0] - 2025-05-19
- IHM improvements:
- Disable volume streaming
- Disable validation report visualization
- Enable assembly symmetry for integrative models
- Fix transparency rendering with occlusion in NodeJS
- mmCIF Support
- Add custom `molstar_bond_site` category that enables serializing explicit bonds by referencing `atom_site.id`
- Add `includeCategoryNames`, `keepAtomSiteId`, `exportExplicitBonds`, `encoder` properties to `to_mmCIF` exporter
- Add support for attachment points property (`M APO`) to the MOL V2000 parser
- Add `json-cif` extension that should pave way towards structure editing capabilities in Mol\*
- JSON-based encoding of the CIF data format
- `JSONCifLigandGraph` that enables editing of small molecules via modifying `atom_site` and `molstar_bond_site` categories
- Add `ligand-editor` example that showcases possible use-cases of the `json-cif` extension
- Breaking (minor): Changed `atom_site.id` indexing to 1-based in `mol-model-formats/structure/mol.ts::getMolModels`.
- WebGL ContextLost handling
- Fix missing framebuffer & drawbuffer re-attachments
- Fix missing cube texture re-initialization
- Fix missing extensions reset
- Fix timer clearing edge case
- Add reset support for geometry generated on the GPU
## [v4.14.1] - 2025-05-09
- Do not raise error when creating duplicate state transformers and print console warning instead
## [v4.14.0] - 2025-05-07
- Fix `Viewer.loadTrajectory` when loading a topology file
- Fix `StructConn.residueCantorPairs` to not include identity pairs
- Add format selection option to image export UI (PNG, WebP, JPEG)
- Add `StateBuilder.To.updateState`
- MVS:
- Support updating transform states
- Add support for `is_hidden` custom state as an extension
- Add `queryMVSRef` and `createMVSRefMap` utility functions
- Adjust max resolution of surfaces for auto quality (#1501)
- Fix switching representation type in Volume UI
- VolumeServer: Avoid grid expansion when requiring unit cell (avoids including an extra layer of cells outside the unit cell query box)
## [v4.13.0] - 2025-04-14
- Support `--host` option for build-dev.mjs script
- Add `Viewer.loadFiles` to open supported files
- Support installing the viewer as a Progressive Web App (PWA)
- `ihm-restraints` example: show entity labels
@@ -37,7 +205,7 @@ Note that since we don't clearly distinguish between a public and private interf
- Fix MolViewSpec builder for volumes.
- Generalize `mvs-kinase-story` example to `mvs-stories`
- Add TATA-binding protein story
- Improve the Kinase story
- Improve the Kinase story
- Fix alpha orbitals example
## [v4.12.0] - 2025-02-28

View File

@@ -190,9 +190,14 @@ To get syntax highlighting for shader files add the following to Visual Code's s
npm publish
## Deploy
To prepare apps and demos for https://molstar.org deploy, run:
npm run test
npm run build
node ./scripts/deploy.js # currently updates the viewer on molstar.org/viewer
npm run deploy:local
To commit these changes remotely to the `molstar/molstar.github.io` repo:
npm run deploy:remote
## Contributing
Just open an issue or make a pull request. All contributions are welcome.

View File

@@ -1,216 +0,0 @@
/**
* Copyright (c) 2017-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
* @author Eric E <etongfu@@outlook.com>
*/
import * as esbuild from 'esbuild';
import * as fs from 'fs';
import * as path from 'path';
import * as argparse from 'argparse';
import { sassPlugin } from 'esbuild-sass-plugin';
import * as os from 'os';
const AllApps = [
'viewer',
'docking-viewer',
'mesoscale-explorer'
];
const AllExamples = [
'proteopedia-wrapper',
'basic-wrapper',
'lighting',
'alpha-orbitals',
'alphafolddb-pae',
'mvs-stories',
'ihm-restraints',
'interactions',
];
function mkDir(dir) {
try {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
} catch (error) {
console.error(`Failed to create directory ${dir}:`, error);
process.exit(1);
}
}
function handleFileError(error, operation, path) {
console.error(`Failed to ${operation} ${path}:`, error);
process.exit(1);
}
function fileLoaderPlugin(options) {
mkDir(options.out);
return {
name: 'file-loader',
setup(build) {
build.onLoad({ filter: /\.jpg$/ }, async (args) => {
try {
const name = path.basename(args.path);
mkDir(path.resolve(options.out, 'images'));
await fs.promises.copyFile(args.path, path.resolve(options.out, 'images', name));
return {
contents: `images/${name}`,
loader: 'text',
};
} catch (error) {
handleFileError(error, 'copy', args.path);
}
});
build.onLoad({ filter: /\.(html|ico)$/ }, async (args) => {
const name = path.basename(args.path);
await fs.promises.copyFile(args.path, path.resolve(options.out, name));
return {
contents: '',
loader: 'empty',
};
});
},
};
}
function examplesCssRenamePlugin({ root }) {
return {
name: 'example-css-rename',
setup(build) {
build.onEnd(async () => {
if (fs.existsSync(path.resolve(root, 'index.css'))) {
await fs.promises.rename(
path.resolve(root, 'index.css'),
path.resolve(root, 'molstar.css')
);
}
});
}
};
}
async function watch(name, kind) {
const prefix = kind === 'app'
? `./build/${name}`
: `./build/examples/${name}`;
let entry = `./src/${kind}s/${name}/index.ts`;
if (!fs.existsSync(entry)) {
entry = `./src/${kind}s/${name}/index.tsx`;
}
const ctx = await esbuild.context({
entryPoints: [entry],
tsconfig: './tsconfig.json',
bundle: true,
globalName: 'molstar',
outfile: kind === 'app'
? `./build/${name}/molstar.js`
: `./build/examples/${name}/index.js`,
plugins: [
fileLoaderPlugin({ out: prefix }),
sassPlugin({
type: 'css',
silenceDeprecations: ['import'],
logger: {
warn: (msg) => console.warn(msg),
debug: () => { },
}
}),
...(kind === 'example' ? [examplesCssRenamePlugin({ root: prefix })] : []),
],
external: ['crypto', 'fs', 'path', 'stream'],
loader: {
},
color: true,
logLevel: 'info',
});
await ctx.rebuild();
await ctx.watch();
}
const argParser = new argparse.ArgumentParser({
add_help: true,
description: 'Mol* development build'
});
argParser.add_argument('--apps', '-a', {
help: 'Apps to build.',
required: false,
nargs: '*',
});
argParser.add_argument('--examples', '-e', {
help: 'Examples to build.',
required: false,
nargs: '*',
});
argParser.add_argument('--port', '-p', {
help: 'Port.',
required: false,
default: 1338,
type: 'int',
});
argParser.add_argument('--host', {
help: 'Show all available host addresses.',
required: false,
action: 'store_true',
});
const args = argParser.parse_args();
const apps = (!args.apps ? [] : (args.apps.length ? args.apps : AllApps)).filter(a => AllApps.includes(a));
const examples = (!args.examples ? [] : (args.examples.length ? args.examples : AllExamples)).filter(e => AllExamples.includes(e));
console.log('Apps:', apps);
console.log('Examples:', examples);
console.log('');
function getLocalIPs() {
const interfaces = os.networkInterfaces();
const ips = [];
for (const name of Object.keys(interfaces)) {
for (const iface of interfaces[name]) {
// Skip internal and non-IPv4 addresses
if (iface.internal || iface.family !== 'IPv4') continue;
ips.push(iface.address);
}
}
return ips;
}
async function main() {
const promises = [];
for (const app of apps) promises.push(watch(app, 'app'));
for (const example of examples) promises.push(watch(example, 'example'));
console.log('Initial build...');
await Promise.all(promises);
console.log('Done.');
const ctx = await esbuild.context({});
ctx.serve({
servedir: './',
port: args.port,
host: '0.0.0.0', // Always listen on all interfaces
});
console.log('');
console.log(`Server URL: http://localhost:${args.port}`);
if (args.host) {
console.log('Available host addresses:');
const ips = getLocalIPs();
ips.forEach(ip => console.log(` http://${ip}:${args.port}`));
}
console.log('');
console.log('Watching for changes...');
console.log('');
console.log('Press Ctrl+C to stop.');
}
main().catch(console.error);

View File

@@ -24,7 +24,7 @@ npm install
Afterwards, build the project source:
```
npm run build-tsc
npm run build:lib
```
and run the server by

View File

@@ -94,7 +94,7 @@ The extension uses several transformations to process and visualize tunnel data:
To help users understand how to use these transformations in practice, include detailed examples:
### Visualizing Multiple Tunnels
This example ([runVisualizeTunnels](../../../src/extensions/sb-ncbr/tunnels/examples.ts#L19)) demonstrates how to visualize multiple tunnels from a fetched dataset.
This example (see `src/extensions/sb-ncbr/tunnels/examples.ts#L19`) demonstrates how to visualize multiple tunnels from a fetched dataset.
```typescript
update.toRoot()
.apply(TunnelsFromRawData, { data: tunnels })
@@ -104,7 +104,7 @@ update.toRoot()
```
### Visualizing a Single Tunnel
This example ([runVisualizeTunnel](../../../src/extensions/sb-ncbr/tunnels/examples.ts#L46)) shows how to visualize a single tunnel.
This example (see `src/extensions/sb-ncbr/tunnels/examples.ts#L46`) shows how to visualize a single tunnel.
```typescript
update.toRoot()
.apply(TunnelFromRawData, {

View File

@@ -141,7 +141,7 @@ export async function loadStructure(plugin: PluginUIContext, url: string, option
```
- Create `src/style.scss`:
```scss
@import '../node_modules/molstar/lib/mol-plugin-ui/skin/light.scss';
@use '../node_modules/molstar/lib/mol-plugin-ui/skin/light.scss';
```
- Create `build/ui.html`:
```html

View File

@@ -247,7 +247,7 @@ async function init() {
const canvas = <HTMLCanvasElement> document.getElementById('molstar-canvas');
const parent = <HTMLDivElement> document.getElementById('molstar-parent');
if (!plugin.initViewer(canvas, parent)) {
if (!(await plugin.initViewer(canvas, parent))) {
console.error('Failed to init Mol*');
return;
}

View File

@@ -0,0 +1,107 @@
# Markdown Extension Manager
The `markdownExtensions` manager in `PluginContext.manager` allows customizing
the `Markdown` React component to enable executing commands and rendering custom content.
The main use case of this is enriching [MolViewSpec](`https://molstar.org/mol-view-spec`) support.
## API
- `PluginContext.manager.markdownExtensions.register*` functions can be used to register extensions and state/data resolvers to make the the manager work with plugin extension
- `PluginContext.manager.markdownExtensions.remove*` can be used to dynamically remove the above
## Commands
Extends Markdown Hyperlink syntax to support expressions of the form `[title](!c1=v1&c2=v2&...)` into an executable command. The command can be executed either on click, mouse enter, or mouse leave.
Generally, the command should be URL encoded, e.g., `a b` => `a%20b` (in JS, `encodeURIComponent`, in Python `urllib.parse.quote_plus/urlencode`).
### Built-in Commands
- `center-camera` - Centers the camera
- `apply-snapshot=key` - Loads snapshots with the provided key
- `next-snapshot[=-1|1]` - Loads next/previous snapshot, the direction is optional and default to `1`
- `play-snapshots` - Starts playback of state snapshots
- `play-transition` - Plays an animation associated with the given snapshot
- `stop-animation` - Stops currently playing animation
- `focus-refs=ref1,ref2,...` - On click, focuses nodes with the provided refs
- `highlight-refs=ref1,ref2,...` - On mouse over, highlights the provided refs
- `query=...&lang=...&action=highlight,focus&focus-radius=...`
- `query` is an expression (e.g., `resn HEM` when using PyMol syntax)
- (optional) `lang` is one of `mol-script` (default), `pymol`, `vmd`, `jmol`
- (optional) `action` is an array of `highlight` (default), `focus` (multiple actions can be specified)
- (optional) `focus-radius` is extra distance applied when focusing the selection (default is `3`)
- Example: `[HEM](!query=resn%20HEM%26lang=pymol&action=highlight,focus)` highlights or focuses the HEM residue (the query must be URL encoded because it contains spaces and possibly other special characters)
- `play-audio=src`, `toggle-audio[=src]`, `stop-audio`, `pause-audio`, `dispose-audio` - Audio playback support
## Custom Content
Extends Markdown Image syntax to support expressions of the form `![alt](!c1=v1&c2=v2&...)` to render custom elements instead.
### Built-in Custom Content
- `color-swatch=color` - Renders a box with the provided color
- Color palettes:
- `color-palette-name=name` - Renders a gradient with the provided named color palette (see `mol-util/color/lists.ts` for supported color schemes)
- `color-palette-colors=color1,color2` - Renders a gradient with the provided colors
- `color-palette-width=CCS-value` - Specifies the width of the element, defaults to `150px`
- `color-palette-height=CCS-value` - Specified the height of the element, defaults to `0.5em`
- `color-palette-discrete` - Renders discrete color list instead of interpolating
## Example
```markdown
### Highlight/Focus:
- ![blue](!color-swatch=blue) [polymer](!highlight-refs=polymer&focus-refs=polymer)
- ![blue](!color-swatch=red) [ligand](!highlight-refs=ligand&focus-refs=ligand)
- [both](!highlight-refs=polymer,ligand&focus-refs=polymer,ligand)
### Color Palettes
|name|visual|
|---:|---|
|viridis|![viridis](!color-palette-name=viridis)|
|rainbow (discrete)|![simple-rainbow](!color-palette-name=simple-rainbow&color-palette-discrete)|
|custom|![custom](!color-palette-colors=red,#00ff00,rgb(0,0,255))|
### Camera controls
- [center](!center-camera)
### Image embedded in MVSX file
![mvsx image](logo.png)
```
This works with the MolViewSpec state built by:
```py
import molviewspec as mvs
builder = mvs.create_builder()
assets = {
"1cbs.cif": "https://files.wwpdb.org/download/1cbs.cif",
"logo.png": "https://molstar.org/img/molstar-logo.png",
}
model = (
builder.download(url="1cbs.cif")
.parse(format="mmcif")
.model_structure()
)
(
model.component(selector="polymer")
.representation(ref="polymer")
.color(color="blue")
)
(
model.component(selector="ligand")
.representation(ref="ligand")
.color(color="red")
)
mvsx = mvs.MVSX(
data=builder.get_state(
description="""...""" # inline the code above
),
assets=assets
)
```

View File

@@ -25,7 +25,6 @@ markdown_extensions:
generic: true
# Scripts for rendering Latex equations (in addition to pymdownx.arithmatex):
extra_javascript:
- https://polyfill.io/v3/polyfill.min.js?features=es6
- https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js
nav:
- 'index.md'
@@ -38,6 +37,8 @@ nav:
- Data State: 'plugin/data-state.md'
- File Formats: 'plugin/file-formats.md'
- CIF Schemas: 'plugin/cif-schemas.md'
- Managers:
- Markdown Extensions: 'plugin/managers/markdown-extensions.md'
- State Transforms:
- Custom Trajectory: 'plugin/transforms/custom-trajectory.md'
- Custom Conformation: 'plugin/transforms/custom-conformation.md'
@@ -59,5 +60,5 @@ nav:
- Interactions: 'extensions/interactions.md'
- Misc:
- Interesting PDB entries: misc/interesting-pdb-entries.md
- Exporting component data: exporting-components.md
- Exporting component data: misc/exporting-components.md
repo_url: https://github.com/molstar/docs

111
eslint.config.mjs Normal file
View File

@@ -0,0 +1,111 @@
import { defineConfig } from "eslint/config";
import globals from "globals";
import typescriptEslint from "@typescript-eslint/eslint-plugin";
import tsParser from "@typescript-eslint/parser";
export default defineConfig([{
ignores: [
"node_modules/*",
"build/*",
"deploy/*",
"docs/site/*",
"lib/*",
"eslint.config.mjs",
"build.mjs",
]
},{
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
ecmaVersion: 2018,
sourceType: "module",
parserOptions: {
ecmaFeatures: {
impliedStrict: true,
},
},
},
rules: {
indent: "off",
"arrow-parens": ["off", "as-needed"],
"brace-style": ["error", "1tbs", {
allowSingleLine: true,
}],
"comma-spacing": "off",
"space-infix-ops": "off",
"comma-dangle": "off",
quotes: ["warn", "single", { "allowTemplateLiterals": true, "avoidEscape": true }],
eqeqeq: ["error", "smart"],
"import/order": "off",
"no-eval": "warn",
"no-extend-native": "warn",
"no-new-wrappers": "warn",
"no-trailing-spaces": "error",
"no-unsafe-finally": "warn",
"no-self-compare": "warn",
"no-var": "error",
"spaced-comment": "error",
semi: "warn",
"no-restricted-syntax": ["error", {
selector: "ExportDefaultDeclaration",
message: "Default exports are not allowed",
}],
"no-throw-literal": "error",
"key-spacing": "error",
"object-curly-spacing": ["error", "always"],
"array-bracket-spacing": "error",
"space-in-parens": "error",
"computed-property-spacing": "error",
"prefer-const": ["error", {
destructuring: "all",
ignoreReadBeforeAssign: false,
}],
"space-before-function-paren": "off",
"func-call-spacing": "off",
"no-multi-spaces": "error",
"block-spacing": "error",
"keyword-spacing": "warn",
"space-before-blocks": "error",
"semi-spacing": "error",
"no-constant-binary-expression": "error",
},
}, {
files: ["**/*.ts", "**/*.tsx"],
plugins: {
"@typescript-eslint": typescriptEslint,
},
languageOptions: {
parser: tsParser,
ecmaVersion: 5,
sourceType: "module",
parserOptions: {
project: ["tsconfig.eslint.json"],
},
},
rules: {
"@typescript-eslint/ban-types": "off",
"@typescript-eslint/class-name-casing": "off",
"@typescript-eslint/member-delimiter-style": ["off", {
multiline: {
delimiter: "none",
requireLast: true,
},
singleline: {
delimiter: "semi",
requireLast: false,
},
}],
"@typescript-eslint/prefer-namespace-keyword": "warn",
"@typescript-eslint/semi": ["off", null],
},
}]);

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

16059
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "molstar",
"version": "4.13.0",
"version": "5.0.0-dev.10",
"description": "A comprehensive macromolecular library.",
"homepage": "https://github.com/molstar/molstar#readme",
"repository": {
@@ -10,34 +10,30 @@
"bugs": {
"url": "https://github.com/molstar/molstar/issues"
},
"engines": {
"node": ">=18.0.0"
},
"scripts": {
"lint": "eslint .",
"lint-fix": "eslint . --fix",
"test": "npm install --no-save \"gl@^6.0.2\" && npm run lint && jest",
"jest": "jest",
"build": "npm run build-tsc && npm run build-extra && npm run build-webpack",
"clean": "node ./scripts/clean.js",
"clean": "node ./scripts/clean.js --all",
"clean:build": "node ./scripts/clean.js --build",
"build": "npm run build:apps && npm run build:lib",
"build:apps": "node ./scripts/build.mjs -a -e --prd",
"build:lib": "concurrently \"tsc --incremental\" \"tsc --build tsconfig.commonjs.json --incremental\" && npm run build:lib-extra",
"build:lib-extra": "node scripts/write-version.mjs && cpx \"src/**/*.{scss,html,ico,jpg}\" lib/ && cpx \"src/**/*.{scss,html,ico,jpg}\" lib/commonjs/ && tsc-alias -p tsconfig.json",
"rebuild": "npm run clean && npm run build",
"build-viewer": "npm run build-tsc && npm run build-extra && npm run build-webpack-viewer",
"build-tsc": "concurrently \"tsc --incremental\" \"tsc --build tsconfig.commonjs.json --incremental\"",
"build-extra": "cpx \"src/**/*.{scss,html,ico,jpg}\" lib/",
"build-webpack": "webpack --mode production --config ./webpack.config.production.js",
"build-webpack-viewer": "webpack --mode production --config ./webpack.config.viewer.js",
"watch": "concurrently -c \"green,green,gray,gray\" --names \"tsc,srv,ext,wpc\" --kill-others \"npm:watch-tsc\" \"npm:watch-servers\" \"npm:watch-extra\" \"npm:watch-webpack\"",
"watch-viewer": "concurrently -c \"green,gray,gray\" --names \"tsc,ext,wpc\" --kill-others \"npm:watch-tsc\" \"npm:watch-extra\" \"npm:watch-webpack-viewer\"",
"watch-viewer-debug": "concurrently -c \"green,gray,gray\" --names \"tsc,ext,wpc\" --kill-others \"npm:watch-tsc\" \"npm:watch-extra\" \"npm:watch-webpack-viewer-debug\"",
"watch-tsc": "tsc --watch --incremental",
"watch-servers": "tsc --build tsconfig.commonjs.json --watch --incremental",
"watch-extra": "cpx \"src/**/*.{scss,html,ico,jpg}\" lib/ --watch",
"watch-webpack": "webpack -w --mode development --stats minimal",
"watch-webpack-viewer": "webpack -w --mode development --stats minimal --config ./webpack.config.viewer.js",
"watch-webpack-viewer-debug": "webpack -w --mode development --stats minimal --config ./webpack.config.viewer.debug.js",
"dev": "node build-dev.mjs",
"dev:all": "node build-dev.mjs -a -e",
"dev:viewer": "node build-dev.mjs -a viewer",
"dev:apps": "node build-dev.mjs -a",
"dev:examples": "node build-dev.mjs -e",
"dev": "node ./scripts/build.mjs",
"dev:all": "node ./scripts/build.mjs -a -e -bt",
"dev:viewer": "node ./scripts/build.mjs -a viewer",
"dev:apps": "node ./scripts/build.mjs -a",
"dev:examples": "node ./scripts/build.mjs -e",
"dev:browser-tests": "node ./scripts/build.mjs -bt",
"serve": "http-server -p 1338 -g",
"deploy:local": "npm run clean:build && npm run build:apps && node ./scripts/deploy.js --local",
"deploy:remote": "npm run clean:build && npm run build:apps && node ./scripts/deploy.js",
"model-server": "node lib/commonjs/servers/model/server.js",
"model-server-watch": "nodemon --watch lib lib/commonjs/servers/model/server.js",
"volume-server-test": "node lib/commonjs/servers/volume/server.js --idMap em 'test/${id}.mdb' --defaultPort 1336",
@@ -48,7 +44,8 @@
},
"files": [
"lib/",
"build/viewer/"
"build/viewer/",
"build/mvs-stories/"
],
"bin": {
"cif2bcif": "lib/commonjs/cli/cif2bcif/index.js",
@@ -123,70 +120,63 @@
"Ventura Rivera <venturaxrivera@gmail.com>",
"Andy Turner <agdturner@gmail.com>",
"Lukáš Polák <admin@lukaspolak.cz>",
"Chetan Mishra <chetan.s115@gmail.com>"
"Chetan Mishra <chetan.s115@gmail.com>",
"Zach Charlop-Powers <zach.charlop.powers@gmail.com>",
"Kim Juho <juho_kim@outlook.com>",
"Victoria Doshchenko <doshchenko.victoria@gmail.com>"
],
"license": "MIT",
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/cors": "^2.8.19",
"@types/gl": "^6.0.5",
"@types/jest": "^29.5.14",
"@types/pngjs": "^6.0.5",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"@typescript-eslint/eslint-plugin": "^8.34.0",
"@typescript-eslint/parser": "^8.34.0",
"benchmark": "^2.1.4",
"concurrently": "^9.1.2",
"cpx2": "^8.0.0",
"crypto-browserify": "^3.12.1",
"css-loader": "^7.1.2",
"esbuild": "^0.25.0",
"esbuild": "^0.25.5",
"esbuild-sass-plugin": "^3.3.1",
"eslint": "^8.57.1",
"extra-watch-webpack-plugin": "^1.0.3",
"file-loader": "^6.2.0",
"eslint": "^9.29.0",
"fs-extra": "^11.3.0",
"http-server": "^14.1.1",
"jest": "^29.7.0",
"jpeg-js": "^0.4.4",
"mini-css-extract-plugin": "^2.9.2",
"path-browserify": "^1.0.1",
"raw-loader": "^4.0.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"sass": "^1.83.4",
"sass-loader": "^16.0.4",
"simple-git": "^3.27.0",
"stream-browserify": "^3.0.0",
"style-loader": "^4.0.0",
"ts-jest": "^29.2.5",
"typescript": "^5.7.3",
"webpack": "^5.97.1",
"webpack-cli": "^6.0.1"
"sass": "^1.89.1",
"simple-git": "^3.28.0",
"ts-jest": "^29.3.4",
"tsc-alias": "^1.8.16",
"typescript": "^5.8.3"
},
"dependencies": {
"@types/argparse": "^2.0.17",
"@types/benchmark": "^2.1.5",
"@types/compression": "1.7.5",
"@types/express": "^5.0.0",
"@types/node": "^18.19.74",
"@types/compression": "1.8.1",
"@types/express": "^5.0.3",
"@types/node": "^18.19.111",
"@types/node-fetch": "^2.6.12",
"@types/swagger-ui-dist": "3.30.5",
"argparse": "^2.0.1",
"compression": "^1.7.5",
"compression": "^1.8.0",
"cors": "^2.8.5",
"express": "^5.0.1",
"express": "^5.1.0",
"h264-mp4-encoder": "^1.0.12",
"immer": "^10.1.1",
"immutable": "^5.0.3",
"immutable": "^5.1.2",
"io-ts": "^2.2.22",
"mutative": "^1.2.0",
"node-fetch": "^2.7.0",
"react-markdown": "^9.0.3",
"rxjs": "^7.8.1",
"swagger-ui-dist": "^5.18.2",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"rxjs": "^7.8.2",
"swagger-ui-dist": "^5.24.0",
"tslib": "^2.8.1",
"util.promisify": "^1.1.3",
"xhr2": "^0.2.1"
"util.promisify": "^1.1.3"
},
"peerDependencies": {
"@google-cloud/storage": "^7.14.0",
@@ -214,4 +204,4 @@
"optional": true
}
}
}
}

307
scripts/build.mjs Normal file
View File

@@ -0,0 +1,307 @@
/**
* Copyright (c) 2017-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
* @author Eric E <etongfu@@outlook.com>
*/
import * as esbuild from 'esbuild';
import * as fs from 'fs';
import * as path from 'path';
import * as argparse from 'argparse';
import { sassPlugin } from 'esbuild-sass-plugin';
import * as os from 'os';
const Apps = [
// Apps
{ kind: 'app', name: 'viewer' },
{ kind: 'app', name: 'docking-viewer' },
{ kind: 'app', name: 'mesoscale-explorer' },
{ kind: 'app', name: 'mvs-stories', globalName: 'mvsStories', filename: 'mvs-stories.js' },
// Examples
{ kind: 'example', name: 'proteopedia-wrapper' },
{ kind: 'example', name: 'basic-wrapper' },
{ kind: 'example', name: 'lighting' },
{ kind: 'example', name: 'alpha-orbitals' },
{ kind: 'example', name: 'alphafolddb-pae' },
{ kind: 'example', name: 'mvs-stories' },
{ kind: 'example', name: 'ihm-restraints' },
{ kind: 'example', name: 'interactions' },
{ kind: 'example', name: 'ligand-editor' },
];
function findApp(name, kind) {
return Apps.find(a => a.name === name && a.kind === kind);
}
function mkDir(dir) {
try {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
} catch (error) {
console.error(`Failed to create directory ${dir}:`, error);
process.exit(1);
}
}
function handleFileError(error, operation, path) {
console.error(`Failed to ${operation} ${path}:`, error);
process.exit(1);
}
function fileLoaderPlugin(options) {
mkDir(options.out);
return {
name: 'file-loader',
setup(build) {
build.onLoad({ filter: /\.jpg$/ }, async (args) => {
try {
const name = path.basename(args.path);
mkDir(path.resolve(options.out, 'images'));
await fs.promises.copyFile(args.path, path.resolve(options.out, 'images', name));
return {
contents: `images/${name}`,
loader: 'text',
};
} catch (error) {
handleFileError(error, 'copy', args.path);
}
});
build.onLoad({ filter: /\.(html|ico)$/ }, async (args) => {
const name = path.basename(args.path);
await fs.promises.copyFile(args.path, path.resolve(options.out, name));
return {
contents: '',
loader: 'empty',
};
});
},
};
}
function examplesCssRenamePlugin({ root }) {
return {
name: 'example-css-rename',
setup(build) {
build.onEnd(async () => {
if (fs.existsSync(path.resolve(root, 'index.css'))) {
await fs.promises.rename(
path.resolve(root, 'index.css'),
path.resolve(root, 'molstar.css')
);
}
});
}
};
}
function resolveEntryPath(path) {
if (!fs.existsSync(path)) {
return path + 'x'; // fallback to .tsx
}
return path;
}
function getPaths(app) {
if (app.kind === 'app') {
return {
prefix: `./build/${app.name}`,
entry: resolveEntryPath(`./src/apps/${app.name}/index.ts`),
outfile: `./build/${app.name}/${app.filename || 'molstar.js'}`,
};
}
if (app.kind === 'example') {
return {
prefix: `./build/examples/${app.name}`,
entry: resolveEntryPath(`./src/examples/${app.name}/index.ts`),
outfile: `./build/examples/${app.name}/${app.filename || 'index.js'}`,
};
}
if (app.kind === 'browser-test') {
return {
prefix: `./build/tests/browser`,
entry: resolveEntryPath(`./src/tests/browser/${app.name}.ts`),
outfile: `./build/tests/browser/${app.name}.js`,
};
}
throw new Error(`Unknown app kind: ${app.kind}`);
}
async function createBundle(app) {
const { name, kind } = app;
const { prefix, entry, outfile } = getPaths(app);
const ctx = await esbuild.context({
entryPoints: [entry],
tsconfig: './tsconfig.json',
bundle: true,
minify: isProduction,
minifyIdentifiers: false,
sourcemap: includeSourceMap,
globalName: app.globalName || 'molstar',
outfile,
plugins: [
fileLoaderPlugin({ out: prefix }),
sassPlugin({
type: 'css',
silenceDeprecations: ['import'],
logger: {
warn: (msg) => console.warn(msg),
debug: () => { },
}
}),
...(kind === 'example' ? [examplesCssRenamePlugin({ root: prefix })] : []),
],
external: ['crypto', 'fs', 'path', 'stream'],
loader: {
},
color: true,
logLevel: 'info',
define: {
'process.env.DEBUG': JSON.stringify(process.env.DEBUG || false),
__MOLSTAR_PLUGIN_VERSION__: JSON.stringify(VERSION),
__MOLSTAR_BUILD_TIMESTAMP__: `${TIMESTAMP}`,
},
});
await ctx.rebuild();
if (!isProduction) await ctx.watch();
}
function findBrowserTests(names) {
const dir = path.resolve('./src', 'tests', 'browser');
let files = fs.readdirSync(dir).filter(file => file.endsWith('.ts')).map(file => file.replace('.ts', ''));
if (names.length) {
files = files.filter(file => names.includes(file));
}
return files.map(name => ({ kind: 'browser-test', name }));
}
const argParser = new argparse.ArgumentParser({
add_help: true,
description: 'Mol* Build'
});
argParser.add_argument('--prd', {
help: 'Create a production build.',
required: false,
action: 'store_true',
});
argParser.add_argument('--no-src-map', {
help: 'Do not include source map.',
required: false,
action: 'store_true',
});
argParser.add_argument('--apps', '-a', {
help: 'Apps to build.',
required: false,
nargs: '*',
});
argParser.add_argument('--examples', '-e', {
help: 'Examples to build.',
required: false,
nargs: '*',
});
argParser.add_argument('--browser-tests', '-bt', {
help: 'Browser Tests to build.',
required: false,
nargs: '*',
});
argParser.add_argument('--port', '-p', {
help: 'Port.',
required: false,
default: 1338,
type: 'int',
});
argParser.add_argument('--host', {
help: 'Show all available host addresses.',
required: false,
action: 'store_true',
});
const args = argParser.parse_args();
const isProduction = !!args.prd;
const includeSourceMap = !args.no_src_map;
const VERSION = isProduction ? JSON.parse(fs.readFileSync('./package.json', 'utf8')).version : '(dev build)';
const TIMESTAMP = Date.now();
const apps = (!args.apps ? [] : (args.apps.length ? args.apps.map(a => findApp(a, 'app')).filter(a => a) : Apps.filter(a => a.kind === 'app')));
const examples = (!args.examples ? [] : (args.examples.length ? args.examples.map(e => findApp(e, 'example')).filter(a => a) : Apps.filter(a => a.kind === 'example')));
const browserTests = (!args.browser_tests ? [] : findBrowserTests(args.browser_tests));
console.log('Apps:', apps.map(a => a.name));
console.log('Examples:', examples.map(e => e.name));
console.log('Browser Tests', browserTests.map(e => e.name));
console.log('');
function getLocalIPs() {
const interfaces = os.networkInterfaces();
const ips = [];
for (const name of Object.keys(interfaces)) {
for (const iface of interfaces[name]) {
// Skip internal and non-IPv4 addresses
if (iface.internal || iface.family !== 'IPv4') continue;
ips.push(iface.address);
}
}
return ips;
}
async function main() {
const promises = [];
console.log(isProduction ? 'Building apps...' : 'Initial build...');
for (const app of apps) promises.push(createBundle(app));
for (const example of examples) promises.push(createBundle(example));
for (const browserTest of browserTests) promises.push(createBundle(browserTest));
await Promise.all(promises);
if (isProduction) {
console.log('Done.');
process.exit(0);
}
console.log('Initial build complete.');
const certfile = './dev.pem';
const keyfile = './dev-key.pem';
const sslEnabled = fs.existsSync(certfile) && fs.existsSync(keyfile);
const protocol = sslEnabled ? 'https' : 'http';
const ctx = await esbuild.context({});
ctx.serve({
servedir: './',
port: args.port,
host: '0.0.0.0', // Always listen on all interfaces
certfile: sslEnabled ? certfile : undefined,
keyfile: sslEnabled ? keyfile : undefined,
});
console.log('');
console.log(`Server URL: ${protocol}://localhost:${args.port}`);
if (args.host) {
console.log('Available host addresses:');
const ips = getLocalIPs();
ips.forEach(ip => console.log(` ${protocol}://${ip}:${args.port}`));
}
console.log('');
console.log('Watching for changes...');
console.log('');
console.log('Press Ctrl+C to stop.');
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -6,6 +6,7 @@
const fs = require('fs');
const path = require('path');
const argparse = require('argparse');
function removeDir(dirPath) {
for (const ent of fs.readdirSync(dirPath)) {
@@ -24,11 +25,29 @@ function remove(entryPath) {
fs.unlinkSync(entryPath);
}
const toClean = [
path.resolve(__dirname, '../build'),
path.resolve(__dirname, '../lib'),
path.resolve(__dirname, '../tsconfig.tsbuildinfo'),
];
const argParser = new argparse.ArgumentParser({
add_help: true,
description: 'Clean Script'
});
argParser.add_argument('--build', { required: false, action: 'store_true' });
argParser.add_argument('--lib', { required: false, action: 'store_true' });
argParser.add_argument('--all', { required: false, action: 'store_true' });
const args = argParser.parse_args();
const toClean = [];
if (args.build || args.all) {
toClean.push(path.resolve(__dirname, '../build'));
toClean.push(path.resolve(__dirname, '../deploy/data'));
}
if (args.lib || args.all) {
toClean.push(
path.resolve(__dirname, '../lib'),
path.resolve(__dirname, '../tsconfig.tsbuildinfo'),
);
}
console.log('\n###', 'cleaning', toClean.join(', '));
toClean.forEach(ph => {
if (fs.existsSync(ph)) {

View File

@@ -2,20 +2,24 @@
* Copyright (c) 2019-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
* @author David Sehnal <david.sehnal@gmail.com>
*/
const git = require('simple-git');
const path = require('path');
const fs = require("fs");
const fse = require("fs-extra");
const fs = require('fs');
const fse = require('fs-extra');
const argparse = require('argparse');
const VERSION = require(path.resolve(__dirname, '../package.json')).version;
const MVS_STORIES_VERSION = require(path.resolve(__dirname, '../src/apps/mvs-stories/version.ts')).VERSION;
const remoteUrl = "https://github.com/molstar/molstar.github.io.git";
const remoteUrl = 'https://github.com/molstar/molstar.github.io.git';
const dataDir = path.resolve(__dirname, '../data/');
const buildDir = path.resolve(__dirname, '../build/');
const deployDir = path.resolve(buildDir, 'deploy/');
const localPath = path.resolve(deployDir, 'molstar.github.io/');
const deployDir = path.resolve(__dirname, '../deploy/');
const localPath = path.resolve(deployDir, 'data/');
const repositoryPath = path.resolve(deployDir, 'molstar.github.io/');
const analyticsTag = /<!-- __MOLSTAR_ANALYTICS__ -->/g;
const analyticsCode = `<!-- Cloudflare Web Analytics --><script defer src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "c414cbae2d284ea995171a81e4a3e721"}'></script><!-- End Cloudflare Web Analytics --><iframe src="https://web3dsurvey.com/collector-iframe.html" style="width: 1px; height: 1px;"></iframe>`;
@@ -80,54 +84,106 @@ function copyMe() {
addAnalytics(path.resolve(meDeployPath, 'index.html'));
}
function copyMVSStories() {
console.log('\n###', 'copy MVS stories files');
const mvsStoriesBuildPath = path.resolve(buildDir, 'mvs-stories/');
const mvsStoriesDeployPath = path.resolve(localPath, `stories-viewer/v${MVS_STORIES_VERSION}/`);
fse.copySync(mvsStoriesBuildPath, mvsStoriesDeployPath, { overwrite: true });
addAnalytics(path.resolve(mvsStoriesDeployPath, 'index.html'));
// TODO: add PWA
// addManifest(path.resolve(mvsStoriesDeployPath, 'index.html'));
// addPwa(path.resolve(mvsStoriesDeployPath, 'index.html'));
}
function copyDemo(name) {
console.log('\n###', `copy demo files for ${name}`);
const demoBuildPath = path.resolve(buildDir, `examples/${name}/`);
const demoDeployPath = path.resolve(localPath, `demos/${name}/`);
fse.copySync(demoBuildPath, demoDeployPath, { overwrite: true });
addAnalytics(path.resolve(demoDeployPath, 'index.html'));
}
function copyDemos() {
console.log('\n###', 'copy demos files');
const lightingBuildPath = path.resolve(buildDir, 'examples/lighting/');
const lightingDeployPath = path.resolve(localPath, 'demos/lighting/');
fse.copySync(lightingBuildPath, lightingDeployPath, { overwrite: true });
addAnalytics(path.resolve(lightingDeployPath, 'index.html'));
const orbitalsBuildPath = path.resolve(buildDir, 'examples/alpha-orbitals/');
const orbitalsDeployPath = path.resolve(localPath, 'demos/alpha-orbitals/');
fse.copySync(orbitalsBuildPath, orbitalsDeployPath, { overwrite: true });
addAnalytics(path.resolve(orbitalsDeployPath, 'index.html'));
copyDemo('lighting');
copyDemo('alpha-orbitals');
copyDemo('mvs-stories');
}
function copyFiles() {
try {
copyViewer();
copyMe();
copyMVSStories();
copyDemos();
} catch (e) {
console.error(e);
}
}
function copyToRepository() {
console.log('\n###', 'copy repository files');
fse.copySync(localPath, repositoryPath, { overwrite: true });
}
function syncRepository() {
console.log('\n###', 'sync repository');
if (!fs.existsSync(path.resolve(repositoryPath, '.git/'))) {
console.log('\n###', 'clone repository');
git()
.outputHandler(log)
.clone(remoteUrl, repositoryPath)
.fetch(['--all'])
.exec(copyToRepository);
} else {
console.log('\n###', 'update repository');
git()
.outputHandler(log)
.fetch(['--all'])
.reset(['--hard', 'origin/master'])
.exec(copyToRepository);
}
}
function commit() {
console.log('\n###', 'commit changes');
git()
.outputHandler(log)
.add(['-A'])
.commit(`Updated Apps and Demos
- Mol* version: ${VERSION}
- MVS Stories version: ${MVS_STORIES_VERSION}`)
.push();
}
if (!fs.existsSync(localPath)) {
console.log('\n###', 'create localPath');
fs.mkdirSync(localPath, { recursive: true });
}
process.chdir(localPath);
const argParser = new argparse.ArgumentParser({
add_help: true,
description: 'Mol* Deploy'
});
argParser.add_argument('--local',{
help: 'Do not commit to remote repository.',
required: false,
action: 'store_true',
});
const args = argParser.parse_args();
if (!fs.existsSync(path.resolve(localPath, '.git/'))) {
console.log('\n###', 'clone repository');
git()
.outputHandler(log)
.clone(remoteUrl, localPath)
.fetch(['--all'])
.exec(copyFiles)
.add(['-A'])
.commit('updated viewer & demos')
.push();
} else {
console.log('\n###', 'update repository');
git()
.outputHandler(log)
.fetch(['--all'])
.reset(['--hard', 'origin/master'])
.exec(copyFiles)
.add(['-A'])
.commit('updated viewer & demos')
.push();
}
copyFiles();
if (args.local) {
process.exit(0);
}
if (!fs.existsSync(repositoryPath)) {
console.log('\n###', 'create repositoryPath');
fs.mkdirSync(repositoryPath, { recursive: true });
}
process.chdir(repositoryPath);
syncRepository();
commit();

16
scripts/write-version.mjs Normal file
View File

@@ -0,0 +1,16 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
import * as fs from 'fs';
const VERSION = JSON.parse(fs.readFileSync('./package.json', 'utf8')).version;
const TIMESTAMP = Date.now();
const file = `export var PLUGIN_VERSION = '${VERSION}';\nexport var PLUGIN_VERSION_DATE = new Date(${TIMESTAMP})`;
const files = ['./lib/mol-plugin/version.js', './lib/commonjs/mol-plugin/version.js'];
for (const f of files) {
if (!fs.existsSync(f)) continue;
fs.writeFileSync(f, file);
}

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2022-2024 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2022-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
@@ -147,6 +147,7 @@ export class MesoscaleExplorer {
behaviors: [
PluginSpec.Behavior(PluginBehaviors.Camera.CameraAxisHelper),
PluginSpec.Behavior(PluginBehaviors.Camera.CameraControls),
PluginSpec.Behavior(PluginBehaviors.State.SnapshotControls),
PluginSpec.Behavior(MesoFocusLoci),
PluginSpec.Behavior(MesoSelectLoci),
@@ -261,7 +262,6 @@ export class MesoscaleExplorer {
image: true,
componentManager: false,
structureSelection: true,
behavior: true,
});
plugin.managers.lociLabels.clearProviders();

View File

@@ -1,32 +1,28 @@
@use "sass:color";
$default-background: #2D3E50;
$font-color: #EDF1F2;
$hover-font-color: #3B9AD9;
$entity-current-font-color: #FFFFFF;
$msp-btn-remove-background: #BF3A31;
$msp-btn-remove-hover-font-color:#ffffff;
$msp-btn-commit-on-font-color: #ffffff;
$entity-badge-font-color: #ccd4e0;
@use '../../mol-plugin-ui/skin/base/colors' with (
$default-background: #2D3E50,
$font-color: #EDF1F2,
$hover-font-color: #3B9AD9,
$entity-current-font-color: #FFFFFF,
$msp-btn-remove-background: #BF3A31,
$msp-btn-remove-hover-font-color:#ffffff,
$msp-btn-commit-on-font-color: #ffffff,
$entity-badge-font-color: #ccd4e0,
// used in LOG
$log-message: #0CCA5D;
$log-info: #5E3673;
$log-warning: #FCC937;
$log-error: #FD354B;
// used in LOG
$log-message: #0CCA5D,
$log-info: #5E3673,
$log-warning: #FCC937,
$log-error: #FD354B,
$logo-background: rgba(0,0,0,0.75);
$logo-background: rgba(0,0,0,0.75),
@function color-lower-contrast($color, $amount) {
@return color.adjust($color, $lightness: -$amount, $space: hsl);
}
$color-adjust-sign: -1,
);
@function color-increase-contrast($color, $amount) {
@return color.adjust($color, $lightness: $amount, $space: hsl);
}
@import '../../mol-plugin-ui/skin/base/base';
@import '../../mol-plugin-ui/skin/base/variables';
@use '../../mol-plugin-ui/skin/base/base';
@use '../../mol-plugin-ui/skin/base/vars' as *;
a {
color: $font-color;

View File

@@ -227,8 +227,7 @@ export async function loadPdbIhm(ctx: PluginContext, id: string) {
}
async function loadColors(ctx: PluginContext, file: File) {
const data = await ctx.runTask(readFromFile(file, 'string'));
const colorData = JSON.parse(data);
const colorData = await ctx.runTask(readFromFile(file, 'json'));
const update = ctx.state.data.build();
const allEntities = getAllEntities(ctx);

View File

@@ -0,0 +1,39 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { BehaviorSubject } from 'rxjs';
import { MVSData } from '../../extensions/mvs/mvs-data';
import type { MVSStoriesViewerModel } from './elements/viewer';
export type MVSStoriesCommand =
| { kind: 'load-mvs', format?: 'mvsj' | 'mvsx', url?: string, data?: MVSData | string | Uint8Array }
export class MVSStoriesContext {
commands = new BehaviorSubject<MVSStoriesCommand | undefined>(undefined);
state = {
viewers: new BehaviorSubject<{ name?: string, model: MVSStoriesViewerModel }[]>([]),
currentStoryData: new BehaviorSubject<string | Uint8Array | undefined>(undefined),
isLoading: new BehaviorSubject(false),
};
dispatch(command: MVSStoriesCommand) {
this.commands.next(command);
}
constructor(public name?: string) {
}
}
export function getMVSStoriesContext(options?: { name?: string, container?: object }): MVSStoriesContext {
const container: any = options?.container ?? window;
container.componentContexts ??= {};
const name = options?.name ?? '<default>';
if (!container.componentContexts[name]) {
container.componentContexts[name] = new MVSStoriesContext(options?.name);
}
return container.componentContexts[name];
}

View File

@@ -0,0 +1,2 @@
import './snapshot-markdown';
import './viewer';

View File

@@ -6,17 +6,17 @@
import { BehaviorSubject, distinctUntilChanged, map } from 'rxjs';
import { PluginComponent } from '../../../mol-plugin-state/component';
import { getMolComponentContext, MolComponentContext } from '../context';
import { MolComponentViewerModel } from './viewer';
import Markdown from 'react-markdown';
import { getMVSStoriesContext, MVSStoriesContext } from '../context';
import { MVSStoriesViewerModel } from './viewer';
import { useBehavior } from '../../../mol-plugin-ui/hooks/use-behavior';
import { createRoot } from 'react-dom/client';
import { PluginStateSnapshotManager } from '../../../mol-plugin-state/manager/snapshots';
import { MarkdownAnchor } from '../../../mol-plugin-ui/controls';
import { PluginReactContext } from '../../../mol-plugin-ui/base';
import { CSSProperties } from 'react';
import { Markdown } from '../../../mol-plugin-ui/controls/markdown';
export class MolComponentSnapshotMarkdownModel extends PluginComponent {
readonly context: MolComponentContext;
export class MVSStoriesSnapshotMarkdownModel extends PluginComponent {
readonly context: MVSStoriesContext;
root: HTMLElement | undefined = undefined;
state = new BehaviorSubject<{
@@ -26,7 +26,7 @@ export class MolComponentSnapshotMarkdownModel extends PluginComponent {
}>({ all: [] });
get viewer() {
return this.context.behavior.viewers.value?.find(v => this.options?.viewerName === v.name);
return this.context.state.viewers.value?.find(v => this.options?.viewerName === v.name);
}
sync() {
@@ -41,11 +41,11 @@ export class MolComponentSnapshotMarkdownModel extends PluginComponent {
async mount(root: HTMLElement) {
this.root = root;
createRoot(root).render(<MolComponentSnapshotMarkdownUI model={this} />);
createRoot(root).render(<MVSStoriesSnapshotMarkdownUI model={this} />);
let currentViewer: MolComponentViewerModel | undefined = undefined;
let currentViewer: MVSStoriesViewerModel | undefined = undefined;
let sub: { unsubscribe: () => void } | undefined = undefined;
this.subscribe(this.context.behavior.viewers.pipe(
this.subscribe(this.context.state.viewers.pipe(
map(xs => xs.find(v => this.options?.viewerName === v.name)),
distinctUntilChanged((a, b) => a?.model === b?.model)
), viewer => {
@@ -66,21 +66,31 @@ export class MolComponentSnapshotMarkdownModel extends PluginComponent {
constructor(private options?: { context?: { name?: string, container?: object }, viewerName?: string }) {
super();
this.context = getMolComponentContext(options?.context);
this.context = getMVSStoriesContext(options?.context);
}
}
export function MolComponentSnapshotMarkdownUI({ model }: { model: MolComponentSnapshotMarkdownModel }) {
export function MVSStoriesSnapshotMarkdownUI({ model }: { model: MVSStoriesSnapshotMarkdownModel }) {
const state = useBehavior(model.state);
const isLoading = useBehavior(model.context.state.isLoading);
if (state.all.length === 0) {
return <div>
<i>No snapshot loaded</i>
const style: CSSProperties = { display: 'flex', flexDirection: 'column', height: '100%' };
const className = 'mvs-stories-markdown-explanation';
if (isLoading) {
return <div style={style} className={className}>
<i>Loading...</i>
</div>;
}
return <div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ display: 'flex', flexDirection: 'row', width: '100%', gap: '8px' }} className='mc-snapshot-markdown-header'>
if (state.all.length === 0) {
return <div style={style} className={className}>
<i>No snapshot loaded or no description available</i>
</div>;
}
return <div style={style} className={className}>
<div style={{ display: 'flex', flexDirection: 'row', width: '100%', gap: '8px' }}>
<span style={{ lineHeight: '38px', minWidth: 60, maxWidth: 60, flexShrink: 0 }}>{typeof state.index === 'number' ? state.index + 1 : '-'}/{state.all.length}</span>
<button onClick={() => model.viewer?.model.plugin?.managers.snapshot.applyNext(-1)} style={{ flexGrow: 1, flexShrink: 0 }}>Prev</button>
<button onClick={() => model.viewer?.model.plugin?.managers.snapshot.applyNext(1)} style={{ flexGrow: 1, flexShrink: 0 }}>Next</button>
@@ -88,18 +98,18 @@ export function MolComponentSnapshotMarkdownUI({ model }: { model: MolComponentS
<div style={{ flexGrow: 1, overflow: 'hidden', overflowY: 'auto', position: 'relative' }}>
<div style={{ position: 'absolute', inset: 0 }}>
<PluginReactContext.Provider value={model.viewer?.model.plugin as any}>
<Markdown skipHtml components={{ a: MarkdownAnchor }}>{state.entry?.description ?? 'Description not available'}</Markdown>
<Markdown>{state.entry?.description ?? 'Description not available'}</Markdown>
</PluginReactContext.Provider>
</div>
</div>
</div>;
}
export class MolComponentSnapshotMarkdownViewer extends HTMLElement {
private model: MolComponentSnapshotMarkdownModel | undefined = undefined;
export class MVSStoriesSnapshotMarkdownViewer extends HTMLElement {
private model: MVSStoriesSnapshotMarkdownModel | undefined = undefined;
async connectedCallback() {
this.model = new MolComponentSnapshotMarkdownModel({
this.model = new MVSStoriesSnapshotMarkdownModel({
context: { name: this.getAttribute('context-name') ?? undefined },
viewerName: this.getAttribute('viewer-name') ?? undefined,
});
@@ -116,4 +126,4 @@ export class MolComponentSnapshotMarkdownViewer extends HTMLElement {
}
}
window.customElements.define('mc-snapshot-markdown', MolComponentSnapshotMarkdownViewer);
window.customElements.define('mvs-stories-snapshot-markdown', MVSStoriesSnapshotMarkdownViewer);

View File

@@ -5,19 +5,21 @@
*/
import { MolViewSpec } from '../../../extensions/mvs/behavior';
import { loadMVS } from '../../../extensions/mvs/load';
import { loadMVSData } from '../../../extensions/mvs/components/formats';
import { MVSData } from '../../../extensions/mvs/mvs-data';
import { StringLike } from '../../../mol-io/common/string-like';
import { PluginComponent } from '../../../mol-plugin-state/component';
import { createPluginUI } from '../../../mol-plugin-ui';
import { renderReact18 } from '../../../mol-plugin-ui/react18';
import { DefaultPluginUISpec } from '../../../mol-plugin-ui/spec';
import { PluginCommands } from '../../../mol-plugin/commands';
import { PluginConfig } from '../../../mol-plugin/config';
import { PluginContext } from '../../../mol-plugin/context';
import { PluginSpec } from '../../../mol-plugin/spec';
import { getMolComponentContext, MolComponentContext } from '../context';
import { getMVSStoriesContext, MVSStoriesContext } from '../context';
export class MolComponentViewerModel extends PluginComponent {
readonly context: MolComponentContext;
export class MVSStoriesViewerModel extends PluginComponent {
readonly context: MVSStoriesContext;
plugin?: PluginContext = undefined;
async mount(root: HTMLElement) {
@@ -51,36 +53,52 @@ export class MolComponentViewerModel extends PluginComponent {
});
this.subscribe(this.context.commands, async (cmd) => {
if (!cmd) return;
if (!cmd || !this.plugin) return;
if (cmd.kind === 'load-mvs') {
if (cmd.url) {
const data = await this.plugin!.runTask(this.plugin!.fetch({ url: cmd.url, type: 'string' }));
const mvsData = MVSData.fromMVSJ(data);
await loadMVS(this.plugin!, mvsData, { sanityChecks: true, sourceUrl: cmd.url, replaceExisting: true });
} else if (cmd.data) {
await loadMVS(this.plugin!, cmd.data, { sanityChecks: true, replaceExisting: true });
try {
this.context.state.isLoading.next(true);
if (cmd.kind === 'load-mvs') {
let loadedData: MVSData | StringLike | Uint8Array | undefined;
if (cmd.url) {
const data = await this.plugin.runTask(this.plugin.fetch({ url: cmd.url, type: cmd.format === 'mvsx' ? 'binary' : 'string' }));
loadedData = await loadMVSData(this.plugin, data, cmd.format ?? 'mvsj', { sourceUrl: cmd.url });
} else if (cmd.data) {
loadedData = await loadMVSData(this.plugin, cmd.data, cmd.format ?? 'mvsj');
}
if (StringLike.is(loadedData) || loadedData instanceof Uint8Array) {
this.context.state.currentStoryData.next(loadedData as string | Uint8Array);
} else if (loadedData) {
this.context.state.currentStoryData.next(JSON.stringify(loadedData));
}
}
} catch (e) {
console.error(e);
PluginCommands.Toast.Show(
this.plugin,
{ key: '<mvsload>', title: 'Error', message: e?.message ? `${e?.message}` : `${e}`, timeoutMs: 10000 }
);
} finally {
this.context.state.isLoading.next(false);
}
});
const viewers = this.context.behavior.viewers.value;
const viewers = this.context.state.viewers.value;
const next = [...viewers, { name: this.options?.name, model: this }];
this.context.behavior.viewers.next(next);
this.context.state.viewers.next(next);
}
constructor(private options?: { context?: { name?: string, container?: object }, name?: string }) {
super();
this.context = getMolComponentContext(options?.context);
this.context = getMVSStoriesContext(options?.context);
const viewers = this.context.behavior.viewers.value;
const viewers = this.context.state.viewers.value;
const index = viewers.findIndex(v => v.name === options?.name);
if (index >= 0) {
const next = [...viewers];
next[index].model.dispose();
next.splice(index, 0);
this.context.behavior.viewers.next(next);
this.context.state.viewers.next(next);
}
}
}
@@ -89,11 +107,11 @@ function EmptyDescription() {
return <></>;
}
export class MolComponentViewer extends HTMLElement {
private model: MolComponentViewerModel | undefined = undefined;
export class MVSStoriesViewer extends HTMLElement {
private model: MVSStoriesViewerModel | undefined = undefined;
async connectedCallback() {
this.model = new MolComponentViewerModel({
this.model = new MVSStoriesViewerModel({
name: this.getAttribute('name') ?? undefined,
context: { name: this.getAttribute('context-name') ?? undefined },
});
@@ -110,4 +128,4 @@ export class MolComponentViewer extends HTMLElement {
}
}
window.customElements.define('mc-viewer', MolComponentViewer);
window.customElements.define('mvs-stories-viewer', MVSStoriesViewer);

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,148 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
<link rel="icon" href="./favicon.ico" type="image/x-icon">
<title>Molecular Stories</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
#viewer {
position: absolute;
left: 0;
top: 0;
right: 34%;
bottom: 0;
}
#controls {
position: absolute;
left: 66%;
top: 0;
right: 0;
bottom: 0;
padding: 16px;
padding-bottom: 20px;
border: 1px solid #ccc;
border-left: none;
background: #F6F5F3;
z-index: -2;
display: flex;
flex-direction: column;
gap: 16px;
}
#links {
position: absolute;
bottom: 4px;
right: 8px;
font-family: "Raleway", "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 0.6rem;
z-index: -1;
color: #666;
}
#links a {
color: #666;
text-decoration: none;
}
#links .sep {
color: #aaa;
}
@media (orientation:portrait) {
#viewer {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 40%;
}
#controls {
position: absolute;
left: 0;
top: 60%;
right: 0;
bottom: 0;
border-top: none;
}
.msp-viewport-controls-buttons {
display: none;
}
}
</style>
<link rel="stylesheet" type="text/css" href="mvs-stories.css" />
<script type="text/javascript" src="mvs-stories.js"></script>
</head>
<body>
<!-- the context-name parameter is optional and useful when embedding multiple stories in a single page -->
<div id="viewer">
<mvs-stories-viewer context-name="story1" ></mvs-stories-viewer>
</div>
<div id="controls">
<mvs-stories-snapshot-markdown context-name="story1" style="flex-grow: 1;" ></mvs-stories-snapshot-markdown>
</div>
<div id="links">
<span id="open-in-stories"><a href="#" id="open-in-stories-link" target="_blank" rel="noopener noreferrer" title="Open and edit the story in the MolViewStories app">Edit in MolViewStories</a>&nbsp;<span class="sep"></span></span>
<span id="open-in-molstar"><a href="#" id="open-in-molstar-link" target="_blank" rel="noopener noreferrer" title="Open the story in the Mol* Viewer app. Enables exporting an animation.">Open in Mol* Viewer</a>&nbsp;<span class="sep"></span></span>
<a href="#" id="mvs-data" title="MolViewSpec State for this story. Can be opened in the Mol* app.">Download MVS</a> <span class="sep"></span> <a href="https://github.com/molstar/molstar/tree/master/src/apps/mvs-stories" id="mvs-data" target="_blank" rel="noopener noreferrer">Source Code</a>
</div>
<script>
var urlParams = new URLSearchParams(window.location.search);
var storyId = urlParams.get('story-id');
var storyUrl = urlParams.get('story-url');
var storySessionUrl = urlParams.get('story-session-url');
var format = urlParams.get('data-format');
// For testing purposes:
// if (!storyUrl) {
// storyUrl = 'https://raw.githubusercontent.com/molstar/molstar/master/examples/mvs/kinase-story.mvsj';
// }
var molstarDataLink = storyUrl;
var editInStoriesUrl = undefined;
if (storyId) {
mvsStories.loadFromID(storyId, { format: format || 'mvsj', contextName: 'story1' });
editInStoriesUrl = 'https://molstar.org/mol-view-stories/builder?published-session-id=' + storyId;
molstarDataLink = 'https://stories.molstar.org/api/story/' + storyId + '/data';
} else if (storyUrl) {
mvsStories.loadFromURL(storyUrl, { format: format || 'mvsj', contextName: 'story1' });
}
if (!editInStoriesUrl && storySessionUrl) {
editInStoriesUrl = 'https://molstar.org/mol-view-stories/builder?session-url=' + encodeURIComponent(storySessionUrl);
}
if (molstarDataLink) {
var molstarLink = 'https://molstar.org/viewer?mvs-url=' + encodeURIComponent(molstarDataLink) + '&mvs-format=' + encodeURIComponent(format || 'mvsj');
document.getElementById('open-in-molstar-link').setAttribute('href', molstarLink);
document.getElementById('open-in-molstar').style.display = 'inline';
}
if (editInStoriesUrl) {
document.getElementById('open-in-stories-link').setAttribute('href', editInStoriesUrl);
document.getElementById('open-in-stories').style.display = 'inline';
}
document.getElementById('mvs-data').addEventListener('click', (e) => {
e.preventDefault();
mvsStories.downloadCurrentStory({ contextName: 'story1' });
});
</script>
<!-- __MOLSTAR_ANALYTICS__ -->
</body>
</html>

View File

@@ -0,0 +1,64 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { getMVSStoriesContext } from './context';
import './elements';
import { MVSData } from '../../extensions/mvs/mvs-data';
import { download } from '../../mol-util/download';
import './favicon.ico';
import '../../mol-plugin-ui/skin/light.scss';
import './styles.scss';
import './index.html';
export function getContext(name?: string) {
return getMVSStoriesContext({ name });
}
export function loadFromURL(url: string, options?: { format: 'mvsx' | 'mvsj', contextName?: string }) {
setTimeout(() => {
getContext(options?.contextName).dispatch({
kind: 'load-mvs',
format: options?.format ?? 'mvsj',
url,
});
}, 0);
}
export function loadFromData(data: MVSData | string | Uint8Array, options?: { format: 'mvsx' | 'mvsj', contextName?: string }) {
setTimeout(() => {
getContext(options?.contextName).dispatch({
kind: 'load-mvs',
format: options?.format ?? 'mvsj',
data,
});
}, 0);
}
function getStoryUrlFromId(id: string, format: 'mvsx' | 'mvsj' = 'mvsj') {
return `https://stories.molstar.org/api/story/${id}/data`;
}
export function loadFromID(id: string, options?: { format?: 'mvsx' | 'mvsj', contextName?: string }) {
loadFromURL(
getStoryUrlFromId(id, options?.format),
{ format: options?.format ?? 'mvsj', contextName: options?.contextName },
);
}
export function downloadCurrentStory(options?: { contextName?: string, filename?: string }) {
const story = getContext(options?.contextName).state.currentStoryData.value;
if (!story) return;
const isMVSJ = typeof story === 'string';
const filename = `${options?.filename ?? 'story'}.${isMVSJ ? 'mvsj' : 'mvsx'}`;
download(
new Blob([typeof story === 'string' ? story : story.buffer], { type: isMVSJ ? 'application/json' : 'application/octet-stream' }),
filename
);
};
export { MVSData };

View File

@@ -0,0 +1,66 @@
# MolViewSpec Stories App
An app that defines `mvs-stories-snapshot-markdown` and `mvs-stories-viewer` web components that can be used to view MolViewSpec molecular stories.
See the [mvs-stories](../../examples/mvs-stories) example that includes specific stories.
### Usage
- Get `mvs-stories.css` and `mvs-stories.js` from `build/mvs-stories` and include these to your HTML page
```html
<link rel="stylesheet" type="text/css" href="mvs-stories.css" />
<script type="text/javascript" src="mvs-stories.js"></script>
```
Can also use `https://cdn.jsdelivr.net/npm/molstar@latest/build/mvs-stories/mvs-stories.js` (and `.css`). `latest` can be substituted by specific version.
- Place the components in your page wrapper in `<div>` elements to set up positioning:
```html
<div class="viewer">
<mvs-stories-viewer />
</div>
<div class="snapshot">
<mvs-stories-snapshot-markdown />
</div>
```
- Load MolViewSpec state:
```html
<script>
mvsStories.loadFromURL('https://raw.githubusercontent.com/molstar/molstar/master/examples/mvs/1cbs.mvsj');
</script>
```
- See [index.html](./index.html) for full example of how to embed the app.
- For interactive development build (for production use `npm run build`) of the example that immediately reflects changes use:
```bash
npm run dev -- -a mvs-stories
```
### Multiple Stories on a Single Page
To support multiple instances of stories, use the `context-name='unique-name'` attribute on the `mvs-` components together with `loadFromURL/Data(..., { contextName: 'unique-name' })`.
For example (simplified to not include layout):
```html
<div>
<mvs-stories-viewer context-name="1" />
<mvs-stories-snapshot-markdown context-name="1" />
</div>
<div>
<mvs-stories-viewer context-name="2" />
<mvs-stories-snapshot-markdown context-name="2" />
</div>
<script>
mvsStories.loadFromURL('1.mvsj', { format: 'mvsj', contextName: '1' });
mvsStories.loadFromURL('2.mvsj', { format: 'mvsj', contextName: '2' });
</script>
```

View File

@@ -1,22 +1,6 @@
.select-story {
select {
width: 100%;
display: inline-block;
height: 38px;
padding: 0 8px;
color: #555;
line-height: 38px;
text-decoration: none;
white-space: nowrap;
background-color: transparent;
border-radius: 4px;
border: 1px solid #bbb;
cursor: pointer;
box-sizing: border-box;
}
}
@use '../../mol-plugin-ui/skin/base/components/markdown.scss';
.markdown-explanation {
.mvs-stories-markdown-explanation {
// Adapted from skeleton.css, The MIT License (MIT), Copyright (c) 2011-2014 Dave Gamache
line-height: 1.4;
font-weight: 400;
@@ -179,4 +163,42 @@
border-width: 0;
border-top: 1px solid #E1E1E1;
}
table {
border: 1px solid #E1E1E1;
border-collapse: collapse;
}
th {
text-align: left;
}
th, td {
border: 1px solid #E1E1E1;
padding: 4px 8px;
}
img {
max-width: 100%;
height: auto;
}
a {
text-decoration: none;
color: #1d4ed7;
&:hover {
text-decoration: underline;
}
}
}
@media (orientation:portrait) {
.mvs-stories-markdown-explanation {
font-size: 0.9rem;
}
.mvs-stories-markdown-explanation h3 {
font-size: 1.5rem;
}
}

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
export const VERSION = 1;

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2018-2024 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2018-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
* @author Alexander Rose <alexander.rose@weirdbyte.de>
@@ -17,7 +17,7 @@ import { QualityAssessment } from '../../extensions/model-archive/quality-assess
import { ModelExport } from '../../extensions/model-export';
import { Mp4Export } from '../../extensions/mp4-export';
import { MolViewSpec } from '../../extensions/mvs/behavior';
import { loadMVSX } from '../../extensions/mvs/components/formats';
import { loadMVSData, loadMVSX } from '../../extensions/mvs/components/formats';
import { loadMVS, MolstarLoadingExtension } from '../../extensions/mvs/load';
import { MVSData } from '../../extensions/mvs/mvs-data';
import { PDBeStructureQualityReport } from '../../extensions/pdbe';
@@ -58,6 +58,7 @@ import { Color } from '../../mol-util/color';
import '../../mol-util/polyfill';
import { ObjectKeys } from '../../mol-util/type-helpers';
import { OpenFiles } from '../../mol-plugin-state/actions/file';
import { StringLike } from '../../mol-io/common/string-like';
export { PLUGIN_VERSION as version } from '../../mol-plugin/version';
export { consoleStats, setDebugMode, setProductionMode, setTimingMode, isProductionMode, isDebugMode, isTimingMode } from '../../mol-util/debug';
@@ -108,8 +109,10 @@ const DefaultViewerOptions = {
resolutionMode: PluginConfig.General.ResolutionMode.defaultValue,
illumination: false,
viewportShowExpand: PluginConfig.Viewport.ShowExpand.defaultValue,
viewportShowReset: PluginConfig.Viewport.ShowReset.defaultValue,
viewportShowScreenshotControls: PluginConfig.Viewport.ShowScreenshotControls.defaultValue,
viewportShowControls: PluginConfig.Viewport.ShowControls.defaultValue,
viewportShowExpand: PluginConfig.Viewport.ShowExpand.defaultValue,
viewportShowSettings: PluginConfig.Viewport.ShowSettings.defaultValue,
viewportShowSelectionMode: PluginConfig.Viewport.ShowSelectionMode.defaultValue,
viewportShowAnimation: PluginConfig.Viewport.ShowAnimation.defaultValue,
@@ -186,8 +189,10 @@ export class Viewer {
[PluginConfig.General.AllowMajorPerformanceCaveat, o.allowMajorPerformanceCaveat],
[PluginConfig.General.PowerPreference, o.powerPreference],
[PluginConfig.General.ResolutionMode, o.resolutionMode],
[PluginConfig.Viewport.ShowExpand, o.viewportShowExpand],
[PluginConfig.Viewport.ShowReset, o.viewportShowReset],
[PluginConfig.Viewport.ShowScreenshotControls, o.viewportShowScreenshotControls],
[PluginConfig.Viewport.ShowControls, o.viewportShowControls],
[PluginConfig.Viewport.ShowExpand, o.viewportShowExpand],
[PluginConfig.Viewport.ShowSettings, o.viewportShowSettings],
[PluginConfig.Viewport.ShowSelectionMode, o.viewportShowSelectionMode],
[PluginConfig.Viewport.ShowAnimation, o.viewportShowAnimation],
@@ -493,7 +498,8 @@ export class Viewer {
: await plugin.builders.data.download({ url: params.model.url, isBinary: params.model.isBinary, label: params.modelLabel });
const provider = plugin.dataFormats.get(params.model.format);
model = await provider!.parse(plugin, data);
const parsed = await provider!.parse(plugin, data);
model = parsed.topology;
}
const data = params.coordinates.kind === 'coordinates-data'
@@ -515,10 +521,10 @@ export class Viewer {
return { model, coords, preset };
}
async loadMvsFromUrl(url: string, format: 'mvsj' | 'mvsx', options?: { replaceExisting?: boolean, keepCamera?: boolean, extensions?: MolstarLoadingExtension<any>[] }) {
async loadMvsFromUrl(url: string, format: 'mvsj' | 'mvsx', options?: { appendSnapshots?: boolean, keepCamera?: boolean, extensions?: MolstarLoadingExtension<any>[] }) {
if (format === 'mvsj') {
const data = await this.plugin.runTask(this.plugin.fetch({ url, type: 'string' }));
const mvsData = MVSData.fromMVSJ(data);
const mvsData = MVSData.fromMVSJ(StringLike.toString(data));
await loadMVS(this.plugin, mvsData, { sanityChecks: true, sourceUrl: url, ...options });
} else if (format === 'mvsx') {
const data = await this.plugin.runTask(this.plugin.fetch({ url, type: 'binary' }));
@@ -534,27 +540,8 @@ export class Viewer {
/** Load MolViewSpec from `data`.
* If `format` is 'mvsj', `data` must be a string or a Uint8Array containing a UTF8-encoded string.
* If `format` is 'mvsx', `data` must be a Uint8Array or a string containing base64-encoded binary data prefixed with 'base64,'. */
async loadMvsData(data: string | Uint8Array, format: 'mvsj' | 'mvsx', options?: { replaceExisting?: boolean, keepCamera?: boolean, extensions?: MolstarLoadingExtension<any>[] }) {
if (typeof data === 'string' && data.startsWith('base64')) {
data = Uint8Array.from(atob(data.substring(7)), c => c.charCodeAt(0)); // Decode base64 string to Uint8Array
}
if (format === 'mvsj') {
if (typeof data !== 'string') {
data = new TextDecoder().decode(data); // Decode Uint8Array to string using UTF8
}
const mvsData = MVSData.fromMVSJ(data);
await loadMVS(this.plugin, mvsData, { sanityChecks: true, sourceUrl: undefined, ...options });
} else if (format === 'mvsx') {
if (typeof data === 'string') {
throw new Error("loadMvsData: if `format` is 'mvsx', then `data` must be a Uint8Array or a base64-encoded string prefixed with 'base64,'.");
}
await this.plugin.runTask(Task.create('Load MVSX file', async ctx => {
const parsed = await loadMVSX(this.plugin, ctx, data as Uint8Array);
await loadMVS(this.plugin, parsed.mvsData, { sanityChecks: true, sourceUrl: parsed.sourceUrl, ...options });
}));
} else {
throw new Error(`Unknown MolViewSpec format: ${format}`);
}
loadMvsData(data: string | Uint8Array, format: 'mvsj' | 'mvsx', options?: { appendSnapshots?: boolean, keepCamera?: boolean, extensions?: MolstarLoadingExtension<any>[] }) {
return loadMVSData(this.plugin, data, format, options);
}
loadFiles(files: File[]) {
@@ -639,7 +626,7 @@ export const ViewerAutoPreset = StructureRepresentationPresetProvider({
export const PluginExtensions = {
wwPDBStructConn: wwPDBStructConnExtensionFunctions,
mvs: { MVSData, loadMVS },
mvs: { MVSData, loadMVS, loadMVSData },
modelArchive: {
qualityAssessment: {
config: MAQualityAssessmentConfig

View File

@@ -15,6 +15,7 @@ import { classifyFloatArray, classifyIntArray } from '../../mol-io/common/binary
import { BinaryEncodingProvider } from '../../mol-io/writer/cif/encoder/binary';
import { Category } from '../../mol-io/writer/cif/encoder';
import { ReaderResult } from '../../mol-io/reader/result';
import { utf8ReadLong } from '../../mol-io/common/utf8';
function showProgress(p: Progress) {
process.stdout.write(`\r${new Array(80).join(' ')}`);
@@ -31,14 +32,10 @@ async function readFile(ctx: RuntimeContext, filename: string): Promise<ReaderRe
if (isGz) input = await unzipAsync(input);
return await CIF.parseBinary(new Uint8Array(input)).runInContext(ctx);
} else {
let str: string;
if (isGz) {
const data = await unzipAsync(await readFileAsync(filename));
str = data.toString('utf8');
} else {
str = await readFileAsync(filename, 'utf8');
}
return await CIF.parseText(str).runInContext(ctx);
const data = isGz ? await unzipAsync(await readFileAsync(filename)) : await readFileAsync(filename);
const str = utf8ReadLong(data);
const cif = await CIF.parseText(str).runInContext(ctx);
return cif;
}
}

View File

@@ -100,7 +100,7 @@ async function main(args: Args): Promise<void> {
} else {
throw new Error(`Input file name must end with .mvsj or .mvsx: ${input}`);
}
await loadMVS(plugin, mvsData, { sanityChecks: true, replaceExisting: true, sourceUrl: sourceUrl, extensions: args.no_extensions ? [] : undefined });
await loadMVS(plugin, mvsData, { sanityChecks: true, sourceUrl: sourceUrl, extensions: args.no_extensions ? [] : undefined });
fs.mkdirSync(path.dirname(output), { recursive: true });
if (args.molj) {

View File

@@ -43,7 +43,7 @@ function paramInfo(param: PD.Any, offset: number): string {
}
}
function oToS(options: readonly (readonly [string, string] | readonly [string, string, string | undefined])[]) {
function oToS(options: readonly PD.SelectOption<any>[]) {
return options.map(o => `'${o[0]}'`).join(', ');
}

View File

@@ -357,7 +357,7 @@ ${nSatisfied} restraints are satisfied.
}
};
await loadMVS(plugin, data, { sanityChecks: true, replaceExisting: true, keepSnapshotCamera: true });
await loadMVS(plugin, data, { sanityChecks: true, keepCamera: true });
return data;
}

View File

@@ -297,11 +297,10 @@ async function loadTestAllExample(plugin: PluginContext) {
basic('weak-hydrogen-bond', 7),
basic('hydrophobic', 8),
basic('metal-coordination', 9),
basic('salt-bridge', 10),
covalent(1, 11),
covalent(2, 12),
covalent(3, 13),
covalent(-1, 14), // aromatic
covalent(1, 10),
covalent(2, 11),
covalent(3, 12),
covalent(-1, 13), // aromatic
basic('unknown', [0, 1, 2, 3, 13, 14], 'Testing centroid for atom set'),
]
}, { dependsOn: refs });
@@ -356,7 +355,7 @@ function SelectExampleUI({ state, load }: {
}
async function init(viewer: HTMLElement | string, controls: HTMLElement | string, defaultExample: keyof typeof Examples = 'Computed (1iep)') {
const root = typeof viewer === 'string' ? document.getElementById('viewer')! : viewer;
const root = typeof viewer === 'string' ? document.getElementById(viewer)! : viewer;
const plugin = await createViewer(root);
const state = new BehaviorSubject<{ name?: keyof typeof Examples, isLoading?: boolean }>({});
@@ -372,7 +371,7 @@ async function init(viewer: HTMLElement | string, controls: HTMLElement | string
};
createRoot(
typeof controls === 'string' ? document.getElementById('controls')! : controls
typeof controls === 'string' ? document.getElementById(controls)! : controls
).render(<SelectExampleUI state={state} load={loadExample} />);
loadExample(defaultExample);

View File

@@ -0,0 +1,219 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { JSONCifLigandGraph, JSONCifLigandGraphAtom, JSONCifLigandGraphBondProps } from '../../extensions/json-cif/ligand-graph';
import { Quat, Vec3 } from '../../mol-math/linear-algebra';
import { VdwRadius } from '../../mol-model/structure/model/properties/atomic';
import { ElementSymbol } from '../../mol-model/structure/model/types';
import { attachRGroup, RGroupName } from './r-groups';
export const TopologyEdits = {
setElement: async (graph: JSONCifLigandGraph, atomIds: number[], type_symbol: string) => {
for (const id of atomIds) {
graph.modifyAtom(id, { type_symbol });
}
},
addElement: async (graph: JSONCifLigandGraph, parentId: number, type_symbol: string) => {
const p = graph.getAtom(parentId);
if (!p) return;
const c = graph.getAtomCoords(p);
const dir = approximateAddAtomDirection(graph, p);
const r = 2 / 5 * (VdwRadius(ElementSymbol(p.row.type_symbol ?? 'C')) + VdwRadius(ElementSymbol(type_symbol)));
const newAtom = graph.addAtom({
...p.row,
// NOTE: this is not correct for editing protein atoms
// as they should have atom names from CCD, or at least the should be
// unique. This should be fine for small ligand editing.
auth_atom_id: type_symbol,
label_atom_id: type_symbol,
type_symbol,
Cartn_x: c[0] + dir[0] * r,
Cartn_y: c[1] + dir[1] * r,
Cartn_z: c[2] + dir[2] * r
});
graph.addOrUpdateBond(p, newAtom, { value_order: 'sing', type_id: 'covale' });
return newAtom;
},
removeAtoms: async (graph: JSONCifLigandGraph, atomIds: number[]) => {
for (const id of atomIds) {
graph.removeAtom(id);
}
},
removeBonds: async (graph: JSONCifLigandGraph, atomIds: number[]) => {
for (let i = 0; i < atomIds.length; ++i) {
for (let j = i + 1; j < atomIds.length; ++j) {
graph.removeBond(atomIds[i], atomIds[j]);
}
}
},
updateBonds: async (graph: JSONCifLigandGraph, atomIds: number[], props: JSONCifLigandGraphBondProps) => {
// TODO: iterate on the all-pairs behavior
// e.g. only add bonds if there is no path connecting them,
// or by a distance threshold, ...
for (let i = 0; i < atomIds.length; ++i) {
for (let j = i + 1; j < atomIds.length; ++j) {
graph.addOrUpdateBond(atomIds[i], atomIds[j], props);
}
}
},
attachRgroup: async (graph: JSONCifLigandGraph, atomId: number, name: RGroupName) => {
await attachRGroup(graph, name, atomId);
}
};
export type GeometryEditFn = (param: number) => JSONCifLigandGraph;
export const GeometryEdits = {
twist: (graph: JSONCifLigandGraph, atomIds: number[]): GeometryEditFn => {
if (atomIds.length !== 2) {
throw new Error('Twist requires exactly two atoms.');
}
const { left, right } = splitGraph(graph, atomIds[0], atomIds[1]);
const active = left.length <= right.length ? left : right;
const a = left.length <= right.length ? atomIds[0] : atomIds[1];
const b = left.length <= right.length ? atomIds[1] : atomIds[0];
const pivot = graph.getAtomCoords(a);
const axis = Vec3.sub(Vec3(), pivot, graph.getAtomCoords(b));
Vec3.normalize(axis, axis);
const basePositions = active.map(a => graph.getAtomCoords(a));
const xform = Quat();
const p = Vec3();
return (angle: number) => {
Quat.setAxisAngle(xform, axis, angle);
for (let i = 0; i < active.length; ++i) {
Vec3.copy(p, basePositions[i]);
Vec3.sub(p, p, pivot);
Vec3.transformQuat(p, p, xform);
Vec3.add(p, p, pivot);
graph.modifyAtom(active[i], {
Cartn_x: p[0],
Cartn_y: p[1],
Cartn_z: p[2]
});
}
return graph;
};
},
stretch: (graph: JSONCifLigandGraph, atomIds: number[]): GeometryEditFn => {
if (atomIds.length !== 2) {
throw new Error('Stretch requires exactly two atoms.');
}
const { left, right } = splitGraph(graph, atomIds[0], atomIds[1]);
const a = graph.getAtomCoords(atomIds[0]);
const b = graph.getAtomCoords(atomIds[1]);
const center = Vec3.add(Vec3(), b, a);
Vec3.scale(center, center, 0.5);
const baseDelta = Vec3.sub(Vec3(), a, center);
const baseLeft = left.map(a => graph.getAtomCoords(a));
const baseRight = right.map(a => graph.getAtomCoords(a));
const p = Vec3();
const delta = Vec3();
return (factor: number) => {
Vec3.scale(delta, baseDelta, factor);
for (let i = 0; i < left.length; ++i) {
Vec3.copy(p, baseLeft[i]);
Vec3.add(p, p, delta);
graph.modifyAtom(left[i], {
Cartn_x: p[0],
Cartn_y: p[1],
Cartn_z: p[2]
});
}
for (let i = 0; i < right.length; ++i) {
Vec3.copy(p, baseRight[i]);
Vec3.sub(p, p, delta);
graph.modifyAtom(right[i], {
Cartn_x: p[0],
Cartn_y: p[1],
Cartn_z: p[2]
});
}
return graph;
};
},
};
function approximateAddAtomDirection(graph: JSONCifLigandGraph, parent: JSONCifLigandGraphAtom) {
let deltas: Vec3[] = [];
const bonds = graph.bondByKey.get(parent.key);
if (!bonds?.length) return Vec3.create(1, 0, 0);
const c = graph.getAtomCoords(parent);
for (const b of bonds) {
const delta = Vec3.sub(Vec3(), graph.getAtomCoords(b.atom_2), c);
deltas.push(delta);
}
if (deltas.length === 1) {
const ret = Vec3.negate(Vec3(), deltas[0]);
Vec3.normalize(ret, ret);
return ret;
}
if (deltas.length === 2) {
const ret = Vec3.add(Vec3(), deltas[0], deltas[1]);
Vec3.normalize(ret, ret);
Vec3.negate(ret, ret);
return ret;
}
// Take the first three deltas and cross-product them
deltas = deltas.slice(0, 3);
const crossProducts: Vec3[] = [];
for (let i = 0; i < deltas.length; ++i) {
for (let j = i + 1; j < deltas.length; ++j) {
const cross = Vec3.cross(Vec3(), deltas[i], deltas[j]);
Vec3.normalize(cross, cross);
crossProducts.push(cross);
}
}
for (let i = 1; i < crossProducts.length; ++i) {
Vec3.matchDirection(crossProducts[i], crossProducts[i], crossProducts[0]);
}
const avg = Vec3.create(0, 0, 0);
for (const cp of crossProducts) {
Vec3.add(avg, avg, cp);
}
Vec3.normalize(avg, avg);
return avg;
}
function getAtomDepths(graph: JSONCifLigandGraph, atomId: number) {
return graph.traverse(atomId, 'bfs', new Map<string, number>(), (a, depths, pred) => {
depths.set(a.key, pred ? depths.get(pred.atom_1.key)! + 1 : 0);
});
}
function splitGraph(graph: JSONCifLigandGraph, leftId: number, rightId: number) {
const xs = getAtomDepths(graph, leftId);
const ys = getAtomDepths(graph, rightId);
const l: JSONCifLigandGraphAtom[] = [];
const r: JSONCifLigandGraphAtom[] = [];
for (const a of graph.atoms) {
if (xs.has(a.key) && ys.has(a.key)) {
if (xs.get(a.key)! < ys.get(a.key)!) l.push(a);
else r.push(a);
} else if (xs.has(a.key)) {
l.push(a);
} else if (ys.has(a.key)) {
r.push(a);
}
}
return { left: l, right: r };
}

View File

@@ -0,0 +1,53 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
export const ExampleMol = `2244
-OEChem-04072009073D
21 21 0 0 0 0 0 0 0999 V2000
1.2333 0.5540 0.7792 O 0 0 0 0 0 0 0 0 0 0 0 0
-0.6952 -2.7148 -0.7502 O 0 0 0 0 0 0 0 0 0 0 0 0
0.7958 -2.1843 0.8685 O 0 0 0 0 0 0 0 0 0 0 0 0
1.7813 0.8105 -1.4821 O 0 0 0 0 0 0 0 0 0 0 0 0
-0.0857 0.6088 0.4403 C 0 0 0 0 0 0 0 0 0 0 0 0
-0.7927 -0.5515 0.1244 C 0 0 0 0 0 0 0 0 0 0 0 0
-0.7288 1.8464 0.4133 C 0 0 0 0 0 0 0 0 0 0 0 0
-2.1426 -0.4741 -0.2184 C 0 0 0 0 0 0 0 0 0 0 0 0
-2.0787 1.9238 0.0706 C 0 0 0 0 0 0 0 0 0 0 0 0
-2.7855 0.7636 -0.2453 C 0 0 0 0 0 0 0 0 0 0 0 0
-0.1409 -1.8536 0.1477 C 0 0 0 0 0 0 0 0 0 0 0 0
2.1094 0.6715 -0.3113 C 0 0 0 0 0 0 0 0 0 0 0 0
3.5305 0.5996 0.1635 C 0 0 0 0 0 0 0 0 0 0 0 0
-0.1851 2.7545 0.6593 H 0 0 0 0 0 0 0 0 0 0 0 0
-2.7247 -1.3605 -0.4564 H 0 0 0 0 0 0 0 0 0 0 0 0
-2.5797 2.8872 0.0506 H 0 0 0 0 0 0 0 0 0 0 0 0
-3.8374 0.8238 -0.5090 H 0 0 0 0 0 0 0 0 0 0 0 0
3.7290 1.4184 0.8593 H 0 0 0 0 0 0 0 0 0 0 0 0
4.2045 0.6969 -0.6924 H 0 0 0 0 0 0 0 0 0 0 0 0
3.7105 -0.3659 0.6426 H 0 0 0 0 0 0 0 0 0 0 0 0
-0.2555 -3.5916 -0.7337 H 0 0 0 0 0 0 0 0 0 0 0 0
1 5 1 0 0 0 0
1 12 1 0 0 0 0
2 11 1 0 0 0 0
2 21 1 0 0 0 0
3 11 2 0 0 0 0
4 12 2 0 0 0 0
5 6 1 0 0 0 0
5 7 2 0 0 0 0
6 8 2 0 0 0 0
6 11 1 0 0 0 0
7 9 1 0 0 0 0
7 14 1 0 0 0 0
8 10 1 0 0 0 0
8 15 1 0 0 0 0
9 10 2 0 0 0 0
9 16 1 0 0 0 0
10 17 1 0 0 0 0
12 13 1 0 0 0 0
13 18 1 0 0 0 0
13 19 1 0 0 0 0
13 20 1 0 0 0 0
M END`;

View File

@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
<title>Mol* Ligand Editor Example</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: sans-serif;
}
#app {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
}
.editor-controls button {
padding: 0 8px;
background-color: transparent;
color: black;
border: 1px solid rgb(206, 200, 186);
border-radius: 0;
cursor: pointer;
height: 24px;
}
.editor-controls input[type="text"] {
padding: 0 8px;
border: 1px solid rgb(206, 200, 186);
border-radius: 0;
height: 24px;
}
.editor-controls textarea {
padding: 4px 8px;
border: 1px solid rgb(206, 200, 186);
border-radius: 0;
}
.editor-controls button:hover, .editor-controls input:hover, .editor-controls textarea:hover {
background-color: rgba(206, 200, 186, 0.1);
}
.msp-selection-viewport-controls {
display: none;
}
</style>
<link rel="stylesheet" type="text/css" href="molstar.css" />
</head>
<body>
<div id="app"></div>
</div>
<script type="text/javascript" src="./index.js"></script>
<script>
initLigandEditorExample('app');
</script>
</body>
</html>

View File

@@ -0,0 +1,433 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { useState } from 'react';
import { createRoot } from 'react-dom/client';
import { BehaviorSubject, Subscription, throttleTime } from 'rxjs';
import { JSONCifLigandGraph, JSONCifLigandGraphBondProps } from '../../extensions/json-cif/ligand-graph';
import { JSONCifDataBlock, JSONCifFile } from '../../extensions/json-cif/model';
import { ParseJSONCifFileData } from '../../extensions/json-cif/transformers';
import { MolViewSpec } from '../../extensions/mvs/behavior';
import { StructureElement, StructureProperties } from '../../mol-model/structure';
import { PluginStateObject } from '../../mol-plugin-state/objects';
import { ModelFromTrajectory, StructureFromModel, TrajectoryFromMmCif } from '../../mol-plugin-state/transforms/model';
import { StructureRepresentation3D } from '../../mol-plugin-state/transforms/representation';
import { PluginUIContext } from '../../mol-plugin-ui/context';
import { useBehavior } from '../../mol-plugin-ui/hooks/use-behavior';
import { Plugin } from '../../mol-plugin-ui/plugin';
import '../../mol-plugin-ui/skin/light.scss';
import { DefaultPluginUISpec } from '../../mol-plugin-ui/spec';
import { PluginCommands } from '../../mol-plugin/commands';
import { PluginConfig } from '../../mol-plugin/config';
import { PluginSpec } from '../../mol-plugin/spec';
import { StateObjectSelector } from '../../mol-state';
import { download } from '../../mol-util/download';
import { GeometryEditFn, GeometryEdits, TopologyEdits } from './edits';
import { ExampleMol } from './example-data';
import './index.html';
import { jsonCifToMolfile } from './molfile';
import { RGroupName } from './r-groups';
import { molfileToJSONCif } from '../../extensions/json-cif/utils';
import { SingleTaskQueue } from '../../mol-util/single-task-queue';
async function init(target: HTMLElement | string, molfile: string = ExampleMol) {
const root = typeof target === 'string' ? document.getElementById(target)! : target;
const plugin = await createViewer(root);
const model = new EditorModel(plugin);
createRoot(root).render(<AppUI model={model} />);
loadMolfile(model, molfile);
return model;
}
(window as any).initLigandEditorExample = init;
async function createViewer(root: HTMLElement) {
const spec = DefaultPluginUISpec();
const plugin = new PluginUIContext({
...spec,
layout: {
initial: {
isExpanded: false,
showControls: false
}
},
components: {
remoteState: 'none',
},
behaviors: [
...spec.behaviors,
PluginSpec.Behavior(MolViewSpec)
],
config: [
[PluginConfig.Viewport.ShowAnimation, false],
[PluginConfig.Viewport.ShowSelectionMode, false],
[PluginConfig.Viewport.ShowExpand, false],
[PluginConfig.Viewport.ShowControls, false],
]
});
await plugin.init();
plugin.managers.interactivity.setProps({ granularity: 'element' });
plugin.selectionMode = true;
return plugin;
}
async function loadMolfile(model: EditorModel, molfile: string) {
const { plugin } = model;
await plugin.clear();
const file = await molfileToJSONCif(molfile);
const update = plugin.build();
const data = update.toRoot()
.apply(ParseJSONCifFileData, { data: file.jsoncif });
data
.apply(TrajectoryFromMmCif)
.apply(ModelFromTrajectory)
.apply(StructureFromModel, { type: { name: 'model', params: {} } })
.apply(StructureRepresentation3D, {
type: { name: 'ball-and-stick', params: {} },
colorTheme: {
name: 'element-symbol',
params: { carbonColor: { name: 'element-symbol', params: {} } }
}
});
await update.commit();
model.setDataSelector(data.selector);
}
class EditorModel {
private dataSelector: StateObjectSelector | undefined = undefined;
state = {
element: new BehaviorSubject<string>('C'),
history: new BehaviorSubject<JSONCifFile[]>([]),
molfile: new BehaviorSubject<string>(''),
};
get data() {
return this.dataSelector?.cell?.transform?.params?.data as JSONCifFile | undefined;
}
get history() {
return this.state.history.value;
}
createGraph() {
return new JSONCifLigandGraph(this.data?.dataBlocks[0]!);
}
setDataSelector(selector: StateObjectSelector) {
this.dataSelector = selector;
this.updateMolFile();
}
updateMolFile() {
if (!this.data) return this.state.molfile.next('');
try {
const molfile = jsonCifToMolfile(this.data?.dataBlocks[0], {
comment: 'Generated by Mol* Ligand Editor'
});
this.state.molfile.next(molfile);
} catch (e) {
console.error('Failed to convert to molfile');
console.error(e);
this.state.molfile.next(`Error: ${e}`);
}
}
async update(data: JSONCifDataBlock, pushHistory = true, historyData?: JSONCifFile) {
if (!this.data) return;
const updated: JSONCifFile = {
...this.data!,
dataBlocks: [data],
};
if (pushHistory) {
this.state.history.next([...this.history, historyData ?? this.data!]);
}
const update = this.plugin.build();
update.to(this.dataSelector!).update({ data: updated });
await update.commit();
this.updateMolFile();
}
undo = async () => {
if (!this.dataSelector) return;
if (this.history.length === 0) return;
const data = this.history[this.history.length - 1];
this.state.history.next(this.history.slice(0, this.history.length - 1));
const update = this.plugin.build();
update.to(this.dataSelector).update({ data });
await update.commit();
this.updateMolFile();
};
private getEditableStructures() {
if (!this.dataSelector?.isOk) return new Set();
const structures = this.plugin.state.data.selectQ(q => q
.byRef(this.dataSelector?.ref!)
.subtree()
.filter(c => PluginStateObject.Molecule.Structure.is(c.obj))
);
return new Set(structures.map(s => s.obj?.data));
}
private getSelectedAtomIds() {
if (!this.data) return [];
const structures = this.getEditableStructures();
if (structures.size === 0) return [];
const { selection } = this.plugin.managers.structure;
const ids: number[] = [];
selection.entries.forEach(e => {
if (!structures.has(e.selection.structure)) return;
StructureElement.Loci.forEachLocation(e.selection, (l) => {
ids.push(StructureProperties.atom.id(l));
});
});
return ids;
}
async editGraphTopology<Args extends any[], T>(fn: (graph: JSONCifLigandGraph, ...args: Args) => Promise<T>, ...args: Args) {
try {
const graph = this.createGraph();
const result = await fn(graph, ...args);
const data = graph.getData().block;
await this.update(data);
this.plugin.managers.interactivity.lociSelects.deselectAll();
return result;
} catch (e) {
console.error('Failed to edit graph');
console.error(e);
this.notify(`${e}`, 5000);
}
}
private notify(message: string, timeoutMs = 2500) {
PluginCommands.Toast.Show(this.plugin, { key: '<edit>', title: 'Edit', message, timeoutMs });
}
setElement = async () => {
const symbol = this.state.element.value.trim();
if (!symbol) return this.notify('No element symbol provided');
const ids = this.getSelectedAtomIds();
if (!ids.length) return this.notify('No atoms selected');
await this.editGraphTopology(TopologyEdits.setElement, ids, symbol);
};
addElement = async () => {
const symbol = this.state.element.value.trim();
if (!symbol) return this.notify('No element symbol provided');
const ids = this.getSelectedAtomIds();
if (ids.length !== 1) return this.notify('Select a single atom to add a new atom to');
await this.editGraphTopology(TopologyEdits.addElement, ids[0], symbol);
};
removeAtoms = async () => {
const ids = this.getSelectedAtomIds();
if (!ids.length) return this.notify('No atoms selected');
await this.editGraphTopology(TopologyEdits.removeAtoms, ids);
};
removeBonds = async () => {
const ids = this.getSelectedAtomIds();
if (!ids.length) return this.notify('No atoms selected');
await this.editGraphTopology(TopologyEdits.removeBonds, ids);
};
updateBonds = async (props: JSONCifLigandGraphBondProps) => {
const ids = this.getSelectedAtomIds();
if (!ids.length) return this.notify('No atoms selected');
await this.editGraphTopology(TopologyEdits.updateBonds, ids, props);
};
attachRgroup = async (name: RGroupName) => {
const ids = this.getSelectedAtomIds();
if (ids.length !== 1) return this.notify('Select a single hydrogen atom to attach an R-group to');
await this.editGraphTopology(TopologyEdits.attachRgroup, ids[0], name);
};
private geometryEditInitialData: JSONCifFile | undefined = undefined;
private geometryEditValues = new BehaviorSubject<[value: number, finish: boolean]>([0, false]);
private currentGeometryEdit: GeometryEditFn | undefined = undefined;
private currentGeomeryEditSub: Subscription | undefined = undefined;
private geometryEditQueue = new SingleTaskQueue();
private applyGeometryEdit = ([param, finish]: [param: number, finish: boolean]) => {
if (!this.currentGeometryEdit) return;
const graph = this.currentGeometryEdit(param);
const data = graph.getData().block;
const initialData = this.geometryEditInitialData;
if (finish) {
this.currentGeometryEdit = undefined;
this.currentGeomeryEditSub?.unsubscribe();
this.currentGeomeryEditSub = undefined;
this.geometryEditInitialData = undefined;
}
this.geometryEditQueue.run(() => this.update(data, finish, initialData));
};
beginGeometryEdit<Args extends any[], T>(fn: (graph: JSONCifLigandGraph, ...args: Args) => GeometryEditFn, initial: number, ...args: Args) {
try {
this.geometryEditValues.next([initial, false]);
const graph = this.createGraph();
this.geometryEditInitialData = this.data!;
this.currentGeometryEdit = fn(graph, ...args);
this.currentGeomeryEditSub = this.geometryEditValues
.pipe(throttleTime(1000 / 60, undefined, { leading: true, trailing: true }))
.subscribe(this.applyGeometryEdit);
} catch (e) {
console.error('Failed to edit graph');
console.error(e);
this.notify(`${e}`, 5000);
}
}
setGeometryEditValue(param: number, finish = false) {
this.geometryEditValues.next([param, finish]);
}
twist = () => {
this.beginGeometryEdit(GeometryEdits.twist, 0, this.getSelectedAtomIds());
};
stretch = () => {
this.beginGeometryEdit(GeometryEdits.stretch, 0, this.getSelectedAtomIds());
};
constructor(public plugin: PluginUIContext) { }
}
function AppUI({ model }: { model: EditorModel }) {
return <div style={{ display: 'flex', flexDirection: 'row', height: '100%', width: '100%' }}>
<div style={{ flexGrow: 1, display: 'block', position: 'relative' }}>
<Plugin plugin={model.plugin} />
</div>
<div style={{ flexShrink: 0, minWidth: 500, width: 400, display: 'flex', flexDirection: 'column', gap: '5px' }}>
<ControlsUI model={model} />
</div>
</div>;
}
function ControlsUI({ model }: { model: EditorModel }) {
return <div style={{ display: 'flex', flexDirection: 'column', gap: '5px', padding: 8, overflow: 'hidden', overflowY: 'auto' }} className='editor-controls'>
<div>
<UndoButton model={model} />
</div>
<b>Atoms</b>
<div style={{ display: 'flex', gap: '5px' }}>
<button onClick={model.removeAtoms}>Remove</button>
<div>
<ElementEditUI model={model} />
<button onClick={model.setElement} style={{ borderLeft: 'none' }}>Set Element</button>
<button onClick={model.addElement} style={{ borderLeft: 'none' }}>Add Element</button>
</div>
</div>
<b>Bonds</b>
<div style={{ display: 'flex', gap: '5px' }}>
<button onClick={model.removeBonds}>Remove</button>
<button onClick={() => model.updateBonds({ value_order: 'sing', type_id: 'covale' })}>-</button>
<button onClick={() => model.updateBonds({ value_order: 'doub', type_id: 'covale' })}>=</button>
<button onClick={() => model.updateBonds({ value_order: 'trip', type_id: 'covale' })}></button>
</div>
<b>R-groups</b>
<div style={{ display: 'flex', gap: '5px' }}>
<button onClick={() => model.attachRgroup('CH3')}>-CH<sub>3</sub></button>
</div>
<b>Geometry</b>
<TwistUI model={model} />
<StretchUI model={model} />
<b>Molfile</b>
<MolFileUI model={model} />
</div>;
}
function MolFileUI({ model }: { model: EditorModel }) {
const molfile = useBehavior(model.state.molfile);
return <>
<textarea value={molfile} readOnly style={{ width: '100%', height: 200, fontFamily: 'monospace', fontSize: '10px' }} />
<div style={{ display: 'flex', gap: '5px' }}>
<button onClick={() => navigator.clipboard.writeText(molfile)}>Copy</button>
<button onClick={() => download(new Blob([molfile], { type: 'text/plain' }), `edited-molecule-${Date.now()}.mol`)}>Save</button>
</div>
</>;
}
function UndoButton({ model }: { model: EditorModel }) {
const history = useBehavior(model.state.history);
return <button onClick={model.undo} disabled={history.length === 0}>Undo [{history.length}]</button>;
}
function ElementEditUI({ model }: { model: EditorModel }) {
const element = useBehavior(model.state.element);
return <input type="text" value={element} style={{ width: 50 }} onChange={e => model.state.element.next(e.target.value)} />;
}
const GeometryLabelWidth = 60;
function TwistUI({ model }: { model: EditorModel }) {
const [value, setValue] = useState(0);
return <div style={{ display: 'flex', alignItems: 'center', gap: 8 }} >
<i style={{ width: GeometryLabelWidth }}>Twist</i> <input
type='range' min={-60} max={60} step={1} value={value}
onMouseDown={model.twist}
onMouseUp={(e) => {
requestAnimationFrame(() => {
model.setGeometryEditValue(Math.PI * value / 60, true);
setValue(0);
});
}}
onChange={e => {
const value = +e.target.value;
setValue(value);
model.setGeometryEditValue(Math.PI * value / 60);
}}
/>
</div>;
}
function StretchUI({ model }: { model: EditorModel }) {
const [value, setValue] = useState(0);
return <div style={{ display: 'flex', alignItems: 'center', gap: 8 }} >
<i style={{ width: GeometryLabelWidth }}>Stretch</i> <input
type='range' min={-60} max={60} step={1} value={value}
onMouseDown={model.stretch}
onMouseUp={(e) => {
requestAnimationFrame(() => {
model.setGeometryEditValue(0.5 * value / 60, true);
setValue(0);
});
}}
onChange={e => {
const value = +e.target.value;
setValue(value);
model.setGeometryEditValue(0.5 * value / 60);
}}
/>
</div>;
}

View File

@@ -0,0 +1,98 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { getJSONCifCategory, JSONCifDataBlock } from '../../extensions/json-cif/model';
import { mmCIF_Schema } from '../../mol-io/reader/cif/schema/mmcif';
import { MolstarBondSiteSchema, MolstarBondSiteTypeId, MolstarBondSiteValueOrder } from '../../mol-model/structure/export/categories/molstar_bond_site';
function padLeft(v: any, n = 3) {
let s = `${v}`;
while (s.length < n) s = ' ' + s;
return s;
}
function padRight(v: any, n = 3) {
let s = `${v}`;
while (s.length < n) s = s + ' ';
return s;
}
function mapMolChage(v: number) {
switch (v) {
case 3: return 1;
case 2: return 2;
case 1: return 3;
case -1: return 5;
case -2: return 6;
case -3: return 7;
default: return 0;
}
}
function mapMolBondOrder(order: MolstarBondSiteValueOrder, type: MolstarBondSiteTypeId) {
if (type !== 'covale') return 8;
switch (order) {
case 'sing': return 1;
case 'doub': return 2;
case 'trip': return 3;
case 'arom': return 4;
default: return 8;
}
}
export function jsonCifToMolfile(data: JSONCifDataBlock, options?: { name?: string, comment?: string }) {
// The method works in the sense that Mol* can re-open the file.
// For production use, this will likely need more testing and tweaks (e.g., support for M CHG property).
if (data.categories.atom_site === undefined || data.categories.molstar_bond_site === undefined) {
throw new Error('The data block must contain atom_site and molstar_bond_site categories.');
}
const { atom_site: _atoms, molstar_bond_site: _bonds } = data.categories;
const atoms = getJSONCifCategory<mmCIF_Schema['atom_site']>(data, 'atom_site')!;
const bonds = getJSONCifCategory<MolstarBondSiteSchema['molstar_bond_site']>(data, 'molstar_bond_site')!;
const lines = [
`${options?.name ?? 'mol'}`,
' Molstar 3D',
options?.comment ?? '',
`${padLeft(atoms.rows.length)}${padLeft(bonds.rows.length)} 0 0 0 0 0 0 0 0 V2000`,
];
const atomIdToIndex = new Map<number, number>();
for (let i = 0; i < atoms.rows.length; ++i) {
const a = atoms.rows[i];
const { id, Cartn_x, Cartn_y, Cartn_z, type_symbol, pdbx_formal_charge } = a;
atomIdToIndex.set(id, i + 1);
const fields = [
padLeft(Cartn_x.toFixed(4), 10),
padLeft(Cartn_y.toFixed(4), 10),
padLeft(Cartn_z.toFixed(4), 10),
' ',
padRight(type_symbol, 2),
' 0',
padLeft(mapMolChage(pdbx_formal_charge), 3),
' 0 0 0 0 0 0 0 0 0 0',
];
lines.push(fields.join(''));
}
for (const b of bonds.rows) {
const { atom_id_1, atom_id_2, value_order, type_id } = b;
const fields = [
padLeft(atomIdToIndex.get(atom_id_1)!, 3),
padLeft(atomIdToIndex.get(atom_id_2)!, 3),
padLeft(mapMolBondOrder(value_order, type_id), 3),
' 0 0 0 0',
];
lines.push(fields.join(''));
}
lines.push('M END');
return lines.join('\n');
}

View File

@@ -0,0 +1,110 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { JSONCifLigandGraph, JSONCifLigandGraphAtom } from '../../extensions/json-cif/ligand-graph';
import { molfileToJSONCif } from '../../extensions/json-cif/utils';
import { Mat4, Vec3 } from '../../mol-math/linear-algebra';
export type RGroupName = keyof typeof RGroups;
export async function attachRGroup(pGraph: JSONCifLigandGraph, rgroupName: RGroupName, pAtomOrId: number | JSONCifLigandGraphAtom) {
const pAtom = pGraph.getAtom(pAtomOrId);
if (pAtom?.row?.type_symbol !== 'H') {
throw new Error('R-group attachment point must be a hydrogen atom.');
}
const { molfile, jsoncif: rgroupData } = await molfileToJSONCif(RGroups[rgroupName]);
const attachIdx = molfile.attachmentPoints?.[0].atomIdx;
if (typeof attachIdx !== 'number') {
throw new Error('R-group attachment point not specified.');
}
// Compute and apply rGroup transformation
const pBonds = pGraph.getBonds(pAtom);
if (pBonds.length !== 1) {
throw new Error('R-group attachment point must have exactly 1 bond.');
}
const pDir = pGraph.getBondDirection(pBonds[0]);
const pPivot = pBonds[0].atom_2;
Vec3.negate(pDir, pDir);
Vec3.normalize(pDir, pDir);
const rGraph = new JSONCifLigandGraph(rgroupData.dataBlocks[0]);
const rAtom = rGraph.getAtomAtIndex(attachIdx - 1);
if (rAtom.row?.type_symbol !== 'R#') {
throw new Error('R-group attachment point is not a R# atom.');
}
const rCoords = rGraph.getAtomCoords(rAtom);
const rBonds = rGraph.getBonds(rAtom);
if (rBonds.length !== 1) {
throw new Error('R-group R# atom must have exactly 1 bond.');
}
const rPivot = rGraph.getAtom(rBonds[0].atom_2);
const rDir = rGraph.getBondDirection(rBonds[0]);
Vec3.normalize(rDir, rDir);
const rotation = Vec3.makeRotation(Mat4(), rDir, pDir);
const translation = Mat4.fromTranslation(Mat4(), Vec3.sub(Vec3(), pGraph.getAtomCoords(pPivot), rCoords));
const C = Mat4.fromTranslation(Mat4(), Vec3.negate(Vec3(), rCoords));
const CT = Mat4.fromTranslation(Mat4(), rCoords);
const T0 = Mat4.mul3(Mat4(), CT, rotation, C);
const T = Mat4.mul(Mat4(), translation, T0);
rGraph.transformCoords(T);
// Merge the two graphs
pGraph.removeAtom(pAtom);
rGraph.removeAtom(rAtom);
const newAtomMap = new Map<string, JSONCifLigandGraphAtom>();
// Add atoms
for (const a of rGraph.atoms) {
const newAtom = pGraph.addAtom(a.row);
newAtomMap.set(a.key, newAtom);
if (a === rPivot) {
pGraph.addOrUpdateBond(pPivot, newAtom, rBonds[0].props);
}
}
// Add bonds
for (const a of rGraph.atoms) {
if (a === rAtom) continue;
const bonds = rGraph.getBonds(a);
const atom1 = newAtomMap.get(a.key)!;
for (const b of bonds) {
if (b.atom_2 === rAtom) continue;
const atom2 = newAtomMap.get(b.atom_2.key)!;
pGraph.addOrUpdateBond(atom1, atom2, b.props);
}
}
return pGraph;
}
// Assumes the "attachment point (M APO)" points to a hydrogen atom that gets removed
// when the R-group is attached.
const RGroups = {
CH3: `CH3
-OEChem-05072507373D
5 4 0 0 0 0 0 0 0999 V2000
0.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
0.5541 0.7996 0.4965 R# 0 0 0 0 0 0 0 0 0 0 0 0
0.6833 -0.8134 -0.2536 H 0 0 0 0 0 0 0 0 0 0 0 0
-0.7782 -0.3735 0.6692 H 0 0 0 0 0 0 0 0 0 0 0 0
-0.4593 0.3874 -0.9121 H 0 0 0 0 0 0 0 0 0 0 0 0
1 2 1 0 0 0 0
1 3 1 0 0 0 0
1 4 1 0 0 0 0
1 5 1 0 0 0 0
M APO 1 2 1
M END`
};

View File

@@ -0,0 +1,12 @@
# Ligand Editor Example
Basic small molecule editing features utilizing the `json-cif` format/extension.
This application is (at least currently) not meant to be a production-ready molecule editor.
To run development build locally from the root `molstar` directory (after `npm install`):
```bash
npm run dev -- -e ligand-editor
```
and navigate to `build/examples/ligand-editor` in the hosted server linked in the script output.

View File

@@ -1,37 +0,0 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { BehaviorSubject } from 'rxjs';
import { MVSData } from '../../extensions/mvs/mvs-data';
import type { MolComponentViewerModel } from './elements/viewer';
export type MolComponentCommand =
| { kind: 'load-mvs', format?: 'mvsj' | 'mvsx', url?: string, data?: MVSData }
export class MolComponentContext {
commands = new BehaviorSubject<MolComponentCommand | undefined>(undefined);
behavior = {
viewers: new BehaviorSubject<{ name?: string, model: MolComponentViewerModel }[]>([]),
};
dispatch(command: MolComponentCommand) {
this.commands.next(command);
}
constructor(public name?: string) {
}
}
export function getMolComponentContext(options?: { name?: string, container?: object }) {
const container: any = options?.container ?? window;
container.componentContexts ??= {};
const name = options?.name ?? '<default>';
if (!container.componentContexts[name]) {
container.componentContexts[name] = new MolComponentContext(options?.name);
}
return container.componentContexts[name];
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -4,6 +4,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
<link rel="icon" href="./favicon.ico" type="image/x-icon">
<title>Molecular Stories</title>
<style>
* {
@@ -70,35 +71,41 @@
border-top: none;
}
.markdown-explanation {
font-size: 0.9rem !important;
}
.markdown-explanation h3 {
font-size: 1.5rem !important;
}
.msp-viewport-controls-buttons {
display: none;
}
}
.select-story select {
width: 100%;
display: inline-block;
height: 38px;
padding: 0 8px;
color: #555;
line-height: 38px;
text-decoration: none;
white-space: nowrap;
background-color: transparent;
border-radius: 4px;
border: 1px solid #bbb;
cursor: pointer;
box-sizing: border-box;
}
</style>
<link rel="stylesheet" type="text/css" href="molstar.css" />
<script type="text/javascript" src="./index.js"></script>
<script type="text/javascript" src="index.js"></script>
</head>
<body>
<div id="viewer">
<mc-viewer name="v1" />
<mvs-stories-viewer></mvs-stories-viewer>
</div>
<div id="controls">
<div id="select-story" class="select-story"></div>
<div class="markdown-explanation" style="flex-grow: 1;">
<mc-snapshot-markdown viewer-name="v1" />
</div>
<mvs-stories-snapshot-markdown style="flex-grow: 1;"></mvs-stories-snapshot-markdown>
</div>
<div id="links">
<a href="#" id="mvs-data" filename="kinase-story.mvsj">Download MVS State</a> | <a href="https://github.com/molstar/molstar/tree/master/src/examples/mvs-stories" id="mvs-data" target="_blank" rel="noopener noreferrer">Source Code</a>
<a href="#" id="mvs-data">Download MVS State</a> | <a href="https://github.com/molstar/molstar/tree/master/src/examples/mvs-stories" id="mvs-data" target="_blank" rel="noopener noreferrer">Source Code</a>
</div>
<script>
@@ -111,6 +118,7 @@
window.initStories();
}, 0);
</script>
<!-- __MOLSTAR_ANALYTICS__ -->
</body>
</html>

View File

@@ -4,42 +4,40 @@
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { getMolComponentContext } from './context';
import './index.html';
import './elements/snapshot-markdown';
import './elements/viewer';
import '../../mol-plugin-ui/skin/light.scss';
import './styles.scss';
import { download } from '../../mol-util/download';
import { BehaviorSubject } from 'rxjs';
import { Stories } from './stories';
import { useBehavior } from '../../mol-plugin-ui/hooks/use-behavior';
import { createRoot } from 'react-dom/client';
import { getMVSStoriesContext } from '../../apps/mvs-stories/context';
import '../../apps/mvs-stories/elements';
export class MolComponents {
getContext(name?: string) {
return getMolComponentContext({ name });
}
import './favicon.ico';
import '../../mol-plugin-ui/skin/light.scss';
import '../../apps/mvs-stories/styles.scss';
import './index.html';
function getContext(name?: string) {
return getMVSStoriesContext({ name });
}
const MC = new MolComponents();
type Story = { kind: 'built-in', id: string } | { kind: 'url', url: string, format: 'mvsx' | 'mvsj' } | undefined;
const CurrentStory = new BehaviorSubject<Story>(undefined);
function SelectStoryUI({ subject }: { subject: BehaviorSubject<Story> }) {
const current = useBehavior(subject);
const selectedId = current?.kind === 'built-in' ? current.id : current?.kind === 'url' ? 'url' : '';
return <select onChange={e => {
const value = e.currentTarget.value;
const s = Stories.find(s => s.id === value);
if (!s) return;
subject.next({ kind: 'built-in', id: s.id });
}}>
}} value={selectedId}>
{!current && <option value=''>Select a story...</option>}
{Stories.map(s => <option key={s.name} value={s.id} selected={current?.kind === 'built-in' && current.id === s.id}>Story: {s.name}</option>)}
{Stories.map(s => <option key={s.name} value={s.id}>Story: {s.name}</option>)}
{current?.kind === 'url' && <option disabled>------------------</option>}
{current?.kind === 'url' && <option value='url' selected>{current.url}</option>}
{current?.kind === 'url' && <option value='url'>{current.url}</option>}
</select>;
}
@@ -49,7 +47,7 @@ function init() {
history.replaceState({}, '', '');
} else if (story.kind === 'url') {
history.replaceState({}, '', story ? `?story-url=${encodeURIComponent(story.url)}&data-format=${story.format}` : '');
MC.getContext().dispatch({
getContext().dispatch({
kind: 'load-mvs',
format: story.format,
url: story.url,
@@ -58,7 +56,7 @@ function init() {
history.replaceState({}, '', story ? `?story=${story.id}` : '');
const s = Stories.find(s => s.id === story.id);
if (s) {
MC.getContext().dispatch({
getContext().dispatch({
kind: 'load-mvs',
data: s.buildStory(),
});
@@ -85,14 +83,13 @@ function init() {
createRoot(document.getElementById('select-story')!).render(<SelectStoryUI subject={CurrentStory} />);
}
(window as any).mc = MC;
(window as any).downloadStory = () => {
if (CurrentStory.value?.kind !== 'built-in') return;
const id = CurrentStory.value.id;
const story = Stories.find(s => s.id === id);
if (!story) return;
const data = JSON.stringify(story.buildStory(), null, 2);
download(new Blob([data], { type: 'application/json' }), 'story.mvsj');
download(new Blob([data], { type: 'application/json' }), `${id}-story.mvsj`);
};
(window as any).initStories = init;
(window as any).CurrentStory = CurrentStory;

View File

@@ -1,10 +1,8 @@
# MolViewSpec Stories Example
This example illustrates:
This example illustrates using the `mvs-stories` app to tell molecular stories built with MolViewSpec.
- Using MolViewSpec to tell a story
- A proof of concept for separating Mol* into a ready-to-use web component library.
- Ability to load MVS states
See the [mvs-stories](../../apps/mvs-stories) app for more info about how to use this app separately.
### Usage
@@ -16,39 +14,7 @@ This example illustrates:
npm build
```
- Get `molstar.css` and `index.js` from `build/examples/mvs-stories` and include these to your HTML page
```html
<link rel="stylesheet" type="text/css" href="molstar.css" />
<script type="text/javascript" src="index.js"></script>
```
- Plate the components in your page wrapper in `<div>` elements to set up positioning:
```html
<div class="viewer">
<mc-viewer name="v1" />
</div>
<div class="snapshot">
<mc-snapshot-markdown viewer-name="v1" />
</div>
```
- Load MolViewSpec state:
```html
<script>
window.mc.getContext().dispatch({
kind: 'load-mvs',
format: 'mvsj',
url: 'https://path/to/file.mvsj',
// or provide data directly
// data: mvsJSON
});
</script>
```
See [index.html](./index.html) for a full example.
- See [index.html](./index.html) for example usage.
- For interactive development build (for production use `npm run build`) of the example that immediately reflects changes use:

View File

@@ -0,0 +1,360 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { MVSData_States } from '../../../extensions/mvs/mvs-data';
import { createMVSBuilder, Structure as MVSStructure, Root } from '../../../extensions/mvs/tree/mvs/mvs-builder';
import { MVSNodeParams } from '../../../extensions/mvs/tree/mvs/mvs-tree';
import { ColorT } from '../../../extensions/mvs/tree/mvs/param-types';
import { Mat4 } from '../../../mol-math/linear-algebra';
const Colors = {
'1cbs': '#4577B2' as ColorT,
'ligand-away': '#F3794C' as ColorT,
'ligand-docked': '#B9E3A0' as ColorT,
};
const Steps = [
{
header: 'Animation Demo',
key: 'intro',
description: `### Molecular Animation
A story showcasing MolViewSpec animation capabilities.
[\[**🔄 Replay Intro**\]](!play-transition)
[\[**⏵ Play Snapshots**\]](!play-snapshots)
[\[**⏹ Stop Animation**\]](!stop-animation)
[\[**➡️ Next Snapshot**\]](!next-snapshot)
`,
linger_duration_ms: 2000,
transition_duration_ms: 500,
state: (): Root => {
const builder = createMVSBuilder();
const _1cbs = structure(builder, '1cbs');
polymer(_1cbs, { color: Colors['1cbs'] });
const prims = _1cbs.primitives({
ref: 'prims',
label_opacity: 0,
});
prims.label({ text: 'Animation Demo', position: { label_asym_id: 'A' }, label_size: 10 });
const anim = builder.animation({
custom: {
molstar_trackball: {
name: 'rock',
params: { speed: 0.5 },
}
}
});
anim.interpolate({
kind: 'scalar',
ref: 'prims-opacity',
target_ref: 'prims',
start_ms: 500,
duration_ms: 500,
property: 'label_opacity',
end: 1,
});
anim.interpolate({
kind: 'scalar',
ref: 'prims-opacity',
target_ref: 'prims',
start_ms: 1500,
duration_ms: 500,
property: 'label_opacity',
start: 1,
end: 0.66,
});
// Uncomment this to make 2nd frame render much faster
// It will cause shader compilation to happen during the 1st snapshot
// const surface = poly.representation({
// type: 'surface',
// surface_type: 'gaussian',
// }).opacity({ opacity: 0 });
// _1cbs.component({ selector: 'ligand' })
// .representation({ type: 'ball_and_stick' })
// .opacity({ opacity: 0 });
// surface.clip({
// ref: 'clip',
// type: 'plane',
// point: [22.0, 15, 0],
// normal: [0, 0, 1],
// });
return builder;
},
camera: {
position: [-11.49, -37.05, 15.78],
target: [15.85, 17.26, 24.32],
up: [-0.88, 0.4, 0.26],
} satisfies MVSNodeParams<'camera'>,
},
{
header: 'Ligand Docking',
description: `Animate ligand moving to the binding site`,
linger_duration_ms: 2500,
transition_duration_ms: 500,
state: (): Root => {
const builder = createMVSBuilder();
const _1cbs = structure(builder, '1cbs');
const [poly,] = polymer(_1cbs, { color: Colors['1cbs'] });
const surface = poly.representation({
type: 'surface',
surface_type: 'gaussian',
});
_1cbs.component({ selector: 'ligand' })
.transform({
ref: 'xform',
translation: [5, 20, -20],
rotation: [1, 0, 0, 0, 1, 0, 0, 0, 1],
rotation_center: 'centroid',
})
.representation({ type: 'ball_and_stick' })
.color({ ref: 'ligand-color', color: 'red' });
surface.clip({
ref: 'clip',
type: 'plane',
point: [22.0, 15, 0],
normal: [0, 0, 1],
});
const anim = builder.animation();
anim.interpolate({
kind: 'scalar',
target_ref: 'clip',
duration_ms: 500,
property: ['point', 2],
end: 55,
easing: 'sin-in',
});
anim.interpolate({
kind: 'scalar',
target_ref: 'clip',
start_ms: 600,
duration_ms: 800,
property: ['point', 2],
end: 0,
easing: 'sin-out',
});
anim.interpolate({
kind: 'scalar',
target_ref: 'clip',
start_ms: 1500,
duration_ms: 500,
property: ['point', 2],
end: 55,
});
anim.interpolate({
kind: 'vec3',
target_ref: 'xform',
duration_ms: 2000,
property: 'translation',
end: [0, 0, 0],
noise_magnitude: 1,
});
anim.interpolate({
kind: 'rotation_matrix',
target_ref: 'xform',
duration_ms: 2000,
property: 'rotation',
noise_magnitude: 0.2,
});
anim.interpolate({
kind: 'color',
target_ref: 'ligand-color',
duration_ms: 2000,
property: 'color',
end: Colors['ligand-docked'],
});
return builder;
},
camera: {
position: [-30.63, 77.29, 2.28],
target: [19.16, 26.15, 22.82],
up: [0.69, 0.71, 0.09],
} satisfies MVSNodeParams<'camera'>,
},
{
header: 'Highlight & Opacity',
description: `Animate emissive, opacity and transform properties`,
linger_duration_ms: 2000,
transition_duration_ms: 0,
state: (): Root => {
const builder = createMVSBuilder();
const _1cbs = structure(builder, '1cbs');
const [poly,] = polymer(_1cbs, { color: Colors['1cbs'] });
poly.representation({
type: 'surface',
surface_type: 'gaussian'
}).opacity({ ref: 'opacity', opacity: 1 }).color({ ref: 'surface-color', color: 'white' });
_1cbs.component({ selector: 'ligand' })
.transform({ ref: 'xform', translation: [0, 0, 0] })
.representation({
ref: 'repr',
type: 'ball_and_stick',
custom: {
molstar_representation_params: {
emissive: 0,
}
}
})
.color({ color: Colors['ligand-docked'] });
const primitives = builder.primitives({
ref: 'primitives',
instances: [
Mat4.identity()
],
opacity: 0,
});
primitives.ellipsoid({
center: [0, 0, 0],
radius: [2, 3, 2.5],
color: 'red'
});
const anim = builder.animation();
anim.interpolate({
kind: 'scalar',
target_ref: 'repr',
duration_ms: 1000,
property: ['custom', 'molstar_representation_params', 'emissive'],
end: 0.2,
});
anim.interpolate({
kind: 'scalar',
target_ref: 'opacity',
duration_ms: 1000,
frequency: 2,
alternate_direction: true,
property: 'opacity',
end: 0,
});
anim.interpolate({
kind: 'transform_matrix',
target_ref: 'primitives',
property: ['instances', 0],
translation_start: [20.24, 29.64, 14.85],
translation_end: [21.84, 21.71, 27.04],
translation_frequency: 4,
pivot: [0, 0, 0],
rotation_noise_magnitude: 0.2,
scale_end: [0.01, 0.01, 0.01],
duration_ms: 1000,
});
anim.interpolate({
kind: 'scalar',
target_ref: 'primitives',
duration_ms: 1000,
property: 'opacity',
end: 1,
});
anim.interpolate({
kind: 'color',
target_ref: 'surface-color',
duration_ms: 2000,
property: 'color',
palette: {
kind: 'continuous',
colors: ['white', Colors['1cbs'], 'white'],
}
});
return builder;
},
camera: {
position: [6.92, 47.17, 10.68],
target: [21.79, 22.2, 23.43],
up: [0.8, 0.57, 0.2],
} satisfies MVSNodeParams<'camera'>,
}
];
function structure(builder: Root, id: string): MVSStructure {
return builder
.download({ url: pdbUrl(id) })
.parse({ format: 'bcif' })
.modelStructure();
}
function polymer(structure: MVSStructure, options: { color: ColorT }) {
const component = structure.component({ selector: { label_asym_id: 'A' } });
const reprensentation = component.representation({ type: 'cartoon' });
reprensentation.color({ color: options.color });
return [component, reprensentation] as const;
}
function pdbUrl(id: string) {
return `https://www.ebi.ac.uk/pdbe/entry-files/download/${id.toLowerCase()}.bcif`;
}
export function buildStory(): MVSData_States {
const snapshots = Steps.map((s, i) => {
const builder = s.state();
if (s.camera) builder.camera(s.camera);
builder.canvas({
custom: {
molstar_postprocessing: {
enable_outline: true,
enable_ssao: true,
}
}
});
const description = i > 0 ? `${s.description}\n\n[Go to start](#intro)` : s.description;
return builder.getSnapshot({
title: s.header,
key: s.key,
description,
description_format: 'markdown',
linger_duration_ms: s.linger_duration_ms ?? 500,
transition_duration_ms: s.transition_duration_ms ?? 1000,
});
});
return {
kind: 'multiple',
snapshots,
metadata: {
title: 'Animation Showcase',
version: '1.0',
timestamp: new Date().toISOString(),
}
};
}

File diff suppressed because one or more lines are too long

View File

@@ -6,8 +6,14 @@
import { buildStory as kinase } from './kinase';
import { buildStory as tbp } from './tbp';
import { buildStory as animation } from './animation';
import { buildStory as audio } from './audio';
import { buildStory as motm1 } from './motm1';
export const Stories = [
{ id: 'kinase', name: 'BCR-ABL: A Kinase Out of Control', buildStory: kinase },
{ id: 'tata', name: 'TATA-Binding Protein and its Role in Transcription Initiation ', buildStory: tbp },
{ id: 'motm1', name: 'RCSB Molecule of the Month #1', buildStory: motm1 },
{ id: 'animation-example', name: 'Molecular Animation Example', buildStory: animation },
{ id: 'audio-example', name: 'Audio Playback Example', buildStory: audio },
] as const;

File diff suppressed because it is too large Load Diff

View File

@@ -62,7 +62,6 @@ export interface CubeGrid {
export type CubeGridFormat = ModelFormat<CubeGrid>;
// eslint-disable-next-line
export function CubeGridFormat(grid: CubeGrid): CubeGridFormat {
return { name: 'custom grid', kind: 'cube-grid', data: grid };
}

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2020-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
@@ -18,7 +18,7 @@ import { StateTransformer } from '../../mol-state';
import { VolumeRepresentation3DHelpers } from '../../mol-plugin-state/transforms/representation';
import { AlphaOrbital, Basis, CubeGrid, CubeGridFormat, isCubeGridData } from './data-model';
import { createSphericalCollocationDensityGrid } from './density';
import { Tensor } from '../../mol-math/linear-algebra';
import { Mat4, Tensor } from '../../mol-math/linear-algebra';
import { Theme } from '../../mol-theme/theme';
export class BasisAndOrbitals extends PluginStateObject.Create<{ basis: Basis, order: SphericalBasisOrder, orbitals: AlphaOrbital[] }>({ name: 'Basis', typeClass: 'Object' }) { }
@@ -114,6 +114,7 @@ export const CreateOrbitalVolume = PluginStateTransform.BuiltIn({
}, a.data.orbitals[params.index], plugin.canvas3d?.webgl).runInContext(ctx);
const volume: Volume = {
grid: data.grid,
instances: [{ transform: Mat4.identity() }],
sourceData: CubeGridFormat(data),
customProperties: new CustomProperties(),
_propertyData: Object.create(null),
@@ -146,6 +147,7 @@ export const CreateOrbitalDensityVolume = PluginStateTransform.BuiltIn({
}, a.data.orbitals, plugin.canvas3d?.webgl).runInContext(ctx);
const volume: Volume = {
grid: data.grid,
instances: [{ transform: Mat4.identity() }],
sourceData: CubeGridFormat(data),
customProperties: new CustomProperties(),
_propertyData: Object.create(null),

View File

@@ -1,12 +1,12 @@
/**
* Copyright (c) 2018-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2018-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
import { ParamDefinition as PD } from '../../mol-util/param-definition';
import { Structure, Model, StructureSelection, QueryContext } from '../../mol-model/structure';
import { Database as _Database, Column } from '../../mol-data/db';
import { Column } from '../../mol-data/db';
import { GraphQLClient } from '../../mol-util/graphql-client';
import { CustomProperty } from '../../mol-model-props/common/custom-property';
import { CustomStructureProperty } from '../../mol-model-props/common/custom-structure-property';
@@ -74,11 +74,8 @@ export namespace AssemblySymmetryData {
export const DefaultServerUrl = 'https://data.rcsb.org/graphql'; // Alternative: 'https://www.ebi.ac.uk/pdbe/aggregated-api/pdb/symmetry' (if serverType is 'pdbe')
export function isApplicable(structure?: Structure): boolean {
return (
!!structure && structure.models.length === 1 &&
Model.hasPdbId(structure.models[0]) &&
isBiologicalAssembly(structure)
);
if (!structure || structure.models.length !== 1 || !Model.hasPdbId(structure.models[0])) return false;
return isBiologicalAssembly(structure) || Model.isIntegrative(structure.models[0]);
}
export async function fetch(ctx: CustomProperty.Context, structure: Structure, props: AssemblySymmetryDataProps): Promise<CustomProperty.Data<AssemblySymmetryDataValue>> {
@@ -91,7 +88,7 @@ export namespace AssemblySymmetryData {
export async function fetchRCSB(ctx: CustomProperty.Context, structure: Structure, props: AssemblySymmetryDataProps): Promise<CustomProperty.Data<AssemblySymmetryDataValue>> {
const client = new GraphQLClient(props.serverUrl, ctx.assetManager);
const variables = {
assembly_id: structure.units[0].conformation.operator.assembly?.id || '',
assembly_id: structure.units[0].conformation.operator.assembly?.id || 'deposited', // data.rcsb.org guarantees assembly 'deposited' to be present for IHM
entry_id: structure.units[0].model.entryId
};
const result = await client.request(ctx.runtime, rcsb_symmetry_gql, variables);

View File

@@ -47,11 +47,12 @@ export const ConfalPyramidsPreset = StructureRepresentationPresetProvider({
}
});
const RemoveNewline = /\r?\n/g;
export function confalPyramidLabel(step: DnatcoTypes.Step) {
return `
<b>${step.auth_asym_id_1}</b> |
<b>${step.label_comp_id_1} ${step.auth_seq_id_1}${step.PDB_ins_code_1}${step.label_alt_id_1.length > 0 ? ` (alt ${step.label_alt_id_1})` : ''}
${step.label_comp_id_2} ${step.auth_seq_id_2}${step.PDB_ins_code_2}${step.label_alt_id_2.length > 0 ? ` (alt ${step.label_alt_id_2})` : ''} </b><br />
<i>NtC:</i> ${step.NtC} | <i>Confal score:</i> ${step.confal_score} | <i>RMSD:</i> ${step.rmsd.toFixed(2)}
`;
`.replace(RemoveNewline, '');
}

View File

@@ -47,11 +47,12 @@ export const NtCTubePreset = StructureRepresentationPresetProvider({
}
});
const RemoveNewline = /\r?\n/g;
export function NtCTubeSegmentLabel(step: DnatcoTypes.Step) {
return `
<b>${step.auth_asym_id_1}</b> |
<b>${step.label_comp_id_1} ${step.auth_seq_id_1}${step.PDB_ins_code_1}${step.label_alt_id_1.length > 0 ? ` (alt ${step.label_alt_id_1})` : ''}
${step.label_comp_id_2} ${step.auth_seq_id_2}${step.PDB_ins_code_2}${step.label_alt_id_2.length > 0 ? ` (alt ${step.label_alt_id_2})` : ''} </b><br />
<i>NtC:</i> ${step.NtC} | <i>Confal score:</i> ${step.confal_score} | <i>RMSD:</i> ${step.rmsd.toFixed(2)}
`;
`.replace(RemoveNewline, '');
}

View File

@@ -25,7 +25,6 @@ export type InteractionElementSchema =
| { kind: 'weak-hydrogen-bond' } & InteractionElementSchemaBase
| { kind: 'hydrophobic' } & InteractionElementSchemaBase
| { kind: 'metal-coordination' } & InteractionElementSchemaBase
| { kind: 'salt-bridge' } & InteractionElementSchemaBase
| { kind: 'covalent', degree?: 'aromatic' | 1 | 2 | 3 | 4 } & InteractionElementSchemaBase
export type InteractionKind = InteractionElementSchema['kind']
@@ -40,7 +39,6 @@ export const InteractionKinds: InteractionKind[] = [
'weak-hydrogen-bond',
'hydrophobic',
'metal-coordination',
'salt-bridge',
'covalent',
];
@@ -54,7 +52,6 @@ export type InteractionInfo =
| { kind: 'weak-hydrogen-bond', hydrogenStructureRef?: string, hydrogen?: StructureElement.Loci }
| { kind: 'hydrophobic' }
| { kind: 'metal-coordination' }
| { kind: 'salt-bridge' }
| { kind: 'covalent', degree?: 'aromatic' | 1 | 2 | 3 | 4 }
export interface StructureInteractionElement {

View File

@@ -47,7 +47,6 @@ export const InteractionVisualParams = {
'weak-hydrogen-bond': hydrogenVisualParams({ color: Color(0x0) }),
'hydrophobic': visualParams({ color: Color(0x555555) }),
'metal-coordination': visualParams({ color: Color(0x952e8f) }),
'salt-bridge': visualParams({ color: Color(0xF54029) }),
'covalent': PD.Group({
color: PD.Color(Color(0x999999)),
radius: PD.Numeric(0.1, { min: 0.01, max: 1, step: 0.01 }),

View File

@@ -0,0 +1,148 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { molfileToJSONCif } from '../utils';
import { CifFile } from '../../../mol-io/reader/cif';
import { trajectoryFromMmCIF } from '../../../mol-model-formats/structure/mmcif';
import { Task } from '../../../mol-task';
import { JSONCifLigandGraph } from '../ligand-graph';
import { parseJSONCif } from '../parser';
import { JSONCifDataBlock } from '../model';
describe('json-cif', () => {
it('roundtrips', async () => {
const { structure, jsoncif: file } = await molfileToJSONCif(MolString);
expect(file.dataBlocks.length).toBe(1);
expect(file.dataBlocks[0].categoryNames.length).toBe(2);
expect(file.dataBlocks[0].categoryNames[0]).toBe('atom_site');
expect(file.dataBlocks[0].categories['atom_site'].rows.length).toBe(structure.elementCount);
const parsed = parseJSONCif(file);
const parsedModel = await parseCifModel(parsed);
expect(parsedModel.atomicHierarchy.atoms._rowCount).toBe(structure.elementCount);
});
it('ligand graph', async () => {
const { structure, jsoncif: file } = await molfileToJSONCif(MolString);
// remove atom
let graph = new JSONCifLigandGraph(file.dataBlocks[0]);
graph.removeAtom(graph.atoms[0]);
let data = graph.getData().block;
expect(data.categories.atom_site.rows.length).toBe(structure.elementCount - 1);
// modify atom
graph = new JSONCifLigandGraph(file.dataBlocks[0]);
expect(file.dataBlocks[0].categories.atom_site.rows[0].type_symbol !== 'N').toBe(true);
graph.modifyAtom(1, { type_symbol: 'N' });
data = graph.getData().block;
expect(data.categories.atom_site.rows[0].type_symbol).toBe('N');
// add atom and bond
graph = new JSONCifLigandGraph(file.dataBlocks[0]);
const newAtom = graph.addAtom({ type_symbol: 'C', Cartn_x: 0, Cartn_y: 0, Cartn_z: 0 });
graph.addOrUpdateBond(graph.atoms[0], newAtom, { value_order: 'sing', type_id: 'covale' });
data = graph.getData().block;
expect(data.categories.atom_site.rows.length).toBe(structure.elementCount + 1);
expect(data.categories.molstar_bond_site.rows.length).toBe(file.dataBlocks[0].categories.molstar_bond_site.rows.length + 1);
// remove bond
graph.removeBond(graph.atoms[0], newAtom);
data = graph.getData().block;
expect(data.categories.atom_site.rows.length).toBe(structure.elementCount + 1);
expect(data.categories.molstar_bond_site.rows.length).toBe(file.dataBlocks[0].categories.molstar_bond_site.rows.length);
});
it('ligand graph traversal', () => {
const data: JSONCifDataBlock = {
header: 'test',
categoryNames: ['atom_site', 'molstar_bond_site'],
categories: {
atom_site: {
name: 'atom_site',
fieldNames: ['id', 'type_symbol'],
rows: [
{ id: 1, type_symbol: 'C', Cartn_x: 0, Cartn_y: 0, Cartn_z: 0 },
{ id: 2, type_symbol: 'C', Cartn_x: 1, Cartn_y: 0, Cartn_z: 0 },
{ id: 3, type_symbol: 'C', Cartn_x: 2, Cartn_y: 0, Cartn_z: 0 },
{ id: 4, type_symbol: 'C', Cartn_x: 2, Cartn_y: 0, Cartn_z: 0 },
],
},
molstar_bond_site: {
name: 'molstar_bond_site',
fieldNames: ['atom_id_1', 'atom_id_2'],
rows: [
{ atom_id_1: 1, atom_id_2: 4 },
{ atom_id_1: 1, atom_id_2: 2 },
{ atom_id_1: 2, atom_id_2: 3 },
],
},
},
};
const graph = new JSONCifLigandGraph(data);
const bfs = graph.traverse(1, 'bfs', [] as number[], (a, s) => s.push(a.row.id!));
expect(bfs).toEqual([1, 4, 2, 3]);
const dfs = graph.traverse(1, 'dfs', [] as number[], (a, s) => s.push(a.row.id!));
expect(dfs).toEqual([1, 2, 3, 4]);
});
});
async function parseCifModel(file: CifFile) {
const models = await trajectoryFromMmCIF(file.blocks[0], file).run();
const model = await Task.resolveInContext(models.getFrameAtIndex(0));
return model;
}
const MolString = `2244
-OEChem-04072009073D
21 21 0 0 0 0 0 0 0999 V2000
1.2333 0.5540 0.7792 O 0 0 0 0 0 0 0 0 0 0 0 0
-0.6952 -2.7148 -0.7502 O 0 0 0 0 0 0 0 0 0 0 0 0
0.7958 -2.1843 0.8685 O 0 0 0 0 0 0 0 0 0 0 0 0
1.7813 0.8105 -1.4821 O 0 0 0 0 0 0 0 0 0 0 0 0
-0.0857 0.6088 0.4403 C 0 0 0 0 0 0 0 0 0 0 0 0
-0.7927 -0.5515 0.1244 C 0 0 0 0 0 0 0 0 0 0 0 0
-0.7288 1.8464 0.4133 C 0 0 0 0 0 0 0 0 0 0 0 0
-2.1426 -0.4741 -0.2184 C 0 0 0 0 0 0 0 0 0 0 0 0
-2.0787 1.9238 0.0706 C 0 0 0 0 0 0 0 0 0 0 0 0
-2.7855 0.7636 -0.2453 C 0 0 0 0 0 0 0 0 0 0 0 0
-0.1409 -1.8536 0.1477 C 0 0 0 0 0 0 0 0 0 0 0 0
2.1094 0.6715 -0.3113 C 0 0 0 0 0 0 0 0 0 0 0 0
3.5305 0.5996 0.1635 C 0 0 0 0 0 0 0 0 0 0 0 0
-0.1851 2.7545 0.6593 H 0 0 0 0 0 0 0 0 0 0 0 0
-2.7247 -1.3605 -0.4564 H 0 0 0 0 0 0 0 0 0 0 0 0
-2.5797 2.8872 0.0506 H 0 0 0 0 0 0 0 0 0 0 0 0
-3.8374 0.8238 -0.5090 H 0 0 0 0 0 0 0 0 0 0 0 0
3.7290 1.4184 0.8593 H 0 0 0 0 0 0 0 0 0 0 0 0
4.2045 0.6969 -0.6924 H 0 0 0 0 0 0 0 0 0 0 0 0
3.7105 -0.3659 0.6426 H 0 0 0 0 0 0 0 0 0 0 0 0
-0.2555 -3.5916 -0.7337 H 0 0 0 0 0 0 0 0 0 0 0 0
1 5 1 0 0 0 0
1 12 1 0 0 0 0
2 11 1 0 0 0 0
2 21 1 0 0 0 0
3 11 2 0 0 0 0
4 12 2 0 0 0 0
5 6 1 0 0 0 0
5 7 2 0 0 0 0
6 8 2 0 0 0 0
6 11 1 0 0 0 0
7 9 1 0 0 0 0
7 14 1 0 0 0 0
8 10 1 0 0 0 0
8 15 1 0 0 0 0
9 10 2 0 0 0 0
9 16 1 0 0 0 0
10 17 1 0 0 0 0
12 13 1 0 0 0 0
13 18 1 0 0 0 0
13 19 1 0 0 0 0
13 20 1 0 0 0 0
M END`;

View File

@@ -0,0 +1,114 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { Column } from '../../mol-data/db';
import { Category, Encoder } from '../../mol-io/writer/cif/encoder';
import { BinaryEncodingProvider } from '../../mol-io/writer/cif/encoder/binary';
import { getCategoryInstanceData, getIncludedFields } from '../../mol-io/writer/cif/encoder/util';
import { Writer } from '../../mol-io/writer/writer';
import { JSONCifCategory, JSONCifDataBlock, JSONCifFile, JSONCifVERSION } from './model';
export class JSONCifEncoder implements Encoder<string> {
private data: JSONCifFile;
private dataBlocks: JSONCifDataBlock[] = [];
private encodedData: string | undefined;
private filter: Category.Filter = Category.DefaultFilter;
readonly isBinary = false;
readonly binaryEncodingProvider: BinaryEncodingProvider | undefined;
setFilter(filter?: Category.Filter) {
this.filter = filter || Category.DefaultFilter;
}
isCategoryIncluded(name: string) {
return this.filter.includeCategory(name);
}
setFormatter(formatter?: Category.Formatter) {
// No formatter needed for JSON encoding.
}
startDataBlock(header: string) {
this.dataBlocks.push({
header: (header || '').replace(/[ \n\t]/g, '').toUpperCase(),
categoryNames: [],
categories: {}
});
}
writeCategory<Ctx>(category: Category<Ctx>, context?: Ctx, options?: Encoder.WriteCategoryOptions) {
if (this.encodedData) {
throw new Error('The writer contents have already been encoded, no more writing.');
}
if (!this.dataBlocks.length) {
throw new Error('No data block created.');
}
if (!options?.ignoreFilter && !this.filter.includeCategory(category.name)) return;
const { instance, rowCount, source } = getCategoryInstanceData(category, context);
if (!rowCount) return;
const fields = getIncludedFields(instance);
if (!fields.length) return;
const rows: Record<string, any>[] = [];
const cat: JSONCifCategory = { name: category.name, fieldNames: fields.map(f => f.name), rows };
for (const src of source) {
const d = src.data;
const keys = src.keys();
while (keys.hasNext) {
const row: Record<string, any> = {};
const k = keys.move();
for (const f of fields) {
const kind = f.valueKind ? f.valueKind(k, d) : Column.ValueKinds.Present;
if (kind === Column.ValueKinds.Present) {
row[f.name] = f.value(k, d, rows.length);
} else if (kind === Column.ValueKinds.NotPresent) {
row[f.name] = null;
}
}
cat.rows.push(row);
}
}
this.dataBlocks[this.dataBlocks.length - 1].categoryNames.push(cat.name);
this.dataBlocks[this.dataBlocks.length - 1].categories[cat.name] = cat;
}
encode() {
if (this.encodedData) return;
this.encodedData = this.options?.formatJSON ? JSON.stringify(this.data, null, 2) : JSON.stringify(this.data);
}
writeTo(writer: Writer) {
writer.writeString(this.encodedData!);
}
getData() {
this.encode();
return this.encodedData!;
}
getSize() {
return this.encodedData?.length ?? 0;
}
getFile() {
return this.data;
}
constructor(encoder: string, public options?: { formatJSON?: boolean }) {
this.data = {
encoder,
version: JSONCifVERSION,
dataBlocks: this.dataBlocks
};
}
}

View File

@@ -0,0 +1,307 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { Table } from '../../mol-data/db';
import { mmCIF_Schema } from '../../mol-io/reader/cif/schema/mmcif';
import { Mat4, Vec3 } from '../../mol-math/linear-algebra';
import { MolstarBondSiteTypeId, MolstarBondSiteValueOrder } from '../../mol-model/structure/export/categories/molstar_bond_site';
import { UUID } from '../../mol-util';
import { arrayMapAdd } from '../../mol-util/map';
import { JSONCifDataBlock } from './model';
type Atom = Partial<Table.Row<mmCIF_Schema['atom_site']>>
export interface JSONCifLigandGraphBondProps {
value_order: MolstarBondSiteValueOrder | undefined;
type_id: MolstarBondSiteTypeId | undefined;
}
export interface JSONCifLigandGraphAtom {
key: string;
final_id: number | undefined;
row: Atom;
}
export interface JSONCifLigandGraphBond {
atom_1: JSONCifLigandGraphAtom;
atom_2: JSONCifLigandGraphAtom;
props: JSONCifLigandGraphBondProps;
}
export interface JSONCifLigandGraphData {
block: JSONCifDataBlock;
atomIdRemapping: Map<number, number>;
addedAtomIds: number[];
removedAtomIds: number[];
}
const _State = {
p1: Vec3(),
p2: Vec3(),
};
export class JSONCifLigandGraph {
readonly atoms: JSONCifLigandGraphAtom[] = [];
readonly atomsByKey: Map<string, JSONCifLigandGraphAtom> = new Map();
readonly atomsById: Map<number, JSONCifLigandGraphAtom> = new Map();
/** Bond with the provided key is always atom_1 */
readonly bondByKey: Map<string, JSONCifLigandGraphBond[]> = new Map();
readonly removedAtomIds: Set<number> = new Set();
getAtomAtIndex(index: number) {
return this.atoms[index];
}
getAtom(atomOrId: number | JSONCifLigandGraphAtom) {
return typeof atomOrId === 'number' ? this.atomsById.get(atomOrId) : atomOrId;
}
getBonds(atomOrId: number | JSONCifLigandGraphAtom) {
const atom = this.getAtom(atomOrId);
if (!atom) return [];
return this.bondByKey.get(atom.key) ?? [];
}
getAtomCoords(atomOrId: number | JSONCifLigandGraphAtom, out: Vec3 = Vec3()) {
const atom = this.getAtom(atomOrId);
if (!atom) return out;
const { Cartn_x, Cartn_y, Cartn_z } = atom.row;
return Vec3.set(out, Cartn_x!, Cartn_y!, Cartn_z!);
}
getBondDirection(bond: JSONCifLigandGraphBond, out: Vec3 = Vec3()) {
const a1 = this.getAtomCoords(bond.atom_1, _State.p1);
const a2 = this.getAtomCoords(bond.atom_2, _State.p2);
const dir = Vec3.sub(out, a2, a1);
return dir;
}
modifyAtom(atomOrId: number | JSONCifLigandGraphAtom, data: Omit<Atom, 'id'>) {
const atom = this.getAtom(atomOrId);
if (!atom) return;
atom.row = { ...atom.row, ...data, id: atom.row.id };
}
addAtom(data: Omit<Atom, 'id'>) {
const atom: JSONCifLigandGraphAtom = {
key: UUID.create22(),
final_id: undefined,
row: { ...data, id: undefined },
};
this.atomsByKey.set(atom.key, atom);
this.atoms.push(atom);
return atom;
}
removeAtom(atomOrId: number | JSONCifLigandGraphAtom) {
const atom = this.getAtom(atomOrId);
if (!atom) return;
if (typeof atom.row.id === 'number') {
this.removedAtomIds.add(atom.row.id);
this.atomsById.delete(atom.row.id);
}
this.atoms.splice(this.atoms.indexOf(atom), 1);
this.atomsByKey.delete(atom.key);
const bonds = this.bondByKey.get(atom.key);
if (!bonds) return;
this.bondByKey.delete(atom.key);
for (const b of bonds) {
const bBonds = this.bondByKey.get(b.atom_2.key);
if (!bBonds) continue;
this.bondByKey.set(b.atom_2.key, bBonds.filter(bb => bb.atom_2 !== atom));
}
}
addOrUpdateBond(atom1: number | JSONCifLigandGraphAtom, atom2: number | JSONCifLigandGraphAtom, props: JSONCifLigandGraphBondProps) {
const a1 = this.getAtom(atom1);
const a2 = this.getAtom(atom2);
if (!a1 || !a2) return;
const ps = { ...props };
this.removeBond(atom1, atom2);
arrayMapAdd(this.bondByKey, a1.key, { atom_1: a1, atom_2: a2, props: ps });
arrayMapAdd(this.bondByKey, a2.key, { atom_1: a2, atom_2: a1, props: ps });
}
removeBond(atom1: number | JSONCifLigandGraphAtom, atom2: number | JSONCifLigandGraphAtom) {
const a1 = this.getAtom(atom1);
const a2 = this.getAtom(atom2);
if (!a1 || !a2) return;
const a1Bonds = this.bondByKey.get(a1.key);
if (a1Bonds) {
this.bondByKey.set(a1.key, a1Bonds.filter(b => b.atom_2 !== a2));
}
const a2Bonds = this.bondByKey.get(a2.key);
if (a2Bonds) {
this.bondByKey.set(a2.key, a2Bonds.filter(b => b.atom_2 !== a1));
}
}
private transformAtomCoords(xform: Mat4, atomOrId: number | JSONCifLigandGraphAtom) {
const atom = this.getAtom(atomOrId);
if (!atom) return;
const p = this.getAtomCoords(atom, _State.p1);
Vec3.transformMat4(p, p, xform);
atom.row.Cartn_x = p[0];
atom.row.Cartn_y = p[1];
atom.row.Cartn_z = p[2];
}
transformCoords(xform: Mat4, atoms?: (number | JSONCifLigandGraphAtom)[]) {
for (const a of atoms ?? this.atoms) {
this.transformAtomCoords(xform, a);
}
}
traverse<S>(
atomOrId: number | JSONCifLigandGraphAtom,
how: 'dfs' | 'bfs',
state: S,
visitAtom: (atom: JSONCifLigandGraphAtom, state: S, pred: JSONCifLigandGraphBond | undefined, graph: JSONCifLigandGraph) => void,
): S {
const start = this.getAtom(atomOrId);
if (!start) return state;
const visited = new Set<string>();
const pred = new Map<string, JSONCifLigandGraphBond>();
const queue: string[] = [start.key];
while (queue.length) {
const key = how === 'bfs' ? queue.shift()! : queue.pop()!;
if (visited.has(key)) continue;
const a = this.atomsByKey.get(key)!;
visited.add(a.key);
visitAtom(a, state, pred.get(key), this);
const bs = this.bondByKey.get(a.key);
if (!bs?.length) continue;
for (const b of bs) {
if (visited.has(b.atom_2.key)) continue;
queue.push(b.atom_2.key);
pred.set(b.atom_2.key, b);
}
}
return state;
}
getData(): JSONCifLigandGraphData {
const atomIdRemapping = new Map<number, number>();
const addedAtomIds: number[] = [];
const sortedAtoms = this.atoms.map((a, i) => [a, i] as const);
sortedAtoms.sort((a, b) => {
const x = a[0].row.type_symbol;
const y = b[0].row.type_symbol;
if (x === 'H' && y !== 'H') return 1;
if (x !== 'H' && y === 'H') return -1;
return a[1] - b[1];
});
const atoms: Atom[] = [];
for (let i = 0; i < sortedAtoms.length; ++i) {
const a = sortedAtoms[i][0];
const id = i + 1;
if (a.row.id === undefined) {
addedAtomIds.push(id);
} else {
atomIdRemapping.set(a.row.id!, id);
}
a.final_id = id;
atoms.push({ ...a.row, id });
}
const block: JSONCifDataBlock = {
...this.data,
categories: {
...this.data.categories,
atom_site: {
...this.data.categories['atom_site'],
rows: atoms,
},
}
};
const bonds: Record<string, any>[] = [];
for (const [a] of sortedAtoms) {
const xs = this.bondByKey.get(a.key);
if (!xs) continue;
for (const bb of xs) {
if (a.final_id! >= bb.atom_2.final_id!) continue;
bonds.push({
atom_id_1: a.final_id,
atom_id_2: bb.atom_2.final_id,
value_order: bb.props.value_order,
type_id: bb.props.type_id,
});
}
}
bonds.sort((a, b) => {
if (a.atom_id_1 !== b.atom_id_1) return a.atom_id_1 - b.atom_id_1;
if (a.atom_id_2 !== b.atom_id_2) return a.atom_id_2 - b.atom_id_2;
return 0;
});
if (block.categories.molstar_bond_site) {
block.categories['molstar_bond_site'] = {
...block.categories['molstar_bond_site'],
rows: bonds
};
}
return {
block,
atomIdRemapping,
addedAtomIds,
removedAtomIds: Array.from(this.removedAtomIds).sort((a, b) => a - b),
};
}
constructor(private data: JSONCifDataBlock) {
for (const row of data.categories['atom_site'].rows) {
const atom: JSONCifLigandGraphAtom = {
key: UUID.create22(),
final_id: row.final_id,
row: { ...row },
};
this.atoms.push(atom);
this.atomsByKey.set(atom.key, atom);
this.atomsById.set(row.id, atom);
}
if (!data.categories.molstar_bond_site) return;
for (const row of data.categories.molstar_bond_site.rows) {
const atom_1 = this.atomsById.get(row.atom_id_1);
const atom_2 = this.atomsById.get(row.atom_id_2);
if (!atom_1 || !atom_2) continue;
arrayMapAdd(this.bondByKey, atom_1.key, {
atom_1: atom_1,
atom_2: atom_2,
props: {
value_order: row.value_order,
type_id: row.type_id,
},
});
arrayMapAdd(this.bondByKey, atom_2.key, {
atom_1: atom_2,
atom_2: atom_1,
props: {
value_order: row.value_order,
type_id: row.type_id,
}
});
}
}
}

View File

@@ -0,0 +1,31 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { Table } from '../../mol-data/db';
export const JSONCifVERSION = '0.1.0';
export interface JSONCifFile {
version: string;
encoder: string;
dataBlocks: JSONCifDataBlock[];
}
export interface JSONCifDataBlock {
header: string,
categoryNames: string[],
categories: Record<string, JSONCifCategory>,
}
export interface JSONCifCategory<T extends Record<string, any> = Record<string, any>> {
name: string,
fieldNames: string[],
rows: T[],
}
export function getJSONCifCategory<S extends Table.Schema>(block: JSONCifDataBlock, name: string): JSONCifCategory<Table.Row<S>> | undefined {
return block.categories[name] as any;
}

View File

@@ -0,0 +1,94 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { Column, ColumnHelpers } from '../../mol-data/db';
import { CifBlock, CifCategory, CifField, CifFile } from '../../mol-io/reader/cif';
import { ReaderResult } from '../../mol-io/reader/result';
import { Task } from '../../mol-task';
import { JSONCifCategory, JSONCifFile } from './model';
function Field(rows: Record<string, any>[], name: string): CifField {
const str: CifField['str'] = row => {
const v = rows[row][name];
if (v === null || v === undefined) return '';
if (typeof v === 'string') return v;
return '' + v;
};
const number: CifField['int'] = row => +rows[row][name];
const valueKind: CifField['valueKind'] = row => {
const v = rows[row][name];
if (v === null) return Column.ValueKinds.NotPresent;
if (v === undefined) return Column.ValueKinds.Unknown;
return Column.ValueKinds.Present;
};
const rowCount = rows.length;
return {
__array: undefined,
binaryEncoding: undefined,
isDefined: true,
rowCount,
str,
int: number,
float: number,
valueKind,
areValuesEqual: (rowA, rowB) => rows[rowA][name] === rows[rowB][name],
toStringArray: params => ColumnHelpers.createAndFillArray(rowCount, str, params),
toIntArray: params => ColumnHelpers.createAndFillArray(rowCount, number, params),
toFloatArray: params => ColumnHelpers.createAndFillArray(rowCount, number, params),
};
}
function Category(data: JSONCifCategory): CifCategory {
const nameSet = new Set(data.fieldNames);
const cache: Record<string, CifField> = Object.create(null);
return {
rowCount: data.rows.length,
name: data.name,
fieldNames: data.fieldNames,
getField(name) {
if (!nameSet.has(name)) return void 0;
if (!!cache[name]) return cache[name];
cache[name] = Field(data.rows, name);
return cache[name];
}
};
}
function checkVersions(min: number[], current: number[]) {
for (let i = 0; i < 2; i++) {
if (min[i] > current[i]) return false;
}
return true;
}
export function parseJSONCif(data: JSONCifFile) {
const minVersion = [0, 1];
if (!checkVersions(minVersion, data.version.match(/(\d)\.(\d)\.\d/)!.slice(1).map(v => +v))) {
throw new Error(`Unsupported format version. Current ${data.version}, required ${minVersion.join('.')}.`);
}
return CifFile(data.dataBlocks.map(block => {
const cats = Object.create(null);
for (const cat of block.categoryNames) cats[cat] = Category(block.categories[cat]);
return CifBlock(block.categoryNames, cats, block.header);
}));
}
export function parseJSONCifString(data: string) {
return Task.create<ReaderResult<CifFile>>('Parse BinaryCIF', async ctx => {
try {
const json = JSON.parse(data) as JSONCifFile;
const file = parseJSONCif(json);
return ReaderResult.success(file);
} catch (e) {
return ReaderResult.error<CifFile>('' + e);
}
});
}

View File

@@ -0,0 +1,5 @@
# JSON CIF Format Support
This extension introduced a JSON encoding of the CIF data format with the goal of making molecule editing more streamlined within the Mol\* ecosystem.
The extensions includes `JSONCifLigandGraph` that enables editing of molecular graphs with `atom_site` and `molstar_bond_site` categories.

View File

@@ -0,0 +1,30 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { PluginStateObject } from '../../mol-plugin-state/objects';
import { StateTransformer } from '../../mol-state';
import { Task } from '../../mol-task';
import { ParamDefinition } from '../../mol-util/param-definition';
import { JSONCifFile } from './model';
import { parseJSONCif } from './parser';
const Transform = StateTransformer.builderFactory('json-cif');
export const ParseJSONCifFileData = Transform({
name: 'parse-json-cif-data',
from: PluginStateObject.Root,
to: PluginStateObject.Format.Cif,
params: {
data: ParamDefinition.Value<JSONCifFile>(undefined as any, { isHidden: true }),
}
})({
apply({ params }) {
return Task.create('Parse JSON Cif', async ctx => {
const parsed = parseJSONCif(params.data);
return new PluginStateObject.Format.Cif(parsed, { label: 'CIF Data' });
});
}
});

View File

@@ -0,0 +1,34 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { parseMol } from '../../mol-io/reader/mol/parser';
import { trajectoryFromMol } from '../../mol-model-formats/structure/mol';
import { Structure, to_mmCIF } from '../../mol-model/structure';
import { Task } from '../../mol-task';
import { JSONCifEncoder } from './encoder';
export async function molfileToJSONCif(molfile: string) {
const parsed = await parseMol(molfile).run();
if (parsed.isError) throw new Error(parsed.message);
const models = await trajectoryFromMol(parsed.result).run();
const model = await Task.resolveInContext(models.getFrameAtIndex(0));
const structure = Structure.ofModel(model);
const encoder = new JSONCifEncoder('Mol*', { formatJSON: true });
to_mmCIF('mol', structure, false, {
encoder,
includedCategoryNames: new Set(['atom_site']),
extensions: {
molstar_bond_site: true,
}
});
return {
structure,
molfile: parsed.result,
jsoncif: encoder.getFile()
};
}

View File

@@ -12,9 +12,10 @@ import { LociLabelProvider } from '../../mol-plugin-state/manager/loci-label';
import { PluginBehavior } from '../../mol-plugin/behavior/behavior';
import { PluginContext } from '../../mol-plugin/context';
import { StructureRepresentationProvider } from '../../mol-repr/structure/representation';
import { StateAction } from '../../mol-state';
import { StateAction, StateObjectCell, StateTree } from '../../mol-state';
import { Task } from '../../mol-task';
import { ColorTheme } from '../../mol-theme/color';
import { fileToDataUri } from '../../mol-util/file';
import { ParamDefinition as PD } from '../../mol-util/param-definition';
import { MVSAnnotationColorThemeProvider } from './components/annotation-color-theme';
import { MVSAnnotationLabelRepresentationProvider } from './components/annotation-label/representation';
@@ -109,6 +110,39 @@ export const MolViewSpec = PluginBehavior.create<{ autoAttach: boolean }>({
for (const action of this.registrables.actions ?? []) {
this.ctx.state.data.actions.add(action);
}
this.ctx.managers.markdownExtensions.registerRefResolver('mvs', (plugin, refs) => {
const mvsRefs = new Set(refs.map(ref => `mvs-ref:${ref}`));
return StateTree.doPreOrder(
plugin.state.data.tree,
plugin.state.data.tree.root,
{ mvsRefs, plugin, cells: [] as StateObjectCell[] },
(n, _, s) => {
if (!n.tags) return;
for (const tag of n.tags) {
if (!s.mvsRefs.has(tag)) continue;
const cell = s.plugin.state.data.cells.get(n.ref);
if (cell) {
s.cells.push(cell);
break;
}
}
}).cells;
});
this.ctx.managers.markdownExtensions.registerUriResolver('mvs', (plugin, uri) => {
const { assets } = plugin.managers.asset;
const asset = assets.find(a => a.file.name === uri);
if (!asset) {
return undefined;
}
try {
return fileToDataUri(asset.file);
} catch (e) {
console.error(`MVS: Failed to convert asset file to data URI for '${uri}'`, e);
return undefined;
}
});
}
update(p: { autoAttach: boolean }) {
const updated = this.params.autoAttach !== p.autoAttach;
@@ -146,6 +180,7 @@ export const MolViewSpec = PluginBehavior.create<{ autoAttach: boolean }>({
for (const action of this.registrables.actions ?? []) {
this.ctx.state.data.actions.remove(action);
}
this.ctx.managers.markdownExtensions.removeRefResolver('mvs');
}
},
params: () => ({
@@ -173,7 +208,7 @@ const MVSDragAndDropHandler: DragAndDropHandler = {
const task = Task.create('Load MVSJ file', async ctx => {
const data = await file.text();
const mvsData = MVSData.fromMVSJ(data);
await loadMVS(plugin, mvsData, { sanityChecks: true, replaceExisting: !applied, sourceUrl: undefined });
await loadMVS(plugin, mvsData, { sanityChecks: true, appendSnapshots: applied, sourceUrl: undefined });
});
await plugin.runTask(task);
applied = true;
@@ -183,7 +218,7 @@ const MVSDragAndDropHandler: DragAndDropHandler = {
const buffer = await file.arrayBuffer();
const array = new Uint8Array(buffer);
const parsed = await loadMVSX(plugin, ctx, array);
await loadMVS(plugin, parsed.mvsData, { sanityChecks: true, replaceExisting: !applied, sourceUrl: parsed.sourceUrl });
await loadMVS(plugin, parsed.mvsData, { sanityChecks: true, appendSnapshots: applied, sourceUrl: parsed.sourceUrl });
});
await plugin.runTask(task);
applied = true;

View File

@@ -6,18 +6,27 @@
*/
import { Camera } from '../../mol-canvas3d/camera';
import { Canvas3DParams, Canvas3DProps } from '../../mol-canvas3d/canvas3d';
import { CameraFogParams, Canvas3DParams, Canvas3DProps, DefaultCanvas3DParams } from '../../mol-canvas3d/canvas3d';
import { TrackballControlsParams } from '../../mol-canvas3d/controls/trackball';
import { BloomParams } from '../../mol-canvas3d/passes/bloom';
import { DofParams } from '../../mol-canvas3d/passes/dof';
import { OutlineParams } from '../../mol-canvas3d/passes/outline';
import { ShadowParams } from '../../mol-canvas3d/passes/shadow';
import { SsaoParams } from '../../mol-canvas3d/passes/ssao';
import { Vec3 } from '../../mol-math/linear-algebra';
import { getFocusSnapshot, getPluginBoundingSphere } from '../../mol-plugin-state/manager/focus-camera/focus-object';
import { PluginCommands } from '../../mol-plugin/commands';
import { PluginContext } from '../../mol-plugin/context';
import { PluginState } from '../../mol-plugin/state';
import { StateObjectSelector } from '../../mol-state';
import { fovAdjustedPosition } from '../../mol-util/camera';
import { ColorNames } from '../../mol-util/color/names';
import { ParamDefinition } from '../../mol-util/param-definition';
import { decodeColor } from './helpers/utils';
import { MolstarLoadingContext } from './load';
import { SnapshotMetadata } from './mvs-data';
import { MolstarNodeParams } from './tree/molstar/molstar-tree';
import { MVSAnimationNode } from './tree/animation/animation-tree';
import { MolstarNode, MolstarNodeParams } from './tree/molstar/molstar-tree';
import { MVSTreeSchema } from './tree/mvs/mvs-tree';
@@ -98,24 +107,6 @@ function resetSceneRadiusFactor(plugin: PluginContext) {
plugin.canvas3d?.setProps({ sceneRadiusFactor });
}
/** Return the distance adjustment ratio for conversion from the "reference camera"
* to a camera with an arbitrary field of view `fov`. */
function distanceAdjustment(mode: Camera.Mode, fov: number) {
if (mode === 'orthographic') return 1 / (2 * Math.tan(fov / 2));
else return 1 / (2 * Math.sin(fov / 2));
}
/** Return the position for a camera with an arbitrary field of view `fov`
* necessary to just fit into view the same sphere (with center at `target`)
* as the "reference camera" placed at `refPosition` would fit, while keeping the camera orientation.
* The "reference camera" is a camera which can just fit into view a sphere of radius R with center at distance 2R
* (this corresponds to FOV = 2 * asin(1/2) in perspective mode or FOV = 2 * atan(1/2) in orthographic mode). */
function fovAdjustedPosition(target: Vec3, refPosition: Vec3, mode: Camera.Mode, fov: number) {
const delta = Vec3.sub(Vec3(), refPosition, target);
const adjustment = distanceAdjustment(mode, fov);
return Vec3.scaleAndAdd(delta, target, delta, adjustment); // return target + delta * adjustment
}
/** Create object for PluginState.Snapshot.camera based on tree loading context and MVS snapshot metadata */
export function createPluginStateSnapshotCamera(plugin: PluginContext, context: MolstarLoadingContext, metadata: SnapshotMetadata & { previousTransitionDurationMs?: number }): PluginState.Snapshot['camera'] {
const camera: PluginState.Snapshot['camera'] = {
@@ -132,19 +123,93 @@ export function createPluginStateSnapshotCamera(plugin: PluginContext, context:
return camera;
}
/** Set canvas properties based on a canvas node params. */
export function setCanvas(plugin: PluginContext, params: MolstarNodeParams<'canvas'> | undefined) {
plugin.canvas3d?.setProps(old => modifyCanvasProps(old, params));
function optionalParams(enable: boolean | undefined, values: any, params: ParamDefinition.Params, fallback: any) {
if (typeof enable === 'boolean') {
return enable
? { name: 'on', params: { ...ParamDefinition.getDefaultValues(params), ...values } }
: { name: 'off', params: {} };
}
return fallback;
}
/** Create a deep copy of `oldCanvasProps` with values modified according to a canvas node params. */
export function modifyCanvasProps(oldCanvasProps: Canvas3DProps, params: MolstarNodeParams<'canvas'> | undefined): Canvas3DProps {
export function modifyCanvasProps(oldCanvasProps: Canvas3DProps, canvasNode: MolstarNode<'canvas'> | undefined, animationNode: MVSAnimationNode<'animation'> | undefined): Canvas3DProps {
const params = canvasNode?.params;
const backgroundColor = decodeColor(params?.background_color) ?? DefaultCanvasBackgroundColor;
const molstar_postprocessing = canvasNode?.custom?.molstar_postprocessing;
const outline = molstar_postprocessing?.enable_outline;
const outlineParams = molstar_postprocessing?.outline_params;
const shadow = molstar_postprocessing?.enable_shadow;
const shadowParams = molstar_postprocessing?.shadow_params;
const occlusion = molstar_postprocessing?.enable_ssao;
const occlusionParams = molstar_postprocessing?.ssao_params;
const fog = molstar_postprocessing?.enable_fog;
const fogParams = molstar_postprocessing?.fog_params;
const dof = molstar_postprocessing?.enable_depth_of_field;
const dofParams = molstar_postprocessing?.depth_of_field_params;
const bloom = molstar_postprocessing?.enable_bloom;
const bloomParams = molstar_postprocessing?.bloom_params;
const trackballAnimation = animationNode?.custom?.molstar_trackball;
const trackballAnimationName = trackballAnimation?.name;
const trackballAnimationParams = trackballAnimation?.params ?? {};
return {
...oldCanvasProps,
postprocessing: {
...oldCanvasProps.postprocessing,
outline: optionalParams(outline, outlineParams, OutlineParams, oldCanvasProps.postprocessing.outline),
shadow: optionalParams(shadow, shadowParams, ShadowParams, oldCanvasProps.postprocessing.shadow),
occlusion: optionalParams(occlusion, occlusionParams, SsaoParams, oldCanvasProps.postprocessing.occlusion),
dof: optionalParams(dof, dofParams, DofParams, oldCanvasProps.postprocessing.dof),
bloom: optionalParams(bloom, bloomParams, BloomParams, oldCanvasProps.postprocessing.bloom),
},
cameraFog: optionalParams(fog, fogParams, CameraFogParams, oldCanvasProps.cameraFog),
renderer: {
...oldCanvasProps.renderer,
backgroundColor: backgroundColor,
},
trackball: {
...oldCanvasProps?.trackball,
...(trackballAnimationName
? {
animate: {
name: trackballAnimationName,
params: {
...TrackballControlsParams.animate.map(trackballAnimationName)?.defaultValue,
...trackballAnimationParams
}
}
}
: {}
),
}
};
}
export function resetCanvasProps(plugin: PluginContext) {
const old = plugin.canvas3d?.props;
plugin.canvas3d?.setProps({
...old,
postprocessing: {
...old,
outline: DefaultCanvas3DParams.postprocessing.outline,
shadow: DefaultCanvas3DParams.postprocessing.shadow,
occlusion: DefaultCanvas3DParams.postprocessing.occlusion,
dof: DefaultCanvas3DParams.postprocessing.dof,
bloom: DefaultCanvas3DParams.postprocessing.bloom,
},
cameraFog: DefaultCanvas3DParams.cameraFog,
trackball: {
...old?.trackball,
animate: { name: 'off', params: {} },
}
});
}

View File

@@ -1,32 +1,85 @@
/**
* Copyright (c) 2023-2024 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2023-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Adam Midlik <midlik@gmail.com>
*/
import { SortedArray } from '../../../mol-data/int';
import { Location } from '../../../mol-model/location';
import { Bond, StructureElement } from '../../../mol-model/structure';
import type { ColorTheme, LocationColor } from '../../../mol-theme/color';
import type { ThemeDataContext } from '../../../mol-theme/theme';
import { Color } from '../../../mol-util/color';
import { ColorNames } from '../../../mol-util/color/names';
import { ParamDefinition as PD } from '../../../mol-util/param-definition';
import { MaybeFloatParamDefinition } from '../helpers/param-definition';
import { decodeColor } from '../helpers/utils';
import { getMVSAnnotationForStructure } from './annotation-prop';
import { getMVSAnnotationForStructure, MVSAnnotation } from './annotation-prop';
import { isMVSStructure } from './is-mvs-model-prop';
export const MVSCategoricalPaletteParams = {
colors: PD.MappedStatic('list', {
list: PD.ColorList('category-10', { description: 'List of colors.', presetKind: 'set' }),
dictionary: PD.ObjectList({
value: PD.Text(),
color: PD.Color(ColorNames.white),
}, e => `${e.value}: ${Color.toHexStyle(e.color)}`, { description: 'Mapping of annotation values to colors.' }),
}),
repeatColorList: PD.Boolean(false, { hideIf: g => g.colors.name !== 'list', description: 'Repeat color list once all colors are depleted (only applies if `colors` is a list).' }),
sort: PD.Select('none', [['none', 'None'], ['lexical', 'Lexical'], ['numeric', 'Numeric']] as const, { hideIf: g => g.colors.name !== 'list', description: 'Sort actual annotation values before assigning colors from a list (none = take values in order of their first occurrence).' }),
sortDirection: PD.Select('ascending', [['ascending', 'Ascending'], ['descending', 'Descending']] as const, { hideIf: g => g.colors.name !== 'list', description: 'Sort direction.' }),
caseInsensitive: PD.Boolean(false, { description: 'Treat annotation values as case-insensitive strings.' }),
setMissingColor: PD.Boolean(false, { description: 'Allow setting a color for missing values.' }),
missingColor: PD.Color(ColorNames.white, { hideIf: g => !g.setMissingColor, description: 'Color to use when (a) `colors` is a dictionary and given key is not present, or (b) `color` is a list and there are more actual annotation values than listed colors and `repeat_color_list` is not true.' }),
};
export type MVSCategoricalPaletteParams = typeof MVSCategoricalPaletteParams
export type MVSCategoricalPaletteProps = PD.Values<MVSCategoricalPaletteParams>
export const MVSDiscretePaletteParams = {
colors: PD.ObjectList({
color: PD.Color(ColorNames.white),
fromValue: PD.Numeric(-Infinity),
toValue: PD.Numeric(Infinity),
}, e => `${Color.toHexStyle(e.color)} [${e.fromValue}, ${e.toValue}]`, { description: 'Mapping of annotation value ranges to colors.' }),
mode: PD.Select('normalized', [['normalized', 'Normalized'], ['absolute', 'Absolute']] as const, { description: 'Defines whether the annotation values should be normalized before assigning color based on checkpoints in `colors` (`x_normalized = (x - x_min) / (x_max - x_min)`, where `[x_min, x_max]` are either `value_domain` if provided, or the lowest and the highest value encountered in the annotation).' }),
xMin: MaybeFloatParamDefinition({ hideIf: g => g.mode !== 'normalized', placeholder: 'auto', description: 'Defines `x_min` for normalization of annotation values. If not provided, minimum of the actual values will be used. Only used when `mode` is `"normalized"' }),
xMax: MaybeFloatParamDefinition({ hideIf: g => g.mode !== 'normalized', placeholder: 'auto', description: 'Defines `x_max` for normalization of annotation values. If not provided, maximum of the actual values will be used. Only used when `mode` is `"normalized"' }),
};
export type MVSDiscretePaletteParams = typeof MVSDiscretePaletteParams
export type MVSDiscretePaletteProps = PD.Values<MVSDiscretePaletteParams>
export const MVSContinuousPaletteParams = {
colors: PD.ColorList('yellow-green', { description: 'List of colors, with optional checkpoints.', presetKind: 'scale', offsets: true }),
mode: PD.Select('normalized', [['normalized', 'Normalized'], ['absolute', 'Absolute']] as const, { description: 'Defines whether the annotation values should be normalized before assigning color based on checkpoints in `colors` (`x_normalized = (x - x_min) / (x_max - x_min)`, where `[x_min, x_max]` are either `value_domain` if provided, or the lowest and the highest value encountered in the annotation).' }),
xMin: MaybeFloatParamDefinition({ hideIf: g => g.mode !== 'normalized', placeholder: 'auto', description: 'Defines `x_min` for normalization of annotation values. If not provided, minimum of the actual values will be used. Only used when `mode` is `"normalized"' }),
xMax: MaybeFloatParamDefinition({ hideIf: g => g.mode !== 'normalized', placeholder: 'auto', description: 'Defines `x_max` for normalization of annotation values. If not provided, maximum of the actual values will be used. Only used when `mode` is `"normalized"' }),
setUnderflowColor: PD.Boolean(false, { description: 'Allow setting a color for values below the lowest checkpoint.' }),
underflowColor: PD.Color(ColorNames.white, { hideIf: g => !g.setUnderflowColor, description: 'Color for values below the lowest checkpoint.' }),
setOverflowColor: PD.Boolean(false, { description: 'Allow setting a color for values above the highest checkpoint.' }),
overflowColor: PD.Color(ColorNames.white, { hideIf: g => !g.setOverflowColor, description: 'Color for values above the highest checkpoint.' }),
};
export type MVSContinuousPaletteParams = typeof MVSContinuousPaletteParams
export type MVSContinuousPaletteProps = PD.Values<MVSContinuousPaletteParams>
/** Parameter definition for color theme "MVS Annotation" */
export const MVSAnnotationColorThemeParams = {
annotationId: PD.Text('', { description: 'Reference to "Annotation" custom model property' }),
fieldName: PD.Text('color', { description: 'Annotation field (column) from which to take color values' }),
background: PD.Color(ColorNames.gainsboro, { description: 'Color for elements without annotation' }),
palette: PD.MappedStatic('direct', {
'direct': PD.EmptyGroup(),
'categorical': PD.Group(MVSCategoricalPaletteParams),
'discrete': PD.Group(MVSDiscretePaletteParams),
'continuous': PD.Group(MVSContinuousPaletteParams),
}),
};
export type MVSAnnotationColorThemeParams = typeof MVSAnnotationColorThemeParams
/** Parameter values for color theme "MVS Annotation" */
export type MVSAnnotationColorThemeProps = PD.Values<MVSAnnotationColorThemeParams>
/** Return color theme that assigns colors based on an annotation file.
* The annotation file itself is handled by a custom model property (`MVSAnnotationsProvider`),
* the color theme then just uses this property. */
@@ -36,9 +89,12 @@ export function MVSAnnotationColorTheme(ctx: ThemeDataContext, props: MVSAnnotat
if (ctx.structure && !ctx.structure.isEmpty) {
const { annotation } = getMVSAnnotationForStructure(ctx.structure, props.annotationId);
if (annotation) {
const paletteFunction = makePaletteFunction(props.palette, annotation, props.fieldName);
const colorForStructureElementLocation = (location: StructureElement.Location) => {
// if (annot.getAnnotationForLocation(location)?.color !== annot.getAnnotationForLocation_Reference(location)?.color) throw new Error('AssertionError');
return decodeColor(annotation?.getValueForLocation(location, props.fieldName)) ?? props.background;
const annotValue = annotation?.getValueForLocation(location, props.fieldName);
const color = annotValue !== undefined ? paletteFunction(annotValue) : undefined;
return color ?? props.background;
};
const auxLocation = StructureElement.Location.create(ctx.structure);
@@ -60,7 +116,7 @@ export function MVSAnnotationColorTheme(ctx: ThemeDataContext, props: MVSAnnotat
return {
factory: MVSAnnotationColorTheme,
granularity: 'group',
granularity: 'groupInstance',
preferSmoothing: true,
color: color,
props: props,
@@ -79,3 +135,124 @@ export const MVSAnnotationColorThemeProvider: ColorTheme.Provider<MVSAnnotationC
defaultValues: PD.getDefaultValues(MVSAnnotationColorThemeParams),
isApplicable: (ctx: ThemeDataContext) => !!ctx.structure && isMVSStructure(ctx.structure),
};
function makePaletteFunction(props: MVSAnnotationColorThemeProps['palette'], annotation: MVSAnnotation, fieldName: string): (value: string) => Color | undefined {
if (props.name === 'direct') return decodeColor;
if (props.name === 'categorical') return makePaletteFunctionCategorical(props.params, annotation, fieldName);
if (props.name === 'discrete') return makePaletteFunctionDiscrete(props.params as MVSDiscretePaletteProps, annotation, fieldName);
if (props.name === 'continuous') return makePaletteFunctionContinuous(props.params as MVSContinuousPaletteProps, annotation, fieldName);
throw new Error(`NotImplementedError: makePaletteFunction for ${(props as any).name}`);
}
function makePaletteFunctionCategorical(props: MVSCategoricalPaletteProps, annotation: MVSAnnotation, fieldName: string): (value: string) => Color | undefined {
const colorMap: { [value: string]: Color } = {};
if (props.colors.name === 'dictionary') {
for (const { value, color } of props.colors.params) {
const key = props.caseInsensitive ? value.toUpperCase() : value;
colorMap[key] = color;
}
} else if (props.colors.name === 'list') {
const values = annotation.getDistinctValuesInField(fieldName, props.caseInsensitive);
if (props.sort === 'lexical') values.sort();
else if (props.sort === 'numeric') values.sort((a, b) => Number.parseFloat(a) - Number.parseFloat(b));
if (props.sortDirection === 'descending') values.reverse();
const colorList = props.colors.params.colors.map(Color.fromColorListEntry);
let next = 0;
for (const value of values) {
colorMap[value] = colorList[next++];
if (next >= colorList.length && props.repeatColorList) next = 0; // else will get index-out-of-range and assign undefined
}
}
const missingColor = props.setMissingColor ? props.missingColor : undefined;
if (props.caseInsensitive) {
return (value: string) => colorMap[value.toUpperCase()] ?? missingColor;
} else {
return (value: string) => colorMap[value] ?? missingColor;
}
}
function makePaletteFunctionDiscrete(props: MVSDiscretePaletteProps, annotation: MVSAnnotation, fieldName: string): (value: string) => Color | undefined {
if (props.colors.length === 0) return () => undefined;
const scale = makeNumericPaletteScale(props, annotation, fieldName);
return (value: string) => {
const xAbs = parseFloat(value);
if (isNaN(xAbs)) return undefined;
const x = scale(xAbs);
for (let i = props.colors.length - 1; i >= 0; i--) {
const { color, fromValue, toValue } = props.colors[i];
if (fromValue <= x && x <= toValue) return color;
}
};
}
function makePaletteFunctionContinuous(props: MVSContinuousPaletteProps, annotation: MVSAnnotation, fieldName: string): (value: string) => Color | undefined {
const { colors, checkpoints } = makeContinuousPaletteCheckpoints(props);
if (colors.length === 0) return () => undefined;
const scale = makeNumericPaletteScale(props, annotation, fieldName);
const underflowColor = props.setUnderflowColor ? props.underflowColor : undefined;
const overflowColor = props.setOverflowColor ? props.overflowColor : undefined;
return (value: string) => {
const xAbs = parseFloat(value);
if (isNaN(xAbs)) return undefined;
const x = scale(xAbs);
const gteIdx = SortedArray.findPredecessorIndex(checkpoints, x); // Index of the first greater or equal checkpoint
if (gteIdx === 0) {
if (x === checkpoints[0]) return colors[0];
else return underflowColor;
}
if (gteIdx === checkpoints.length) {
return overflowColor;
}
const q = (x - checkpoints[gteIdx - 1]) / (checkpoints[gteIdx] - checkpoints[gteIdx - 1]);
return Color.interpolate(colors[gteIdx - 1], colors[gteIdx], q);
};
}
function makeNumericPaletteScale(props: MVSContinuousPaletteProps | MVSDiscretePaletteProps, annotation: MVSAnnotation, fieldName: string): (x: number) => number {
if (props.mode === 'normalized') {
// Mode normalized
let xMin = props.xMin;
let xMax = props.xMax;
if (xMin === null || xMax === null) {
const values = annotation.getDistinctValuesInField(fieldName, false).map(parseFloat).filter(x => !isNaN(x));
if (values.length > 0) {
xMin ??= values.reduce((a, b) => a < b ? a : b); // xMin ??= min(values)
xMax ??= values.reduce((a, b) => a > b ? a : b); // xMax ??= max(values)
} else {
xMin ??= 0;
xMax ??= 1;
}
}
if (xMin === xMax) {
return x => (x < xMin ? -0.5 : x === xMin ? 0.5 : 1.5);
} else {
return x => (x - xMin) / (xMax - xMin);
}
} else {
// Mode absolute
return x => x;
}
}
export function makeContinuousPaletteCheckpoints(props: MVSContinuousPaletteProps) {
if (props.colors.colors.every(x => Array.isArray(x))) {
// Explicit checkpoints
const sorted = props.colors.colors.sort((a, b) => a[1] - b[1]);
const colors = sorted.map(Color.fromColorListEntry);
const checkpoints = SortedArray.ofSortedArray(sorted.map(t => t[1]));
return { colors, checkpoints };
} else {
// Auto checkpoints (linspace 0 to 1)
const colors = props.colors.colors.map(Color.fromColorListEntry);
const n = colors.length - 1;
const checkpoints = SortedArray.ofSortedArray(colors.map((_, i) => i / n));
return { colors, checkpoints };
}
}

View File

@@ -1,10 +1,10 @@
/**
* Copyright (c) 2023-2024 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2023-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Adam Midlik <midlik@gmail.com>
*/
import { Column, Table } from '../../../mol-data/db';
import { Column } from '../../../mol-data/db';
import { CIF, CifBlock, CifCategory, CifFile } from '../../../mol-io/reader/cif';
import { toTable } from '../../../mol-io/reader/cif/schema';
import { MmcifFormat } from '../../../mol-model-formats/structure/mmcif';
@@ -13,19 +13,17 @@ import { CustomProperty } from '../../../mol-model-props/common/custom-property'
import { CustomPropertyDescriptor } from '../../../mol-model/custom-property';
import { Model } from '../../../mol-model/structure';
import { Structure, StructureElement } from '../../../mol-model/structure/structure';
import { UUID } from '../../../mol-util';
import { arrayExtend } from '../../../mol-util/array';
import { Asset } from '../../../mol-util/assets';
import { Jsonable, canonicalJsonString } from '../../../mol-util/json';
import { pickObjectKeys, promiseAllObj } from '../../../mol-util/object';
import { objectOfArraysToArrayOfObjects, pickObjectKeysWithRemapping, promiseAllObj } from '../../../mol-util/object';
import { Choice } from '../../../mol-util/param-choice';
import { ParamDefinition as PD } from '../../../mol-util/param-definition';
import { AtomRanges } from '../helpers/atom-ranges';
import { IndicesAndSortings } from '../helpers/indexing';
import { MaybeStringParamDefinition } from '../helpers/param-definition';
import { MVSAnnotationRow, MVSAnnotationSchema, getCifAnnotationSchema } from '../helpers/schemas';
import { atomQualifies, getAtomRangesForRow } from '../helpers/selections';
import { Maybe, safePromise } from '../helpers/utils';
import { getAtomRangesForRow } from '../helpers/selections';
import { Maybe, isDefined, safePromise } from '../helpers/utils';
/** Allowed values for the annotation format parameter */
@@ -50,7 +48,11 @@ export const MVSAnnotationsParams = {
index: PD.Group({ index: PD.Numeric(0, { min: 0, step: 1 }, { description: '0-based index of the block' }) }),
header: PD.Group({ header: PD.Text(undefined, { description: 'Block header' }) }),
}, { description: 'Specify which CIF block contains annotation data (only relevant when format=cif or format=bcif)' }),
cifCategory: MaybeStringParamDefinition(undefined, { description: 'Specify which CIF category contains annotation data (only relevant when format=cif or format=bcif)' }),
cifCategory: MaybeStringParamDefinition({ placeholder: 'Take first category', description: 'Specify which CIF category contains annotation data (only relevant when format=cif or format=bcif)' }),
fieldRemapping: PD.ObjectList({
standardName: PD.Text('', { placeholder: ' ', description: 'Standard name of the selector field (e.g. label_asym_id)' }),
actualName: MaybeStringParamDefinition({ placeholder: 'Ignore field', description: 'Actual name of the field in the annotation data (e.g. spam_chain_id), null to ignore the field with standard name' }),
}, e => `"${e.standardName}": ${e.actualName === null ? 'null' : `"${e.actualName}"`}`, { description: 'Optional remapping of annotation field names { standardName1: actualName1, ... }. Use { "label_asym_id": "X" } to load actual field "X" as "label_asym_id". Use { "label_asym_id": null } to ignore actual field "label_asym_id". Fields not mentioned here are mapped implicitely (i.e. actual name = standard name).' }),
id: PD.Text('', { description: 'Arbitrary identifier that can be referenced by MVSAnnotationColorTheme' }),
},
obj => obj.id
@@ -137,16 +139,26 @@ export function getMVSAnnotationForStructure(structure: Structure, annotationId:
return { annotation: undefined, model: undefined };
}
type FieldRemapping = Record<string, string | null>;
/** Mapping `ElementIndex` -> annotation row index for all elements in a `Model`.
* `-1` means no row applies to the element.
* `null` means no row applies to any element. */
type IndexedModel = number[] | null;
/** Main class for processing MVS annotation */
export class MVSAnnotation {
/** Store mapping `ElementIndex` -> annotation row index for each `Model`, -1 means no row applies */
private indexedModels = new Map<UUID, number[]>();
private rows: MVSAnnotationRow[] | undefined = undefined;
/** Number of annotation rows. */
public nRows: number;
constructor(
public data: MVSAnnotationData,
public schema: MVSAnnotationSchema,
) { }
public fieldRemapping: FieldRemapping,
) {
this.nRows = getRowCount(data);
}
/** Create a new `MVSAnnotation` based on specification `spec`. Use `file` if provided, otherwise download the file.
* Throw error if download fails or problem with data. */
@@ -165,7 +177,7 @@ export class MVSAnnotation {
switch (blockSpec.name) {
case 'header':
const foundBlock = file.data.blocks.find(b => b.header === blockSpec.params.header);
if (!foundBlock) throw new Error(`CIF block with header ${blockSpec.params.header} not found`);
if (!foundBlock) throw new Error(`CIF block with header "${blockSpec.params.header}" not found`);
block = foundBlock;
break;
case 'index':
@@ -176,32 +188,21 @@ export class MVSAnnotation {
const categoryName = spec.cifCategory ?? Object.keys(block.categories)[0];
if (!categoryName) throw new Error('There are no categories in CIF block');
const category = block.categories[categoryName];
if (!category) throw new Error(`CIF category ${categoryName} not found`);
if (!category) throw new Error(`CIF category "${categoryName}" not found`);
data = { format: 'cif', data: category };
break;
}
return new MVSAnnotation(data, spec.schema);
return new MVSAnnotation(data, spec.schema, Object.fromEntries(spec.fieldRemapping.map(e => [e.standardName, e.actualName])));
}
static createEmpty(schema: MVSAnnotationSchema): MVSAnnotation {
return new MVSAnnotation({ format: 'json', data: [] }, schema);
}
/** Reference implementation of `getAnnotationForLocation`, just for checking, DO NOT USE DIRECTLY */
getAnnotationForLocation_Reference(loc: StructureElement.Location): MVSAnnotationRow | undefined {
const model = loc.unit.model;
const iAtom = loc.element;
let result: MVSAnnotationRow | undefined = undefined;
for (const row of this.getRows()) {
if (atomQualifies(model, iAtom, row)) result = row;
}
return result;
return new MVSAnnotation({ format: 'json', data: [] }, schema, {});
}
/** Return value of field `fieldName` assigned to location `loc`, if any */
getValueForLocation(loc: StructureElement.Location, fieldName: string): string | undefined {
const indexedModel = this.getIndexedModel(loc.unit.model);
const iRow = indexedModel[loc.element];
const indexedModel = this.getIndexedModel(loc.unit.model, loc.unit.conformation.operator.instanceId);
const iRow = (indexedModel !== null) ? indexedModel[loc.element] : -1;
return this.getValueForRow(iRow, fieldName);
}
/** Return value of field `fieldName` assigned to `i`-th annotation row, if any */
@@ -218,40 +219,69 @@ export class MVSAnnotation {
}
/** Return cached `ElementIndex` -> `MVSAnnotationRow` mapping for `Model` (or create it if not cached yet) */
private getIndexedModel(model: Model): number[] {
const key = model.id;
if (!this.indexedModels.has(key)) {
const result = this.getRowForEachAtom(model);
this.indexedModels.set(key, result);
private getIndexedModel(model: Model, instanceId: string): IndexedModel {
const key = this.hasInstanceIds() ? `${model.id}:${instanceId}` : model.id;
if (!this._indexedModels.has(key)) {
const result = this.getRowForEachAtom(model, instanceId);
this._indexedModels.set(key, result);
}
return this.indexedModels.get(key)!;
return this._indexedModels.get(key)!;
}
/** Cached `IndexedModel` per `Model.id` (if annotation contains no instanceIds)
* or per `Model.id:instanceId` combination (if at least one row contains instanceId). */
private _indexedModels = new Map<string, IndexedModel>();
/** Create `ElementIndex` -> `MVSAnnotationRow` mapping for `Model` */
private getRowForEachAtom(model: Model): number[] {
private getRowForEachAtom(model: Model, instanceId: string): IndexedModel {
const indices = IndicesAndSortings.get(model);
const nAtoms = model.atomicHierarchy.atoms._rowCount;
const result: number[] = Array(nAtoms).fill(-1);
let result: IndexedModel = null;
const rows = this.getRows();
for (let i = 0, nRows = rows.length; i < nRows; i++) {
const atomRanges = getAtomRangesForRow(model, rows[i], indices);
AtomRanges.foreach(atomRanges, (from, to) => result.fill(i, from, to));
const row = rows[i];
const atomRanges = getAtomRangesForRow(row, model, instanceId, indices);
if (AtomRanges.count(atomRanges) === 0) continue;
result ??= Array(nAtoms).fill(-1);
AtomRanges.foreach(atomRanges, (from, to) => result!.fill(i, from, to));
}
return result;
}
/** Parse and return all annotation rows in this annotation, or return cached result if available */
getRows(): readonly MVSAnnotationRow[] {
return this._rows ??= this._getRows();
}
/** Cached annotation rows. Do not use directly, use `getRows` instead. */
private _rows: MVSAnnotationRow[] | undefined = undefined;
/** Parse and return all annotation rows in this annotation */
private _getRows(): MVSAnnotationRow[] {
switch (this.data.format) {
case 'json':
return getRowsFromJson(this.data.data, this.schema);
return getRowsFromJson(this.data.data, this.schema, this.fieldRemapping);
case 'cif':
return getRowsFromCif(this.data.data, this.schema);
return getRowsFromCif(this.data.data, this.schema, this.fieldRemapping);
}
}
/** Parse and return all annotation rows in this annotation, or return cached result if available */
getRows(): readonly MVSAnnotationRow[] {
return this.rows ??= this._getRows();
/** Return `true` if some rows in the annotation contain `instance_id` field. */
private hasInstanceIds(): boolean {
return this._hasInstanceIds ??= this.getRows().some(row => isDefined(row.instance_id));
}
private _hasInstanceIds?: boolean = undefined;
/** Return list of all distinct values appearing in field `fieldName`, in order of first occurrence. Ignores special values `.` and `?`. If `caseInsensitive`, make all values uppercase. */
getDistinctValuesInField(fieldName: string, caseInsensitive: boolean): string[] {
const seen = new Set<string | undefined>();
const out = [];
for (let i = 0; i < this.nRows; i++) {
let value = this.getValueForRow(i, fieldName);
if (caseInsensitive) value = value?.toUpperCase();
if (value !== undefined && !seen.has(value)) {
seen.add(value);
out.push(value);
}
}
return out;
}
}
@@ -272,55 +302,80 @@ function getValueFromCif(rowIndex: number, fieldName: string, data: CifCategory)
return column.str(rowIndex);
}
function getRowsFromJson(data: Jsonable, schema: MVSAnnotationSchema): MVSAnnotationRow[] {
/** Return number of rows in this annotation (without parsing all the data) */
function getRowCount(data: MVSAnnotationData): number {
switch (data.format) {
case 'json':
return getRowCountFromJson(data.data);
case 'cif':
return getRowCountFromCif(data.data);
}
}
function getRowCountFromJson(data: Jsonable): number {
const js = data as any;
const cifSchema = getCifAnnotationSchema(schema);
if (Array.isArray(js)) {
// array of objects
return js.map(row => pickObjectKeys(row, Object.keys(cifSchema)));
return js.length;
} else {
// object of arrays
const rows: MVSAnnotationRow[] = [];
const keys = Object.keys(js).filter(key => Object.hasOwn(cifSchema, key as any));
const keys = Object.keys(js);
if (keys.length > 0) {
const n = js[keys[0]].length;
if (keys.some(key => js[key].length !== n)) throw new Error('FormatError: arrays must have the same length.');
for (let i = 0; i < n; i++) {
const item: { [key: string]: any } = {};
for (const key of keys) {
item[key] = js[key][i];
}
rows.push(item);
}
return js[keys[0]].length;
} else {
return 0;
}
return rows;
}
}
function getRowCountFromCif(data: CifCategory): number {
return data.rowCount;
}
function getRowsFromCif(data: CifCategory, schema: MVSAnnotationSchema): MVSAnnotationRow[] {
const rows: MVSAnnotationRow[] = [];
function getRowsFromJson(data: Jsonable, schema: MVSAnnotationSchema, fieldRemapping: FieldRemapping): MVSAnnotationRow[] {
const js = data as any;
const cifSchema = getCifAnnotationSchema(schema);
const table = toTable(cifSchema, data);
arrayExtend(rows, getRowsFromTable(table)); // Avoiding Table.getRows(table) as it replaces . and ? fields by 0 or ''
return rows;
const cifSchemaKeys = Object.keys(cifSchema);
if (Array.isArray(js)) {
// array of objects
return js.map(row => pickObjectKeysWithRemapping(row, cifSchemaKeys, fieldRemapping));
} else {
// object of arrays
const selectedFields: Record<string, any[]> = pickObjectKeysWithRemapping(js, cifSchemaKeys, fieldRemapping);
return objectOfArraysToArrayOfObjects(selectedFields);
}
}
/** Same as `Table.getRows` but omits `.` and `?` fields (instead of using type defaults) */
function getRowsFromTable<S extends Table.Schema>(table: Table<S>): Partial<Table.Row<S>>[] {
const rows: Partial<Table.Row<S>>[] = [];
const columns = table._columns;
const nRows = table._rowCount;
const Present = Column.ValueKind.Present;
for (let iRow = 0; iRow < nRows; iRow++) {
const row: Partial<Table.Row<S>> = {};
for (const col of columns) {
if (table[col].valueKind(iRow) === Present) {
row[col as keyof S] = table[col].value(iRow);
}
}
rows[iRow] = row;
function getRowsFromCif(data: CifCategory, schema: MVSAnnotationSchema, fieldRemapping: FieldRemapping): MVSAnnotationRow[] {
const cifSchema = getCifAnnotationSchema(schema);
const cifSchemaKeys = Object.keys(cifSchema) as (keyof typeof cifSchema)[];
const columns: Partial<Record<keyof typeof cifSchema, any[]>> = {};
for (const key of cifSchemaKeys) {
let srcKey = fieldRemapping[key];
if (srcKey === null) continue; // Ignore key
if (srcKey === undefined) srcKey = key; // Implicit key mapping
const columnArray = getArrayFromCifCategory(data, srcKey, cifSchema[key]); // Avoiding `column.toArray` as it replaces . and ? fields by 0 or ''
if (columnArray) columns[key] = columnArray;
}
return rows;
return objectOfArraysToArrayOfObjects(columns);
}
/** Load data from a specific column in a CIF category into an array. Load `.` and `?` as undefined. */
function getArrayFromCifCategory<T>(data: CifCategory, columnName: string, columnSchema: Column.Schema): (T | undefined)[] | undefined {
if (data.getField(columnName) === undefined) return undefined;
const table = toTable({ [columnName]: columnSchema }, data); // a bit dumb, I don't know how to make column directly
const column = table[columnName];
return getArrayFromCifColumn(column); // Avoiding `column.toArray` as it replaces . and ? fields by 0 or ''
}
/** Same as `column.toArray` but reads `.` and `?` as undefined (instead of using type defaults) */
function getArrayFromCifColumn<T>(column: Column<T>): (T | undefined)[] {
const nRows = column.rowCount;
const Present = Column.ValueKind.Present;
const out: (T | undefined)[] = new Array(nRows);
for (let iRow = 0; iRow < nRows; iRow++) {
out[iRow] = column.valueKind(iRow) === Present ? column.value(iRow) : undefined;
}
return out;
}
async function getFileFromSource(ctx: CustomProperty.Context, source: MVSAnnotationSource, model?: Model): Promise<MVSAnnotationFile> {

View File

@@ -75,7 +75,7 @@ function createLabelText(ctx: VisualContext, structure: Structure, theme: Theme,
break;
case 'selection':
const substructure = substructureFromSelector(structure, item.position.params.selector);
const p = textPropsForSelection(substructure, theme.size.size, {});
const p = textPropsForSelection(substructure, theme.size.size, [{}]);
const group = serialIndexOfSubstructure(structure, substructure) ?? 0;
if (p) builder.add(item.text, p.center[0], p.center[1], p.center[2], p.depth, p.scale, group);
break;

View File

@@ -1,10 +1,12 @@
/**
* Copyright (c) 2023-2024 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2023-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Adam Midlik <midlik@gmail.com>
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { hashFnv32a } from '../../../mol-data/util';
import { murmurHash3_128_fromBytes } from '../../../mol-data/util';
import { StringLike } from '../../../mol-io/common/string-like';
import { DataFormatProvider } from '../../../mol-plugin-state/formats/provider';
import { PluginStateObject as SO } from '../../../mol-plugin-state/objects';
import { Download } from '../../../mol-plugin-state/transforms/data';
@@ -14,7 +16,7 @@ import { RuntimeContext, Task } from '../../../mol-task';
import { Asset, AssetManager } from '../../../mol-util/assets';
import { ParamDefinition as PD } from '../../../mol-util/param-definition';
import { unzip } from '../../../mol-util/zip/zip';
import { loadMVS } from '../load';
import { loadMVS, MVSLoadOptions } from '../load';
import { MVSData } from '../mvs-data';
import { MVSTransform } from './annotation-structure-component';
@@ -30,7 +32,7 @@ export const ParseMVSJ = MVSTransform({
to: Mvs,
})({
apply({ a }, plugin: PluginContext) {
const mvsData = MVSData.fromMVSJ(a.data);
const mvsData = MVSData.fromMVSJ(StringLike.toString(a.data));
const sourceUrl = tryGetDownloadUrl(a, plugin);
return new Mvs({ mvsData, sourceUrl });
},
@@ -57,7 +59,7 @@ export const ParseMVSX = MVSTransform({
/** Params for the `LoadMvsData` action */
export const LoadMvsDataParams = {
replaceExisting: PD.Boolean(false, { description: 'If true, the loaded MVS view will replace the current state; if false, the MVS view will be added to the current state.' }),
appendSnapshots: PD.Boolean(false, { description: 'If true, add snapshots from MVS into current snapshot list; if false, replace the snapshot list.' }),
keepCamera: PD.Boolean(false, { description: 'If true, any camera positioning from the MVS state will be ignored and the current camera position will be kept.' }),
applyExtensions: PD.Boolean(true, { description: 'If true, apply builtin MVS-loading extensions (not a part of standard MVS specification).' }),
};
@@ -69,7 +71,7 @@ export const LoadMvsData = StateAction.build({
params: LoadMvsDataParams,
})(({ a, params }, plugin: PluginContext) => Task.create('Load MVS Data', async () => {
const { mvsData, sourceUrl } = a.data;
await loadMVS(plugin, mvsData, { replaceExisting: params.replaceExisting, keepCamera: params.keepCamera, sourceUrl: sourceUrl, extensions: params.applyExtensions ? undefined : [] });
await loadMVS(plugin, mvsData, { appendSnapshots: params.appendSnapshots, keepCamera: params.keepCamera, sourceUrl: sourceUrl, extensions: params.applyExtensions ? undefined : [] });
}));
@@ -111,7 +113,12 @@ export const MVSXFormatProvider: DataFormatProvider<{}, StateObjectRef<Mvs>, any
* and parse the main file in the archive as MVSJ.
* Return parsed MVS data and `sourceUrl` for resolution of relative URIs. */
export async function loadMVSX(plugin: PluginContext, runtimeCtx: RuntimeContext, data: Uint8Array, mainFilePath: string = 'index.mvsj'): Promise<{ mvsData: MVSData, sourceUrl: string }> {
const archiveId = `ni,fnv1a;${hashFnv32a(data)}`;
// Ensure at most one generation of MVSX file assets exists in the asset manager.
// Hopefully, this is a reasonable compromise to ensure MVSX files work in multi-snapshot
// states.
clearMVSXFileAssets(plugin);
const archiveId = `ni,MurmurHash3_128;${murmurHash3_128_fromBytes(data, 42)}`;
let files: { [path: string]: Uint8Array };
try {
files = await unzip(runtimeCtx, data) as typeof files;
@@ -121,7 +128,8 @@ export async function loadMVSX(plugin: PluginContext, runtimeCtx: RuntimeContext
}
for (const path in files) {
const url = arcpUri(archiveId, path);
ensureUrlAsset(plugin.managers.asset, url, files[path]);
// Need to use static assets so they persist accross snapsho
ensureUrlAsset(plugin.managers.asset, url, files[path], { isFile: true });
}
const mainFile = files[mainFilePath];
if (!mainFile) throw new Error(`File ${mainFilePath} not found in the MVSX archive`);
@@ -130,6 +138,42 @@ export async function loadMVSX(plugin: PluginContext, runtimeCtx: RuntimeContext
return { mvsData, sourceUrl };
}
export async function loadMVSData(plugin: PluginContext, data: MVSData | StringLike | Uint8Array, format: 'mvsj' | 'mvsx', options?: MVSLoadOptions) {
if (typeof data === 'string' && data.startsWith('base64')) {
data = Uint8Array.from(atob(data.substring(7)), c => c.charCodeAt(0)); // Decode base64 string to Uint8Array
}
if (format === 'mvsj') {
if ((data as Uint8Array).BYTES_PER_ELEMENT && (data as Uint8Array).buffer) {
data = new TextDecoder().decode(data as Uint8Array); // Decode Uint8Array to string using UTF8
}
let mvsData: MVSData;
if (typeof data === 'string') {
mvsData = MVSData.fromMVSJ(data);
} else {
mvsData = data as MVSData;
}
await loadMVS(plugin, mvsData, { sanityChecks: true, sourceUrl: undefined, ...options });
} else if (format === 'mvsx') {
if (typeof data === 'string') {
throw new Error("loadMvsData: if `format` is 'mvsx', then `data` must be a Uint8Array or a base64-encoded string prefixed with 'base64,'.");
}
await plugin.runTask(Task.create('Load MVSX file', async ctx => {
const parsed = await loadMVSX(plugin, ctx, data as Uint8Array);
await loadMVS(plugin, parsed.mvsData, { sanityChecks: true, ...options, sourceUrl: parsed.sourceUrl });
}));
} else {
throw new Error(`Unknown MolViewSpec format: ${format}`);
}
return data;
}
function clearMVSXFileAssets(plugin: PluginContext) {
plugin.managers.asset.clearTag('mvsx-file');
}
/** If the PluginStateObject `pso` comes from a Download transform, try to get its `url` parameter. */
function tryGetDownloadUrl(pso: SO.Data.String, plugin: PluginContext): string | undefined {
const theCell = plugin.state.data.selectQ(q => q.ofTransformer(Download)).find(cell => cell.obj === pso);
@@ -138,7 +182,7 @@ function tryGetDownloadUrl(pso: SO.Data.String, plugin: PluginContext): string |
}
/** Return a URI referencing a file within an archive, using ARCP scheme (https://arxiv.org/pdf/1809.06935.pdf).
* `archiveId` corresponds to the `authority` part of URI (e.g. 'uuid,EYVwjDiZhM20PWbF1OWWvQ' or 'ni,fnv1a;938511930')
* `archiveId` corresponds to the `authority` part of URI (e.g. 'uuid,EYVwjDiZhM20PWbF1OWWvQ' or 'ni,MurmurHash3_128;e6494f6be71f34c556f3de73d306780c')
* `path` corresponds to the path to a file within the archive */
function arcpUri(archiveId: string, path: string): string {
return new URL(path, `arcp://${archiveId}/`).href;
@@ -146,11 +190,13 @@ function arcpUri(archiveId: string, path: string): string {
/** Add a URL asset to asset manager.
* Skip if an asset with the same URL already exists. */
function ensureUrlAsset(manager: AssetManager, url: string, data: Uint8Array) {
function ensureUrlAsset(manager: AssetManager, url: string, data: Uint8Array, options?: { isFile?: boolean }) {
const asset = Asset.getUrlAsset(manager, url);
if (!manager.has(asset)) {
const filename = url.split('/').pop() ?? 'file';
manager.set(asset, new File([data], filename));
// We need to mark files as static resources to prevent deleting them
// when changing state snapshots.
manager.set(asset, new File([data], filename), options?.isFile ? { isStatic: true, tag: 'mvsx-file' } : undefined);
}
}

View File

@@ -4,17 +4,19 @@
* @author Adam Midlik <midlik@gmail.com>
*/
import { ColorTypeLocation } from '../../../mol-geo/geometry/color-data';
import { Location } from '../../../mol-model/location';
import { Bond, Structure, StructureElement } from '../../../mol-model/structure';
import { ColorTheme, LocationColor } from '../../../mol-theme/color';
import { ColorThemeCategory } from '../../../mol-theme/color/categories';
import { ThemeDataContext } from '../../../mol-theme/theme';
import { deepEqual } from '../../../mol-util';
import { Color } from '../../../mol-util/color';
import { ColorNames } from '../../../mol-util/color/names';
import { ParamDefinition as PD } from '../../../mol-util/param-definition';
import { stringToWords } from '../../../mol-util/string';
import { isMVSStructure } from './is-mvs-model-prop';
import { ElementSet, SelectorParams, isSelectorAll } from './selector';
import { ElementSet, SelectorParams, isSelectorAll, substructureFromSelector } from './selector';
/** Special value that can be used as color with null-like semantic (i.e. "no color provided").
@@ -70,32 +72,7 @@ export const DefaultMultilayerColorThemeProps: MultilayerColorThemeProps = { lay
* If a nested theme provider has `ensureCustomProperties` methods, these will not be called automatically
* (the caller must ensure that any required custom properties be attached). */
function makeMultilayerColorTheme(ctx: ThemeDataContext, props: MultilayerColorThemeProps, colorThemeRegistry: ColorTheme.Registry): ColorTheme<MultilayerColorThemeParams> {
const colorLayers: { color: LocationColor, elementSet: ElementSet | undefined }[] = []; // undefined elementSet means 'all'
for (let i = props.layers.length - 1; i >= 0; i--) { // iterate from end to get top layer first, bottom layer last
const layer = props.layers[i];
const themeProvider = colorThemeRegistry.get(layer.theme.name);
if (!themeProvider) {
console.warn(`Skipping color theme '${layer.theme.name}', cannot find it in registry.`);
continue;
}
if (themeProvider.ensureCustomProperties?.attach) {
console.warn(`Multilayer color theme: layer "${themeProvider.name}" has ensureCustomProperties.attach method, but Multilayer color theme does not call it. If the layer does not work, make sure you call ensureCustomProperties.attach somewhere.`);
}
const theme = themeProvider.factory(ctx, layer.theme.params);
switch (theme.granularity) {
case 'uniform':
case 'instance':
case 'group':
case 'groupInstance':
case 'vertex':
case 'vertexInstance':
const elementSet = isSelectorAll(layer.selection) ? undefined : ElementSet.fromSelector(ctx.structure, layer.selection); // treating 'all' specially for performance reasons (it's expected to be used most often)
colorLayers.push({ color: theme.color, elementSet });
break;
default:
console.warn(`Skipping color theme '${layer.theme.name}', cannot process granularity '${theme.granularity}'`);
}
};
const { colorLayers, granularity } = makeLayers(ctx, props, colorThemeRegistry);
function structureElementColor(loc: StructureElement.Location, isSecondary: boolean): Color {
for (const layer of colorLayers) {
@@ -123,7 +100,7 @@ function makeMultilayerColorTheme(ctx: ThemeDataContext, props: MultilayerColorT
return {
factory: (ctx_, props_) => makeMultilayerColorTheme(ctx_, props_, colorThemeRegistry),
granularity: 'group',
granularity,
preferSmoothing: true,
color: color,
props: props,
@@ -132,6 +109,117 @@ function makeMultilayerColorTheme(ctx: ThemeDataContext, props: MultilayerColorT
}
const GRAN_INSTANCE = 1, GRAN_GROUP = 2, GRAN_VERTEX = 4;
const granularityFlagsFromName = {
'uniform': 0,
'instance': GRAN_INSTANCE,
'group': GRAN_GROUP,
'groupInstance': GRAN_GROUP | GRAN_INSTANCE,
'vertex': GRAN_VERTEX,
'vertexInstance': GRAN_VERTEX | GRAN_INSTANCE,
} satisfies { [name in ColorTypeLocation]: number };
function granularityNameFromFlags(flags: number): ColorTypeLocation {
if (flags & GRAN_VERTEX) return flags & GRAN_INSTANCE ? 'vertexInstance' : 'vertex';
if (flags & GRAN_GROUP) return flags & GRAN_INSTANCE ? 'groupInstance' : 'group';
return flags & GRAN_INSTANCE ? 'instance' : 'uniform';
}
interface ColorLayer {
/** Substructure to which the layer is applied, undefined means 'all' */
elementSet: ElementSet | undefined,
/** Color theme for the layer */
color: LocationColor,
}
function makeLayers(ctx: ThemeDataContext, props: MultilayerColorThemeProps, colorThemeRegistry: ColorTheme.Registry) {
const colorLayers: ColorLayer[] = [];
let granularityFlags = 0;
for (let i = props.layers.length - 1; i >= 0; i--) { // iterate from the end to get top layer first, bottom layer last
const layer = props.layers[i];
const themeProvider = colorThemeRegistry.get(layer.theme.name);
if (!themeProvider) {
console.warn(`Skipping color theme '${layer.theme.name}', cannot find it in registry.`);
continue;
}
if (themeProvider.ensureCustomProperties?.attach) {
console.warn(`Multilayer color theme: layer "${themeProvider.name}" has ensureCustomProperties.attach method, but Multilayer color theme does not call it. If the layer does not work, make sure you call ensureCustomProperties.attach somewhere.`);
}
const theme = themeProvider.factory(ctx, layer.theme.params);
switch (theme.granularity) {
case 'uniform':
case 'instance':
case 'group':
case 'groupInstance':
case 'vertex':
case 'vertexInstance':
let elementSet: ElementSet | undefined;
let selectionGranularity: 'uniform' | 'instance' | 'group' | 'groupInstance';
if (!ctx.structure) {
elementSet = {};
selectionGranularity = 'uniform';
} else if (isSelectorAll(layer.selection)) {
// Treating 'all' specially for performance reasons (it's expected to be used most often)
elementSet = undefined;
selectionGranularity = 'uniform';
} else {
const substructure = substructureFromSelector(ctx.structure, layer.selection);
elementSet = ElementSet.fromStructure(substructure);
selectionGranularity = getSubstructureGranularity(ctx.structure, substructure);
}
colorLayers.push({ elementSet, color: theme.color });
granularityFlags |= granularityFlagsFromName[selectionGranularity];
granularityFlags |= granularityFlagsFromName[theme.granularity];
break;
default:
console.warn(`Skipping color theme '${layer.theme.name}', cannot process granularity '${theme.granularity}'`);
}
}
return { colorLayers, granularity: granularityNameFromFlags(granularityFlags) };
}
function getSubstructureGranularity(parent: Structure, substructure: Structure) {
const parentCounts: { [instance: string]: number } = {};
for (const unit of parent.units) {
const instance = unit.conformation.operator.instanceId;
parentCounts[instance] ??= 0;
parentCounts[instance] += unit.elements.length;
}
const childCounts: { [instance: string]: number } = {};
const elementsPerInstance: { [instance: string]: { [invariantId: number]: StructureElement.Set } } = {};
for (const unit of substructure.units) {
const instance = unit.conformation.operator.instanceId;
childCounts[instance] ??= 0;
childCounts[instance] += unit.elements.length;
(elementsPerInstance[instance] ??= {})[unit.invariantId] = unit.elements;
}
const parentInstances = Object.keys(parentCounts);
const childInstances = Object.keys(childCounts);
const groupGranularity = !childInstances.every(inst => childCounts[inst] === parentCounts[inst]);
let instanceGranularity: boolean;
if (childInstances.length === 0) {
instanceGranularity = false;
} else if (childInstances.length < parentInstances.length) {
instanceGranularity = true;
} else {
instanceGranularity = false;
for (let i = 1; i < childInstances.length; i++) {
if (!deepEqual(elementsPerInstance[childInstances[0]], elementsPerInstance[childInstances[i]])) {
instanceGranularity = true;
break;
}
}
}
if (groupGranularity) return instanceGranularity ? 'groupInstance' : 'group';
else return instanceGranularity ? 'instance' : 'uniform';
}
/** Unique name for "Multilayer" color theme */
export const MultilayerColorThemeName = 'mvs-multilayer';

View File

@@ -5,6 +5,7 @@
* @author Adam Midlik <midlik@gmail.com>
*/
import { BaseGeometry } from '../../../mol-geo/geometry/base';
import { Lines } from '../../../mol-geo/geometry/lines/lines';
import { LinesBuilder } from '../../../mol-geo/geometry/lines/lines-builder';
import { addFixedCountDashedCylinder, addSimpleCylinder, BasicCylinderProps } from '../../../mol-geo/geometry/mesh/builder/cylinder';
@@ -16,6 +17,7 @@ import { TextBuilder } from '../../../mol-geo/geometry/text/text-builder';
import { Box, BoxCage } from '../../../mol-geo/primitive/box';
import { Circle } from '../../../mol-geo/primitive/circle';
import { Primitive } from '../../../mol-geo/primitive/primitive';
import { StringLike } from '../../../mol-io/common/string-like';
import { Box3D, Sphere3D } from '../../../mol-math/geometry';
import { Mat4, Vec3 } from '../../../mol-math/linear-algebra';
import { radToDeg } from '../../../mol-math/misc';
@@ -24,19 +26,22 @@ import { Structure, StructureElement, StructureSelection } from '../../../mol-mo
import { StructureQueryHelper } from '../../../mol-plugin-state/helpers/structure-query';
import { PluginStateObject as SO } from '../../../mol-plugin-state/objects';
import { PluginContext } from '../../../mol-plugin/context';
import { ShapeRepresentation } from '../../../mol-repr/shape/representation';
import { Expression } from '../../../mol-script/language/expression';
import { StateObject } from '../../../mol-state';
import { StateObject, StateTransformer } from '../../../mol-state';
import { Task } from '../../../mol-task';
import { round } from '../../../mol-util';
import { range } from '../../../mol-util/array';
import { Asset } from '../../../mol-util/assets';
import { Color } from '../../../mol-util/color';
import { MarkerActions } from '../../../mol-util/marker-action';
import { ParamDefinition as PD } from '../../../mol-util/param-definition';
import { capitalize } from '../../../mol-util/string';
import { rowsToExpression, rowToExpression } from '../helpers/selections';
import { collectMVSReferences, decodeColor } from '../helpers/utils';
import { MolstarNode, MolstarSubtree } from '../tree/molstar/molstar-tree';
import { MVSNode } from '../tree/mvs/mvs-tree';
import { collectMVSReferences, decodeColor, isDefined } from '../helpers/utils';
import { addParamDefaults } from '../tree/generic/params-schema';
import { MolstarNode, MolstarNodeParams, MolstarSubtree } from '../tree/molstar/molstar-tree';
import { MVSNode, MVSTreeSchema } from '../tree/mvs/mvs-tree';
import { isComponentExpression, isPrimitiveComponentExpressions, isVector3, PrimitivePositionT } from '../tree/mvs/param-types';
import { MVSTransform } from './annotation-structure-component';
@@ -75,7 +80,7 @@ export const MVSDownloadPrimitiveData = MVSTransform({
return Task.create('Download Primitive Data', async ctx => {
const url = Asset.getUrlAsset(plugin.managers.asset, params.uri);
const asset = await plugin.managers.asset.resolve(url, 'string').runInContext(ctx);
const node = JSON.parse(asset.data) as MolstarSubtree<'primitives'>;
const node = JSON.parse(StringLike.toString(asset.data)) as MolstarSubtree<'primitives'>;
(cache as any).asset = asset;
return new MVSPrimitivesData({
node,
@@ -93,6 +98,16 @@ export const MVSDownloadPrimitiveData = MVSTransform({
},
});
/* Cannot use MolstarSubtree<'primitives'>> because information about type of children would be lost and cause TypeScript errors in dependent code */
interface PrimitivesSubtree {
kind: 'primitives',
params: MolstarNodeParams<'primitives'>,
children?: {
kind: 'primitive',
params: MolstarNodeParams<'primitive'>,
}[],
}
export type MVSInlinePrimitiveData = typeof MVSInlinePrimitiveData
export const MVSInlinePrimitiveData = MVSTransform({
name: 'mvs-inline-primitive-data',
@@ -100,7 +115,10 @@ export const MVSInlinePrimitiveData = MVSTransform({
from: [SO.Root, SO.Molecule.Structure],
to: MVSPrimitivesData,
params: {
node: PD.Value<MolstarSubtree<'primitives'>>(undefined as any, { isHidden: true }),
node: PD.Value<PrimitivesSubtree>({
kind: 'primitives',
params: addParamDefaults(MVSTreeSchema.nodes.primitives.params, {}),
}, { isHidden: true }),
},
})({
apply({ a, params }) {
@@ -130,34 +148,62 @@ export const MVSBuildPrimitiveShape = MVSTransform({
const structureRefs = dependencies ? collectMVSReferences([SO.Molecule.Structure], dependencies) : {};
const context: PrimitiveBuilderContext = { ...a.data, structureRefs };
const snapshotKey = { snapshotKey: { ...SnapshotKey, defaultValue: a.data.options?.snapshot_key ?? '' } };
const markdownCommands = { markdownCommands: { ...MarkdownCommands, defaultValue: a.data.node?.custom?.molstar_markdown_commands } };
const label = capitalize(params.kind);
if (params.kind === 'mesh') {
if (!hasPrimitiveKind(a.data, 'mesh')) return StateObject.Null;
const customMeshParams = a.data.node.custom?.molstar_mesh_params;
return new SO.Shape.Provider({
label,
data: context,
params: PD.withDefaults(Mesh.Params, { alpha: a.data.options?.opacity ?? 1 }),
params: {
...PD.withDefaults(Mesh.Params, { alpha: a.data.options?.opacity ?? 1, ...customMeshParams }),
...snapshotKey,
...markdownCommands,
},
getShape: (_, data, __, prev: any) => buildPrimitiveMesh(data, prev?.geometry),
geometryUtils: Mesh.Utils,
}, { label });
} else if (params.kind === 'labels') {
if (!hasPrimitiveKind(a.data, 'label')) return StateObject.Null;
const options = a.data.options;
const bgColor = options?.label_background_color;
const customLabelParams = a.data.node.custom?.molstar_label_params;
return new SO.Shape.Provider({
label,
data: context,
params: PD.withDefaults(DefaultLabelParams, { alpha: a.data.options?.label_opacity ?? 1 }),
getShape: (_, data, __, prev: any) => buildPrimitiveLabels(data, prev?.geometry),
params: {
...PD.withDefaults(DefaultLabelParams, {
alpha: a.data.options?.label_opacity ?? 1,
attachment: options?.label_attachment ?? 'middle-center',
tether: options?.label_show_tether ?? false,
tetherLength: options?.label_tether_length ?? 1,
background: isDefined(bgColor),
backgroundColor: isDefined(bgColor) ? decodeColor(bgColor) : undefined,
...customLabelParams,
}),
...snapshotKey,
...markdownCommands,
},
getShape: (_, data, props, prev: any) => buildPrimitiveLabels(data, prev?.geometry, props),
geometryUtils: Text.Utils,
}, { label });
} else if (params.kind === 'lines') {
if (!hasPrimitiveKind(a.data, 'line')) return StateObject.Null;
const customLineParams = a.data.node.custom?.molstar_line_params;
return new SO.Shape.Provider({
label,
data: context,
params: PD.withDefaults(Lines.Params, { alpha: a.data.options?.opacity ?? 1 }),
params: {
...PD.withDefaults(Lines.Params, { alpha: a.data.options?.opacity ?? 1, ...customLineParams }),
...snapshotKey,
...markdownCommands,
},
getShape: (_, data, __, prev: any) => buildPrimitiveLines(data, prev?.geometry),
geometryUtils: Lines.Utils,
}, { label });
@@ -167,6 +213,51 @@ export const MVSBuildPrimitiveShape = MVSTransform({
}
});
export const MVSShapeRepresentation3D = MVSTransform({
name: 'shape-representation-3d',
display: '3D Representation',
from: SO.Shape.Provider,
to: SO.Shape.Representation3D,
params: (a, ctx: PluginContext) => {
return a ? a.data.params : BaseGeometry.Params;
}
})({
canAutoUpdate() {
return true;
},
apply({ a, params }) {
return Task.create('Shape Representation', async ctx => {
const props = { ...PD.getDefaultValues(a.data.params), ...params };
const repr = ShapeRepresentation(a.data.getShape, a.data.geometryUtils);
await repr.createOrUpdate(props, a.data.data).runInContext(ctx);
const pickable = !!(params as any).snapshotKey?.trim() || !!(params as any).markdownCommands;
if (pickable) {
repr.setState({ pickable, markerActions: MarkerActions.Highlighting });
}
return new SO.Shape.Representation3D({ repr, sourceData: a.data }, { label: a.data.label });
});
},
update({ a, b, newParams }) {
return Task.create('Shape Representation', async ctx => {
const props = { ...b.data.repr.props, ...newParams };
await b.data.repr.createOrUpdate(props, a.data.data).runInContext(ctx);
b.data.sourceData = a.data;
const pickable = !!(newParams as any).snapshotKey?.trim() || !!(newParams as any).markdownCommands;
if (pickable) {
b.data.repr.setState({ pickable, markerActions: MarkerActions.Highlighting });
}
return StateTransformer.UpdateResult.Updated;
});
}
});
const SnapshotKey = PD.Text('', { isEssential: true, disableInteractiveUpdates: true, description: 'Activate the snapshot with the provided key when clicking on the label' });
const MarkdownCommands = PD.Value<any>(undefined, { isHidden: true });
/* **************************************************** */
class GroupManager {
@@ -215,8 +306,16 @@ interface PrimitiveBuilderContext {
structureRefs: Record<string, Structure | undefined>;
primitives: MolstarNode<'primitive'>[];
options: PrimitivesParams;
positionCache: Map<string, [Sphere3D, Box3D]>;
positionCache: Map<string, [isDefined: boolean, Sphere3D, Box3D]>;
instances: Mat4[] | undefined;
emptySelectionWarningPrinted?: boolean;
}
function printEmptySelectionWarning(ctx: PrimitiveBuilderContext, position: PrimitivePositionT): void {
if (!ctx.emptySelectionWarningPrinted) {
console.warn('Some primitives use positions which refer to empty substructure, not showing these primitives.', position, '(There may be more)');
ctx.emptySelectionWarningPrinted = true;
}
}
interface MeshBuilderState {
@@ -372,14 +471,20 @@ function hasPrimitiveKind(context: PrimitiveBuilderContext, kind: 'mesh' | 'line
return false;
}
function resolveBasePosition(context: PrimitiveBuilderContext, position: PrimitivePositionT, targetPosition: Vec3) {
/** Save resolved position into `targetPosition`.
* Return `true` if the resolved position is defined (i.e. vector or non-empty selection);
* return `false` if the resolved position is not defined (i.e. empty selection). */
function resolveBasePosition(context: PrimitiveBuilderContext, position: PrimitivePositionT, targetPosition: Vec3): boolean {
return resolvePosition(context, position, targetPosition, undefined, undefined);
}
const _EmptySphere = Sphere3D.zero();
const _EmptyBox = Box3D.zero();
function resolvePosition(context: PrimitiveBuilderContext, position: PrimitivePositionT, targetPosition: Vec3 | undefined, targetSphere: Sphere3D | undefined, targetBox: Box3D | undefined) {
/** Save resolved position into `targetPosition`, `targetSphere`, `targetBox`.
* Return `true` if the resolved position is defined (i.e. vector or non-empty selection);
* return `false` if the resolved position is not defined (i.e. empty selection). */
function resolvePosition(context: PrimitiveBuilderContext, position: PrimitivePositionT, targetPosition: Vec3 | undefined, targetSphere: Sphere3D | undefined, targetBox: Box3D | undefined): boolean {
let expr: Expression | undefined;
let pivotRef: string | undefined;
@@ -387,7 +492,7 @@ function resolvePosition(context: PrimitiveBuilderContext, position: PrimitivePo
if (targetPosition) Vec3.copy(targetPosition, position as any);
if (targetSphere) Sphere3D.set(targetSphere, position as any, 0);
if (targetBox) Box3D.set(targetBox, position as any, position as any);
return;
return true;
}
if (isPrimitiveComponentExpressions(position)) {
@@ -408,36 +513,41 @@ function resolvePosition(context: PrimitiveBuilderContext, position: PrimitivePo
throw new Error(`Structure with ref '${pivotRef ?? '<default>'}' not found.`);
}
const cackeKey = JSON.stringify(position);
if (context.positionCache.has(cackeKey)) {
const cached = context.positionCache.get(cackeKey)!;
if (targetPosition) Vec3.copy(targetPosition, cached[0].center);
if (targetSphere) Sphere3D.copy(targetSphere, cached[0]);
if (targetBox) Box3D.copy(targetBox, cached[1]);
return;
const cacheKey = JSON.stringify(position);
if (context.positionCache.has(cacheKey)) {
const [isDefined, sphere, box] = context.positionCache.get(cacheKey)!;
if (targetPosition) Vec3.copy(targetPosition, sphere.center);
if (targetSphere) Sphere3D.copy(targetSphere, sphere);
if (targetBox) Box3D.copy(targetBox, box);
return isDefined;
}
const { selection } = StructureQueryHelper.createAndRun(pivot, expr);
let box: Box3D;
let sphere: Sphere3D;
let isDefined: boolean;
if (StructureSelection.isEmpty(selection)) {
if (targetPosition) Vec3.set(targetPosition, 0, 0, 0);
box = _EmptyBox;
sphere = _EmptySphere;
isDefined = false;
printEmptySelectionWarning(context, position);
} else {
const loci = StructureSelection.toLociWithSourceUnits(selection);
const boundary = StructureElement.Loci.getBoundary(loci);
if (targetPosition) Vec3.copy(targetPosition, boundary.sphere.center);
box = boundary.box;
sphere = boundary.sphere;
isDefined = true;
}
if (targetSphere) Sphere3D.copy(targetSphere, sphere);
if (targetBox) Box3D.copy(targetBox, box);
context.positionCache.set(cackeKey, [sphere, box]);
context.positionCache.set(cacheKey, [isDefined, sphere, box]);
return isDefined;
}
function getInstances(options: PrimitivesParams | undefined): Mat4[] | undefined {
@@ -513,8 +623,11 @@ function buildPrimitiveLines(context: PrimitiveBuilderContext, prev?: Lines): Sh
);
}
function buildPrimitiveLabels(context: PrimitiveBuilderContext, prev?: Text): Shape<Text> {
const labelsBuilder = TextBuilder.create(BaseLabelProps, 1024, 1024, prev);
function buildPrimitiveLabels(context: PrimitiveBuilderContext, prev: Text | undefined, props: PD.Values<Text.Params>): Shape<Text> {
const labelsBuilder = TextBuilder.create({
...BaseLabelProps,
...props,
}, 1024, 1024, prev);
const state: LabelBuilderState = { groups: new GroupManager(), labels: labelsBuilder };
for (const c of context.primitives) {
@@ -630,8 +743,9 @@ const lEnd = Vec3.zero();
function addTubeMesh(context: PrimitiveBuilderContext, { groups, mesh }: MeshBuilderState, node: MVSNode<'primitive'>, params: PrimitiveParams<'tube'>, options?: { skipResolvePosition?: boolean }) {
if (!options?.skipResolvePosition) {
resolveBasePosition(context, params.start, lStart);
resolveBasePosition(context, params.end, lEnd);
const startDefined = resolveBasePosition(context, params.start, lStart);
const endDefined = resolveBasePosition(context, params.end, lEnd);
if (!startDefined || !endDefined) return;
}
const radius = params.radius;
@@ -664,13 +778,17 @@ const ArrowState = {
};
function addArrowMesh(context: PrimitiveBuilderContext, { groups, mesh }: MeshBuilderState, node: MVSNode<'primitive'>, params: PrimitiveParams<'arrow'>) {
resolveBasePosition(context, params.start, ArrowState.start);
if (params.end) {
resolveBasePosition(context, params.end, ArrowState.end);
}
const startDefined = resolveBasePosition(context, params.start, ArrowState.start);
if (!startDefined) return;
if (params.direction) {
if (params.end) {
const endDefined = resolveBasePosition(context, params.end, ArrowState.end);
if (!endDefined) return;
} else if (params.direction) {
Vec3.add(ArrowState.end, ArrowState.start, params.direction as any as Vec3);
} else {
console.warn(`Primitive arrow does not contain "end" nor "distance". Not showing.`);
return;
}
Vec3.sub(ArrowState.dir, ArrowState.end, ArrowState.start);
@@ -695,9 +813,10 @@ function addArrowMesh(context: PrimitiveBuilderContext, { groups, mesh }: MeshBu
groups.updateColor(mesh.currentGroup, params.color);
groups.updateTooltip(mesh.currentGroup, params.tooltip);
const startRadius = params.start_cap_radius ?? tubeRadius;
if (params.show_start_cap) {
Vec3.scaleAndAdd(ArrowState.startCap, ArrowState.start, ArrowState.dir, startRadius);
const startRadius = params.start_cap_radius ?? 2 * tubeRadius;
const startCapLength = params.start_cap_length ?? 2 * startRadius;
Vec3.scaleAndAdd(ArrowState.startCap, ArrowState.start, ArrowState.dir, startCapLength);
addSimpleCylinder(mesh, ArrowState.startCap, ArrowState.start, {
radiusBottom: startRadius,
radiusTop: 0,
@@ -709,9 +828,10 @@ function addArrowMesh(context: PrimitiveBuilderContext, { groups, mesh }: MeshBu
Vec3.copy(ArrowState.startCap, ArrowState.start);
}
const endRadius = params.end_cap_radius ?? tubeRadius;
if (params.show_end_cap) {
Vec3.scaleAndAdd(ArrowState.endCap, ArrowState.end, ArrowState.dir, -endRadius);
const endRadius = params.end_cap_radius ?? 2 * tubeRadius;
const endCapLength = params.end_cap_length ?? 2 * endRadius;
Vec3.scaleAndAdd(ArrowState.endCap, ArrowState.end, ArrowState.dir, -endCapLength);
addSimpleCylinder(mesh, ArrowState.endCap, ArrowState.end, {
radiusBottom: endRadius,
radiusTop: 0,
@@ -735,19 +855,26 @@ function addArrowMesh(context: PrimitiveBuilderContext, { groups, mesh }: MeshBu
}
function getDistanceLabel(context: PrimitiveBuilderContext, params: PrimitiveParams<'distance_measurement'>) {
resolveBasePosition(context, params.start, lStart);
resolveBasePosition(context, params.end, lEnd);
/** Return distance in angstroms, or `undefined` if any of the endpoints corresponds to empty substructure.
* This function also sets `lStart`, `lEnd` globals. */
function computeDistance(context: PrimitiveBuilderContext, start: PrimitivePositionT, end: PrimitivePositionT): number | undefined {
const startDefined = resolveBasePosition(context, start, lStart);
const endDefined = resolveBasePosition(context, end, lEnd);
if (startDefined && endDefined) return Vec3.distance(lStart, lEnd);
else return undefined;
}
const dist = Vec3.distance(lStart, lEnd);
const distance = `${round(dist, 2)} Å`;
const label = typeof params.label_template === 'string' ? params.label_template.replace('{{distance}}', distance) : distance;
return label;
// /** Return text for distance measurement label/tooltip. */
function distanceLabel(distance: number, params: PrimitiveParams<'distance_measurement'>): string {
const distStr = `${round(distance, 2)} Å`;
if (typeof params.label_template === 'string') return params.label_template.replace('{{distance}}', distStr);
else return distStr;
}
function addDistanceMesh(context: PrimitiveBuilderContext, state: MeshBuilderState, node: MVSNode<'primitive'>, params: PrimitiveParams<'distance_measurement'>) {
const tooltip = getDistanceLabel(context, params);
const distance = computeDistance(context, params.start, params.end); // sets lStart, lEnd
if (distance === undefined) return; // empty substructure in measurement
const tooltip = distanceLabel(distance, params);
addTubeMesh(context, state, node, { ...params, tooltip } as any, { skipResolvePosition: true });
}
@@ -755,12 +882,8 @@ const labelPos = Vec3.zero();
function addDistanceLabel(context: PrimitiveBuilderContext, state: LabelBuilderState, node: MVSNode<'primitive'>, params: PrimitiveParams<'distance_measurement'>) {
const { labels, groups } = state;
resolveBasePosition(context, params.start, lStart);
resolveBasePosition(context, params.end, lEnd);
const dist = Vec3.distance(lStart, lEnd);
const distance = `${round(dist, 2)} Å`;
const label = typeof params.label_template === 'string' ? params.label_template.replace('{{distance}}', distance) : distance;
const dist = computeDistance(context, params.start, params.end); // sets lStart, lEnd
if (dist === undefined) return; // empty substructure in measurement
let size: number | undefined;
if (typeof params.label_size === 'number') {
@@ -776,30 +899,36 @@ function addDistanceLabel(context: PrimitiveBuilderContext, state: LabelBuilderS
groups.updateColor(group, params.label_color);
groups.updateSize(group, size);
labels.add(label, labelPos[0], labelPos[1], labelPos[2], 1.05 * (params.radius), 1, group);
labels.add(distanceLabel(dist, params), labelPos[0], labelPos[1], labelPos[2], 1.05 * (params.radius), 1, group);
}
const AngleState = {
isDefined: false,
a: Vec3(),
b: Vec3(),
c: Vec3(),
ba: Vec3(),
bc: Vec3(),
labelPos: Vec3(),
/** Sector radius */
radius: 0,
label: '',
};
function syncAngleState(context: PrimitiveBuilderContext, params: PrimitiveParams<'angle_measurement'>) {
resolveBasePosition(context, params.a, AngleState.a);
resolveBasePosition(context, params.b, AngleState.b);
resolveBasePosition(context, params.c, AngleState.c);
function syncAngleState(context: PrimitiveBuilderContext, params: PrimitiveParams<'angle_measurement'>): void {
const aDefined = resolveBasePosition(context, params.a, AngleState.a);
const bDefined = resolveBasePosition(context, params.b, AngleState.b);
const cDefined = resolveBasePosition(context, params.c, AngleState.c);
AngleState.isDefined = aDefined && bDefined && cDefined;
if (!AngleState.isDefined) return;
Vec3.sub(AngleState.ba, AngleState.a, AngleState.b);
Vec3.sub(AngleState.bc, AngleState.c, AngleState.b);
const value = radToDeg(Vec3.angle(AngleState.ba, AngleState.bc));
const angle = `${round(value, 2)}\u00B0`;
const label = typeof params.label_template === 'string' ? params.label_template.replace('{{angle}}', angle) : angle;
AngleState.label = typeof params.label_template === 'string' ? params.label_template.replace('{{angle}}', angle) : angle;
if (typeof params.section_radius === 'number') {
AngleState.radius = params.section_radius;
@@ -809,16 +938,16 @@ function syncAngleState(context: PrimitiveBuilderContext, params: PrimitiveParam
AngleState.radius *= params.section_radius_scale;
}
}
return label;
}
function addAngleMesh(context: PrimitiveBuilderContext, state: MeshBuilderState, node: MVSNode<'primitive'>, params: PrimitiveParams<'angle_measurement'>) {
const label = syncAngleState(context, params);
syncAngleState(context, params);
if (!AngleState.isDefined) return; // empty substructure in measurement
const { groups, mesh } = state;
if (params.show_vector) {
const radius = 0.01;
const radius = params.vector_radius ?? 0.05;
const cylinderProps: BasicCylinderProps = {
radiusBottom: radius,
radiusTop: radius,
@@ -828,7 +957,7 @@ function addAngleMesh(context: PrimitiveBuilderContext, state: MeshBuilderState,
mesh.currentGroup = groups.allocateSingle(node);
groups.updateColor(mesh.currentGroup, params.vector_color);
groups.updateTooltip(mesh.currentGroup, label);
groups.updateTooltip(mesh.currentGroup, AngleState.label);
let count = Math.ceil(Vec3.magnitude(AngleState.ba) / (2 * radius));
addFixedCountDashedCylinder(mesh, AngleState.a, AngleState.b, 1.0, count, true, cylinderProps);
@@ -856,14 +985,15 @@ function addAngleMesh(context: PrimitiveBuilderContext, state: MeshBuilderState,
theta_start: 0,
theta_end: angle,
color: params.section_color,
tooltip: label,
tooltip: AngleState.label,
});
}
}
function addAngleLabel(context: PrimitiveBuilderContext, state: LabelBuilderState, node: MVSNode<'primitive'>, params: PrimitiveParams<'angle_measurement'>) {
const { labels, groups } = state;
const label = syncAngleState(context, params);
syncAngleState(context, params);
if (!AngleState.isDefined) return; // empty substructure in measurement
Vec3.normalize(AngleState.ba, AngleState.ba);
Vec3.normalize(AngleState.bc, AngleState.bc);
@@ -886,7 +1016,7 @@ function addAngleLabel(context: PrimitiveBuilderContext, state: LabelBuilderStat
groups.updateColor(group, params.label_color);
groups.updateSize(group, size);
labels.add(label, AngleState.labelPos[0], AngleState.labelPos[1], AngleState.labelPos[2], 1, 1, group);
labels.add(AngleState.label, AngleState.labelPos[0], AngleState.labelPos[1], AngleState.labelPos[2], 1, 1, group);
}
function resolveLabelRefs(params: PrimitiveParams<'label'>, refs: Set<string>) {
@@ -900,7 +1030,8 @@ const PrimitiveLabelState = {
function addPrimitiveLabel(context: PrimitiveBuilderContext, state: LabelBuilderState, node: MVSNode<'primitive'>, params: PrimitiveParams<'label'>) {
const { labels, groups } = state;
resolvePosition(context, params.position, PrimitiveLabelState.position, PrimitiveLabelState.sphere, undefined);
const positionDefined = resolvePosition(context, params.position, PrimitiveLabelState.position, PrimitiveLabelState.sphere, undefined);
if (!positionDefined) return;
const group = groups.allocateSingle(node);
groups.updateColor(group, params.label_color);
@@ -950,17 +1081,20 @@ function addEllipseMesh(context: PrimitiveBuilderContext, state: MeshBuilderStat
const circle = getCircle({ thetaStart: params.theta_start, thetaEnd: params.theta_end });
if (!circle) return;
resolvePosition(context, params.center, EllipseState.centerPos, undefined, undefined);
const centerDefined = resolvePosition(context, params.center, EllipseState.centerPos, undefined, undefined);
if (!centerDefined) return;
if (params.major_axis_endpoint) {
resolvePosition(context, params.major_axis_endpoint, EllipseState.majorPos, undefined, undefined);
const endpointDefined = resolvePosition(context, params.major_axis_endpoint, EllipseState.majorPos, undefined, undefined);
if (!endpointDefined) return;
Vec3.sub(EllipseState.majorAxis, EllipseState.majorPos, EllipseState.centerPos);
} else {
Vec3.copy(EllipseState.majorAxis, params.major_axis as any as Vec3);
}
if (params.minor_axis_endpoint) {
resolvePosition(context, params.minor_axis_endpoint, EllipseState.minorPos, undefined, undefined);
const endpointDefined = resolvePosition(context, params.minor_axis_endpoint, EllipseState.minorPos, undefined, undefined);
if (!endpointDefined) return;
Vec3.sub(EllipseState.minorAxis, EllipseState.minorPos, EllipseState.centerPos);
} else {
Vec3.copy(EllipseState.minorAxis, params.minor_axis as any as Vec3);
@@ -1015,10 +1149,12 @@ const EllipsoidState = {
function addEllipsoidMesh(context: PrimitiveBuilderContext, state: MeshBuilderState, node: MVSNode<'primitive'>, params: PrimitiveParams<'ellipsoid'>) {
resolvePosition(context, params.center, EllipsoidState.centerPos, EllipsoidState.sphere, undefined);
const centerDefined = resolvePosition(context, params.center, EllipsoidState.centerPos, EllipsoidState.sphere, undefined);
if (!centerDefined) return;
if (params.major_axis_endpoint) {
resolvePosition(context, params.major_axis_endpoint, EllipsoidState.majorPos, undefined, undefined);
const endpointDefined = resolvePosition(context, params.major_axis_endpoint, EllipsoidState.majorPos, undefined, undefined);
if (!endpointDefined) return;
Vec3.sub(EllipsoidState.majorAxis, EllipsoidState.majorPos, EllipsoidState.centerPos);
} else if (params.major_axis) {
Vec3.copy(EllipsoidState.majorAxis, params.major_axis as any as Vec3);
@@ -1027,7 +1163,8 @@ function addEllipsoidMesh(context: PrimitiveBuilderContext, state: MeshBuilderSt
}
if (params.minor_axis_endpoint) {
resolvePosition(context, params.minor_axis_endpoint, EllipsoidState.minorPos, undefined, undefined);
const endpointDefined = resolvePosition(context, params.minor_axis_endpoint, EllipsoidState.minorPos, undefined, undefined);
if (!endpointDefined) return;
Vec3.sub(EllipsoidState.minorAxis, EllipsoidState.minorPos, EllipsoidState.centerPos);
} else if (params.minor_axis) {
Vec3.copy(EllipsoidState.minorAxis, params.minor_axis as any as Vec3);
@@ -1081,7 +1218,8 @@ const BoxState = {
function addBoxMesh(context: PrimitiveBuilderContext, state: MeshBuilderState, node: MVSNode<'primitive'>, params: PrimitiveParams<'box'>) {
if (!params.show_edges && !params.show_faces) return;
resolvePosition(context, params.center, BoxState.center, undefined, BoxState.boundary);
const positionDefined = resolvePosition(context, params.center, BoxState.center, undefined, BoxState.boundary);
if (!positionDefined) return;
if (params.extent) {
Box3D.expand(BoxState.boundary, BoxState.boundary, params.extent as unknown as Vec3);
}

View File

@@ -10,8 +10,6 @@ import { StaticStructureComponentTypes, createStructureComponent } from '../../.
import { PluginStateObject } from '../../../mol-plugin-state/objects';
import { MolScriptBuilder } from '../../../mol-script/language/builder';
import { Expression } from '../../../mol-script/language/expression';
import { UUID } from '../../../mol-util';
import { arrayExtend, sortIfNeeded } from '../../../mol-util/array';
import { mapArrayToObject, pickObjectKeys } from '../../../mol-util/object';
import { Choice } from '../../../mol-util/param-choice';
import { ParamDefinition as PD } from '../../../mol-util/param-definition';
@@ -47,28 +45,27 @@ export function isSelectorAll(props: Selector): props is typeof SelectorAll {
/** Data structure for fast lookup of a structure element location in a substructure */
export type ElementSet = { [modelId: UUID]: SortedArray<ElementIndex> }
export type ElementSet = { [unitId: number]: SortedArray<ElementIndex> }
export const ElementSet = {
/** Create an `ElementSet` from a structure */
fromStructure(structure: Structure | undefined): ElementSet {
if (!structure) return {};
const out: ElementSet = {};
for (const unit of structure.units) {
out[unit.id] = unit.elements;
}
return out;
},
/** Create an `ElementSet` from the substructure of `structure` defined by `selector` */
fromSelector(structure: Structure | undefined, selector: Selector): ElementSet {
if (!structure) return {};
const arrays: { [modelId: UUID]: ElementIndex[] } = {};
const selection = substructureFromSelector(structure, selector); // using `getAtomRangesForRow` might (might not) be faster here
for (const unit of selection.units) {
arrayExtend(arrays[unit.model.id] ??= [], unit.elements);
}
const result: { [modelId: UUID]: SortedArray<ElementIndex> } = {};
for (const modelId in arrays) {
const array = arrays[modelId as UUID];
sortIfNeeded(array, (a, b) => a - b);
result[modelId as UUID] = SortedArray.ofSortedArray(array);
}
return result;
return this.fromStructure(selection);
},
/** Decide if the element set `set` contains structure element location `location` */
has(set: ElementSet, location: StructureElement.Location): boolean {
const array = set[location.unit.model.id];
const array = set[location.unit.id];
return array ? SortedArray.has(array, location.element) : false;
},
};

View File

@@ -0,0 +1,36 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { PluginStateObject } from '../../../mol-plugin-state/objects';
import { getTrajectory } from '../../../mol-plugin-state/transforms/model';
import { Task } from '../../../mol-task';
import { ParamDefinition } from '../../../mol-util/param-definition';
import { getMVSReferenceObject } from '../helpers/utils';
import { MVSTransform } from './annotation-structure-component';
export const MVSTrajectoryWithCoordinates = MVSTransform({
name: 'trajectory-with-coordinates',
display: { name: 'Trajectory with Coordinates', description: 'Create a trajectory from existing model and the provided coordinates.' },
from: PluginStateObject.Molecule.Model,
to: PluginStateObject.Molecule.Trajectory,
params: {
coordinatesRef: ParamDefinition.Text('', { isHidden: true }),
}
})({
apply({ a, params, dependencies }) {
return Task.create('Create trajectory from model/topology and coordinates', async ctx => {
const coordinates = getMVSReferenceObject([PluginStateObject.Molecule.Coordinates], dependencies, params.coordinatesRef);
if (!coordinates) {
throw new Error('Coordinates not found.');
}
const trajectory = await getTrajectory(ctx, a, coordinates.data);
const props = { label: 'Trajectory', description: `${trajectory.frameCount} model${trajectory.frameCount === 1 ? '' : 's'}` };
return new PluginStateObject.Molecule.Trajectory(trajectory, props);
});
}
});

View File

@@ -0,0 +1,26 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { Zip } from '../../mol-util/zip/zip';
import { MVSData } from './mvs-data';
/**
* Creates an MVSX zip file with from the provided data and assets
*/
export async function createMVSX(data: MVSData, assets: { name: string, content: string | Uint8Array }[]) {
const encoder = new TextEncoder();
const files: Record<string, Uint8Array> = {
'index.mvsj': encoder.encode(JSON.stringify(data)),
};
for (const asset of assets) {
files[asset.name] = typeof asset.content === 'string'
? encoder.encode(asset.content)
: asset.content;
}
const zip = await Zip(files).run();
return new Uint8Array(zip);
}

View File

@@ -0,0 +1,605 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
* @author Ludovic Autin <ludovic.autin@gmail.com>
*/
import { SortedArray } from '../../../mol-data/int';
import * as EasingFns from '../../../mol-math/easing';
import { clamp, lerp } from '../../../mol-math/interpolate';
import { EPSILON, Mat3, Mat4, Quat, Vec3 } from '../../../mol-math/linear-algebra';
import { RuntimeContext } from '../../../mol-task';
import { deepEqual } from '../../../mol-util';
import { Color } from '../../../mol-util/color';
import { produce } from '../../../mol-util/produce';
import { makeContinuousPaletteCheckpoints, MVSContinuousPaletteProps, MVSDiscretePaletteProps } from '../components/annotation-color-theme';
import { palettePropsFromMVSPalette } from '../load-helpers';
import { Snapshot } from '../mvs-data';
import { MVSAnimationEasing, MVSAnimationNode, MVSAnimationSchema } from '../tree/animation/animation-tree';
import { Tree } from '../tree/generic/tree-schema';
import { addDefaults } from '../tree/generic/tree-utils';
import { MVSTree } from '../tree/mvs/mvs-tree';
import { ColorT } from '../tree/mvs/param-types';
export async function generateStateTransition(ctx: RuntimeContext, snapshot: Snapshot, snapshotIndex: number, snapshotCount: number) {
if (!snapshot.animation) return undefined;
const tree = addDefaults(snapshot.animation, MVSAnimationSchema);
const transitions = tree.children?.filter(child => child.kind === 'interpolate');
if (!transitions?.length) return undefined;
const duration = Math.max(
snapshot.animation.params?.duration_ms ?? 0,
...transitions.map(t => (t.params.start_ms ?? 0) + t.params.duration_ms)
);
const frames: [tree: MVSTree, time: number][] = [];
const dt = tree.params?.frame_time_ms ?? (1000 / 60);
const N = Math.ceil(duration / dt);
const nodeMap = makeNodeMap(snapshot.root, new Map(), []);
const cache = new Map<any, InterpolationCacheEntry>();
const transitionGroups = groupTranstions(transitions);
let prevRoot: MVSTree | undefined;
for (let i = 0; i <= N; i++) {
const t = i * dt;
const root = createSnapshot(snapshot.root, transitionGroups, t, cache, nodeMap);
if (root === prevRoot || (prevRoot && deepEqual(root, prevRoot))) {
frames[frames.length - 1][1] += dt;
} else {
frames.push([root, dt]);
}
prevRoot = root;
if (ctx.shouldUpdate) {
await ctx.update({ message: `Generating transition for snapshot ${snapshotIndex + 1}/${snapshotCount}`, current: i + 1, max: N });
}
}
return { tree, frametimeMs: dt, frames };
}
const EasingFnMap: Record<MVSAnimationEasing, (t: number) => number> = {
'linear': t => t,
'bounce-in': EasingFns.bounceIn,
'bounce-out': EasingFns.bounceOut,
'bounce-in-out': EasingFns.bounceInOut,
'circle-in': EasingFns.circleIn,
'circle-out': EasingFns.circleOut,
'circle-in-out': EasingFns.circleInOut,
'cubic-in': EasingFns.cubicIn,
'cubic-out': EasingFns.cubicOut,
'cubic-in-out': EasingFns.cubicInOut,
'exp-in': EasingFns.expIn,
'exp-out': EasingFns.expOut,
'exp-in-out': EasingFns.expInOut,
'quad-in': EasingFns.quadIn,
'quad-out': EasingFns.quadOut,
'quad-in-out': EasingFns.quadInOut,
'sin-in': EasingFns.sinIn,
'sin-out': EasingFns.sinOut,
'sin-in-out': EasingFns.sinInOut,
};
interface InterpolationCacheEntry {
paletteFn?: (value: number) => Color,
rotation?: { axis: Vec3, angle: number, start: Quat, end: Quat },
}
function getTransitionKey(transition: MVSAnimationNode<'interpolate'>) {
const prop = transition.params.property;
if (Array.isArray(prop)) {
return `${transition.params.target_ref}:${prop.join('.')}`;
}
return `${transition.params.target_ref}:${prop}`;
}
function groupTranstions(transitions: MVSAnimationNode<'interpolate'>[]) {
const map = new Map<string, MVSAnimationNode<'interpolate'>[]>();
const groups: MVSAnimationNode<'interpolate'>[][] = [];
for (const t of transitions) {
const key = getTransitionKey(t);
if (!map.has(key)) {
const group: MVSAnimationNode<'interpolate'>[] = [];
map.set(key, group);
groups.push(group);
}
map.get(key)!.push(t);
}
for (const group of groups) {
group.sort((a, b) => {
const s = (a.params.start_ms ?? 0) - (b.params.start_ms ?? 0);
if (s !== 0) return s;
return a.params.duration_ms - b.params.duration_ms;
});
}
return groups;
}
function createSnapshot(tree: MVSTree, transitionGroups: MVSAnimationNode<'interpolate'>[][], time: number, cache: Map<any, InterpolationCacheEntry>, nodeMap: Map<string, (string | number)[]>) {
let modified = false;
const ret = produce(tree, (draft) => {
for (const transitionGroup of transitionGroups) {
const pivot = transitionGroup[0];
const nodePath = nodeMap.get(pivot.params.target_ref);
if (!nodePath) continue;
const node = select(draft, nodePath, 0);
const target = pivot.params.property[0] === 'custom' ? node?.custom : node?.params;
if (!target) continue;
const offset = pivot.params.property[0] === 'custom' ? 1 : 0;
let transition: MVSAnimationNode<'interpolate'> = pivot;
let previous: MVSAnimationNode<'interpolate'> | undefined;
for (let i = transitionGroup.length - 1; i > 0; i--) {
const current = transitionGroup[i];
const currentStart = current.params.start_ms ?? 0;
if (time >= currentStart) {
transition = current;
previous = i > 0 ? transitionGroup[i - 1] : undefined;
break;
}
}
if (!cache.has(transition)) {
cache.set(transition, {});
}
const cacheEntry: InterpolationCacheEntry = cache.get(transition)!;
const startTime: number = transition.params.start_ms ?? 0;
const durationMs: number = transition.params.duration_ms ?? 0;
const t = (time - startTime) / durationMs;
let next: any;
if (transition.params.kind === 'transform_matrix') {
next = processTransformMatrix(transition, target, clamp(t, 0, 1), cacheEntry, offset, previous);
} else {
next = processScalarLike(transition, target, t, cacheEntry, offset, previous);
}
if (next === undefined) {
continue;
}
modified = true;
assign(target, transition.params.property, next, offset);
}
});
return modified ? ret : tree;
}
function applyFrequency(t: number, frequency: number, alternate: boolean) {
let v = (t * (frequency || 1));
if (v < 1) return v;
if (!alternate) {
v = (v % 1);
if (v === 0) return 1;
return v;
}
if (Math.abs(v - 1) < EPSILON) return 1;
v = v % 2;
if (v > 1) return 2 - v;
return v;
}
function getPreviousScalarEnd(previous: MVSAnimationNode<'interpolate'> | undefined) {
if (!previous || previous.params.kind === 'transform_matrix') return undefined;
return previous.params.end;
}
function processScalarLike(transition: MVSAnimationNode<'interpolate'>, target: any, time: number, cacheEntry: InterpolationCacheEntry, offset: number, previous: MVSAnimationNode<'interpolate'> | undefined) {
if (transition.params.kind === 'transform_matrix') return;
if (previous && previous.params.kind === 'transform_matrix') return;
const startBase = transition.params.start ?? getPreviousScalarEnd(previous) ?? select(target, transition.params.property, offset);
if (transition.params.kind === 'color' && !cacheEntry.paletteFn) {
cacheEntry.paletteFn = makePaletteFunction(transition, startBase, transition.params.end as ColorT | undefined);
}
const paletteFn = cacheEntry.paletteFn!;
const startValue: any = transition.params.kind === 'color'
? Color.toHexStyle(paletteFn(0))
: startBase;
const endValue: any = transition.params.kind === 'color'
? Color.toHexStyle(paletteFn(1))
: transition.params.end;
if (time <= 0) return startValue;
else if (time >= 1 - EPSILON && !transition.params.alternate_direction) return endValue;
let t = clamp(time, 0, 1);
t = applyFrequency(t, transition.params.frequency ?? 1, !!transition.params.alternate_direction);
const easing = EasingFnMap[transition.params.easing ?? 'linear'] ?? EasingFnMap['linear'];
t = easing(t);
if (transition.params.kind === 'scalar') {
return interpolateScalars(startValue, endValue, t, transition.params.noise_magnitude ?? 0, !!transition.params.discrete);
} else if (transition.params.kind === 'vec3') {
return interpolateVectors(startValue, endValue, t, transition.params.noise_magnitude ?? 0, !!transition.params.spherical);
} else if (transition.params.kind === 'rotation_matrix') {
return interpolateRotation(startValue, endValue, t, transition.params.noise_magnitude ?? 0, cacheEntry);
} else if (transition.params.kind === 'color') {
const color = paletteFn(t);
return Color.toHexStyle(color);
}
}
function getPreviousMatrixEnd(previous: MVSAnimationNode<'interpolate'> | undefined, prop: 'rotation_start' | 'translation_start' | 'scale_start') {
if (!previous || previous.params.kind !== 'transform_matrix') return undefined;
return previous.params[prop];
}
const TransformState = {
pivotTranslation: Mat4(),
pivotTranslationInv: Mat4(),
rotation: Mat4(),
scale: Mat4(),
translation: Mat4(),
pivotNeg: Vec3(),
temp: Mat4(),
};
function processTransformMatrix(transition: MVSAnimationNode<'interpolate'>, target: any, time: number, cache: InterpolationCacheEntry, offset: number, previous: MVSAnimationNode<'interpolate'> | undefined) {
if (transition.params.kind !== 'transform_matrix') return;
if (previous && previous.params.kind !== 'transform_matrix') return;
const transform = select(target, transition.params.property, offset) ?? Mat4.identity();
const startRotation = transition.params.rotation_start ?? getPreviousMatrixEnd(previous, 'rotation_start') ?? Mat3.fromMat4(Mat3(), transform);
const startTranslation = transition.params.translation_start ?? getPreviousMatrixEnd(previous, 'translation_start') ?? Mat4.getTranslation(Vec3(), transform);
const startScale = transition.params.scale_start ?? getPreviousMatrixEnd(previous, 'scale_start') ?? Mat4.getScaling(Vec3(), transform);
const endRotation = transition.params.rotation_end;
const endTranslation = transition.params.translation_end;
const endScale = transition.params.scale_end;
let rotation, translation, scale;
if (time <= 0) {
rotation = startRotation as Mat3;
translation = startTranslation as Vec3;
scale = startScale as Vec3;
} else {
const clampedTime = clamp(time, 0, 1);
let t = applyFrequency(clampedTime, transition.params.rotation_frequency ?? 1, !!transition.params.rotation_alternate_direction);
let easing = EasingFnMap[transition.params.rotation_easing ?? 'linear'] ?? EasingFnMap['linear'];
rotation = interpolateRotation(startRotation as Mat3, endRotation as Mat3, easing(t), transition.params.rotation_noise_magnitude ?? 0, cache);
t = applyFrequency(clampedTime, transition.params.translation_frequency ?? 1, !!transition.params.translation_alternate_direction);
easing = EasingFnMap[transition.params.translation_easing ?? 'linear'] ?? EasingFnMap['linear'];
translation = interpolateVec3(startTranslation as Vec3, endTranslation as Vec3 | undefined, easing(t), transition.params.translation_noise_magnitude ?? 0, false);
t = applyFrequency(clampedTime, transition.params.scale_frequency ?? 1, !!transition.params.scale_alternate_direction);
easing = EasingFnMap[transition.params.scale_easing ?? 'linear'] ?? EasingFnMap['linear'];
scale = interpolateVec3(startScale as Vec3, endScale as Vec3 | undefined, easing(t), transition.params.scale_noise_magnitude ?? 0, false);
}
const pivot = transition.params.pivot ?? Vec3.zero();
Mat4.fromTranslation(TransformState.translation, translation);
Mat4.fromScaling(TransformState.scale, scale);
Mat4.setIdentity(TransformState.rotation);
Mat4.fromMat3(TransformState.rotation, rotation);
Mat4.fromTranslation(TransformState.pivotTranslation, pivot as Vec3);
Mat4.fromTranslation(TransformState.pivotTranslationInv, Vec3.negate(TransformState.pivotNeg, pivot as Vec3));
// translation . pivot . rotation . scale . pivotInv
const result = Mat4();
Mat4.mul(result, TransformState.scale, TransformState.pivotTranslationInv);
Mat4.mul(result, TransformState.rotation, result);
Mat4.mul(result, TransformState.translation, result);
return result;
}
function interpolateScalars(start: number | number[], end: number | number[] | undefined, t: number, noise: number, discrete: boolean) {
if (Array.isArray(start)) {
const ret = Array.from<number>({ length: start.length }).fill(0.1);
if (!end || !Array.isArray(end)) {
for (let i = 0; i < start.length; i++) {
ret[i] = interpolateScalar(start[i], end, t, noise, discrete);
}
return ret;
}
for (let i = 0; i < start.length; i++) {
ret[i] = interpolateScalar(start[i], end[i], t, noise, discrete);
}
return ret;
}
if (Array.isArray(end)) {
const ret = Array.from<number>({ length: end.length }).fill(0.1);
for (let i = 0; i < end.length; i++) {
ret[i] = interpolateScalar(start, end[i], t, noise, discrete);
}
return ret;
}
return interpolateScalar(start, end, t, noise, discrete);
}
function interpolateScalar(start: number, end: number | undefined, t: number, noise: number, discrete: boolean) {
let v = typeof end === 'number' ? lerp(start, end, t) : start;
if (noise) {
v += (Math.random() - 0.5) * noise;
}
if (discrete) {
v = Math.round(v);
}
return v;
}
const InterpolateVectorsState = {
start: Vec3(),
end: Vec3(),
v: Vec3(),
};
function interpolateVectors(start: number[], end: number[] | undefined, t: number, noise: number, isSpherical: boolean) {
if ((!end || start === end) && !noise) return start;
const ret: number[] = Array.from<number>({ length: start.length }).fill(0.1);
for (let i = 0; i < start.length; i += 3) {
const s = Vec3.fromArray(InterpolateVectorsState.start, start, i);
let v: Vec3;
if (end) {
const e = Vec3.fromArray(InterpolateVectorsState.end, end, i);
v = isSpherical
? Vec3.slerp(InterpolateVectorsState.v, s, e, t)
: Vec3.lerp(InterpolateVectorsState.v, s, e, t);
} else {
v = Vec3.clone(s);
}
if (noise && t <= 1 - EPSILON) {
Vec3.random(Vec3Noise, noise);
Vec3.add(v, v, Vec3Noise);
}
Vec3.toArray(v, ret, i);
}
return ret;
}
const Vec3Noise = Vec3();
function interpolateVec3(start: Vec3, end: Vec3 | undefined, t: number, noise: number, isSpherical: boolean) {
if ((!end || start === end) && !noise) return start;
let v: Vec3;
if (end) {
v = isSpherical
? Vec3.slerp(Vec3(), start, end, t)
: Vec3.lerp(Vec3(), start, end, t);
} else {
v = Vec3.clone(start);
}
if (noise && t <= 1 - EPSILON) {
Vec3.random(Vec3Noise, noise);
Vec3.add(v, v, Vec3Noise);
}
return v;
}
const RotationState = {
start: Quat(),
end: Quat(),
v: Quat(),
noise: Quat(),
axis: Vec3(),
temp: Mat4(),
};
function interpolateRotation(start: Mat3, end: Mat3 | undefined, t: number, noise: number, cache: InterpolationCacheEntry) {
if ((!end || start === end) && !noise) return start;
if (end) {
if (!cache.rotation) {
cache.rotation = {
...relativeAxisAngle(start, end),
start: Quat.fromMat3(Quat(), start),
end: Quat.fromMat3(Quat(), end),
};
}
const { axis, angle, start: startQ, end: endQ } = cache.rotation;
if (angle < 1e-6) {
// start ≈ end: make a clean spin about the detected (or default) axis
Quat.setAxisAngle(RotationState.v, axis, t * 2 * Math.PI); // Make a full turn
} else {
// Normal case: stick with your existing slerp between start/end
Quat.slerp(RotationState.v, startQ, endQ, t);
}
} else {
Quat.fromMat3(RotationState.v, start);
}
if (noise && t <= 1 - EPSILON) {
Vec3.random(RotationState.axis, 1);
Quat.setAxisAngle(RotationState.noise, RotationState.axis, 2 * Math.PI * noise * (Math.random() - 0.5));
Quat.multiply(RotationState.v, RotationState.noise, RotationState.v);
}
Mat4.fromQuat(RotationState.temp, RotationState.v);
return Mat3.fromMat4(Mat3(), RotationState.temp);
}
function select(params: any, path: string | (string | number)[], offset: number) {
if (typeof path === 'string') {
return params?.[path];
}
let f = params;
for (let i = offset; i < path.length; i++) {
if (!f) break;
f = f[path[i]];
}
return f;
}
function assign(params: any, path: string | (string | number)[], value: any, offset: number) {
if (!params) return;
if (typeof path === 'string') {
params[path] = value;
return;
}
let f = params;
for (let i = offset; i < path.length; i++) {
if (!f) break;
if (i === path.length - 1) {
f[path[i]] = value;
} else {
f = f[path[i]];
}
}
}
function makeNodeMap(tree: Tree, map: Map<string, (string | number)[]>, currentPath: (string | number)[]) {
if (tree.ref) {
map.set(tree.ref, [...currentPath]);
}
if (!tree.children) return map;
currentPath.push('children');
for (let i = 0; i < tree.children.length; i++) {
const child = tree.children[i];
currentPath.push(i);
makeNodeMap(child, map, currentPath);
currentPath.pop();
}
currentPath.pop();
return map;
}
function makePaletteFunction(props: MVSAnimationNode<'interpolate'>, start: ColorT | undefined | null, end: ColorT | undefined | null): ((value: number) => Color) | undefined {
if (props.params.kind !== 'color') return undefined;
const params = props.params.palette
? palettePropsFromMVSPalette(props.params.palette)
: palettePropsFromMVSPalette({ kind: 'continuous', colors: [start ?? 'black', end ?? start ?? 'black'] });
if (params.name === 'discrete') return makePaletteFunctionDiscrete(params.params);
if (params.name === 'continuous') return makePaletteFunctionContinuous(params.params);
throw new Error(`NotImplementedError: makePaletteFunction for ${(props as any).name}`);
}
function makePaletteFunctionDiscrete(props: MVSDiscretePaletteProps): (value: number) => Color {
const defaultColor = Color(0x0);
if (props.colors.length === 0) return () => defaultColor;
return (value: number) => {
const x = clamp(value, 0, 1);
for (let i = props.colors.length - 1; i >= 0; i--) {
const { color, fromValue, toValue } = props.colors[i];
if (fromValue <= x && x <= toValue) return color;
}
return defaultColor;
};
}
function makePaletteFunctionContinuous(props: MVSContinuousPaletteProps): (value: number) => Color {
const defaultColor = Color(0x0);
const { colors, checkpoints } = makeContinuousPaletteCheckpoints(props);
if (colors.length === 0) return () => defaultColor;
const underflowColor = props.setUnderflowColor ? props.underflowColor : defaultColor;
const overflowColor = props.setOverflowColor ? props.overflowColor : defaultColor;
return (value: number) => {
const x = clamp(value, 0, 1);
const gteIdx = SortedArray.findPredecessorIndex(checkpoints, x); // Index of the first greater or equal checkpoint
if (gteIdx === 0) {
if (x === checkpoints[0]) return colors[0];
else return underflowColor;
}
if (gteIdx === checkpoints.length) {
return overflowColor;
}
const q = (x - checkpoints[gteIdx - 1]) / (checkpoints[gteIdx] - checkpoints[gteIdx - 1]);
return Color.interpolate(colors[gteIdx - 1], colors[gteIdx], q);
};
}
const RelativeAxisAngleState = {
Rt: Mat3(),
R: Mat3(),
};
function relativeAxisAngle(start: Mat3, end: Mat3): { axis: Vec3, angle: number } {
// R_rel = end * start^T
const R0 = start, R1 = end;
const Rt = Mat3.transpose(RelativeAxisAngleState.Rt, R0);
const R = Mat3.mul(RelativeAxisAngleState.R, R1, Rt);
const tr = R[0] + R[4] + R[8]; // trace
let angle = Math.acos(clamp((tr - 1) * 0.5, -1, 1)); // in [0, π]
const axis = Vec3();
const eps = 1e-6;
const sinA = Math.sin(angle);
if (angle < eps) {
// Near identity: axis undefined; return any unit axis (choose something stable)
Vec3.set(axis, 0, 0, 1);
angle = 0.0;
return { axis, angle };
}
if (Math.PI - angle > 1e-4) {
// General case
axis[0] = (R[5] - R[7]) / (2 * sinA); // (r32 - r23)
axis[1] = (R[6] - R[2]) / (2 * sinA); // (r13 - r31)
axis[2] = (R[1] - R[3]) / (2 * sinA); // (r21 - r12)
Vec3.normalize(axis, axis);
return { axis, angle };
}
// angle ~ π: use diagonal-based extraction for stability
// Compute squared components then pick the largest to avoid precision loss
const xx = Math.max(0, (R[0] + 1) * 0.5);
const yy = Math.max(0, (R[4] + 1) * 0.5);
const zz = Math.max(0, (R[8] + 1) * 0.5);
let x = Math.sqrt(xx), y = Math.sqrt(yy), z = Math.sqrt(zz);
if (x >= y && x >= z) {
x = Math.max(x, 1e-8);
y = (R[1] + R[3]) / (4 * x);
z = (R[2] + R[6]) / (4 * x);
Vec3.set(axis, x, y, z);
} else if (y >= x && y >= z) {
y = Math.max(y, 1e-8);
x = (R[1] + R[3]) / (4 * y);
z = (R[5] + R[7]) / (4 * y);
Vec3.set(axis, x, y, z);
} else {
z = Math.max(z, 1e-8);
x = (R[2] + R[6]) / (4 * z);
y = (R[5] + R[7]) / (4 * z);
Vec3.set(axis, x, y, z);
}
Vec3.normalize(axis, axis);
return { axis, angle: Math.PI };
}

View File

@@ -0,0 +1,145 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Adam Midlik <midlik@gmail.com>
*/
import { ElementSymbolColors } from '../../../mol-theme/color/element-symbol';
import { ResidueNameColors } from '../../../mol-theme/color/residue-name';
import { SecondaryStructureColors as SecStrColors } from '../../../mol-theme/color/secondary-structure';
import { Color } from '../../../mol-util/color';
import { ColorList } from '../../../mol-util/color/color';
import { ColorLists } from '../../../mol-util/color/lists';
import { omitObjectKeys } from '../../../mol-util/object';
import { ColorDictNameT, ColorListNameT } from '../tree/mvs/param-types';
import { decodeColor } from './utils';
/** Colors for amino acid groups, based on Clustal (https://www.jalview.org/help/html/colourSchemes/clustal.html) */
const AminoGroupColors = {
aromatic: decodeColor('#15A4A4')!,
hydrophobic: decodeColor('#80A0F0')!,
polar: decodeColor('#15C015')!,
positive: decodeColor('#F01505')!,
negative: decodeColor('#C048C0')!,
proline: decodeColor('#C0C000')!,
cysteine: decodeColor('#F08080')!,
glycine: decodeColor('#F09048')!,
};
/** Colors for individual amino acids, based on Clustal (https://www.jalview.org/help/html/colourSchemes/clustal.html), plus Jmol colors for nucleotides (http://jmol.sourceforge.net/jscolors/) */
const ResiduePropertyColors = {
...ResidueNameColors,
HIS: AminoGroupColors.aromatic,
TYR: AminoGroupColors.aromatic,
ALA: AminoGroupColors.hydrophobic,
VAL: AminoGroupColors.hydrophobic,
LEU: AminoGroupColors.hydrophobic,
ILE: AminoGroupColors.hydrophobic,
MET: AminoGroupColors.hydrophobic,
PHE: AminoGroupColors.hydrophobic,
TRP: AminoGroupColors.hydrophobic,
SER: AminoGroupColors.polar,
THR: AminoGroupColors.polar,
ASN: AminoGroupColors.polar,
GLN: AminoGroupColors.polar,
LYS: AminoGroupColors.positive,
ARG: AminoGroupColors.positive,
ASP: AminoGroupColors.negative,
GLU: AminoGroupColors.negative,
PRO: AminoGroupColors.proline,
CYS: AminoGroupColors.cysteine,
GLY: AminoGroupColors.glycine,
};
/** Colors for secondary structure types, based on Jmol colors (http://jmol.sourceforge.net/jscolors/) */
const SecondaryStructureColors = {
// Simple categories
helix: SecStrColors.alphaHelix,
strand: SecStrColors.betaStrand,
turn: SecStrColors.betaTurn,
bend: SecStrColors.bend,
// DSSP categories
H: SecStrColors.alphaHelix,
B: SecStrColors.betaStrand,
E: SecStrColors.betaStrand,
G: SecStrColors.threeTenHelix,
I: SecStrColors.piHelix,
P: Color(0xA00000), // Polyproline II helix, Jmol has no color for it
T: SecStrColors.betaTurn,
S: SecStrColors.bend,
};
export const MvsNamedColorDicts: Record<ColorDictNameT, Record<string, Color>> = {
ElementSymbol: omitObjectKeys(ElementSymbolColors, ['C']), // ommitting carbon color to allow easier combination of multiple color layers
ResidueName: ResidueNameColors,
ResidueProperties: ResiduePropertyColors,
SecondaryStructure: SecondaryStructureColors,
};
export const MvsNamedColorLists: Record<ColorListNameT, ColorList> = {
// Sequential single-hue
Reds: ColorLists['reds'],
Oranges: ColorLists['oranges'],
Greens: ColorLists['greens'],
Blues: ColorLists['blues'],
Purples: ColorLists['purples'],
Greys: ColorLists['greys'],
// Sequential multi-hue
OrRd: ColorLists['orange-red'],
BuGn: ColorLists['blue-green'],
PuBuGn: ColorLists['purple-blue-green'],
GnBu: ColorLists['green-blue'],
PuBu: ColorLists['purple-blue'],
BuPu: ColorLists['blue-purple'],
RdPu: ColorLists['red-purple'],
PuRd: ColorLists['purple-red'],
YlOrRd: ColorLists['yellow-orange-red'],
YlOrBr: ColorLists['yellow-orange-brown'],
YlGn: ColorLists['yellow-green'],
YlGnBu: ColorLists['yellow-green-blue'],
Magma: ColorLists['magma'],
Inferno: ColorLists['inferno'],
Plasma: ColorLists['plasma'],
Viridis: ColorLists['viridis'],
Cividis: ColorLists['cividis'],
Turbo: ColorLists['turbo'],
Warm: ColorLists['warm'],
Cool: ColorLists['cool'],
CubehelixDefault: ColorLists['cubehelix-default'],
// Cyclical
Rainbow: ColorLists['rainbow'],
Sinebow: ColorLists['sinebow'],
// Diverging
RdBu: ColorLists['red-blue'],
RdGy: ColorLists['red-grey'],
PiYG: ColorLists['pink-yellow-green'],
BrBG: ColorLists['brown-white-green'],
PRGn: ColorLists['purple-green'],
PuOr: ColorLists['purple-orange'],
RdYlGn: ColorLists['red-yellow-green'],
RdYlBu: ColorLists['red-yellow-blue'],
Spectral: ColorLists['spectral'],
// Categorical
Category10: ColorLists['category-10'],
Observable10: ColorLists['observable-10'],
Tableau10: ColorLists['tableau-10'],
Set1: ColorLists['set-1'],
Set2: ColorLists['set-2'],
Set3: ColorLists['set-3'],
Pastel1: ColorLists['pastel-1'],
Pastel2: ColorLists['pastel-2'],
Dark2: ColorLists['dark-2'],
Paired: ColorLists['paired'],
Accent: ColorLists['accent'],
// Additional lists, not standard for visualization in general, but commonly used for structures
Chainbow: ColorLists['turbo-no-black'],
};

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2023-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Adam Midlik <midlik@gmail.com>
*/
@@ -19,6 +19,12 @@ export interface IndicesAndSortings {
residuesSortedByLabelSeqId: Mapping<ChainIndex, Sorting<ResidueIndex, number>>,
residuesSortedByAuthSeqId: Mapping<ChainIndex, Sorting<ResidueIndex, number>>,
residuesByInsCode: Mapping<ChainIndex, Mapping<string, readonly ResidueIndex[]>>,
residuesByLabelCompId: Mapping<ChainIndex, Mapping<string, readonly ResidueIndex[]>>,
/** Indicates if each residue is listed only once in `residuesByLabelCompId` (i.e. if each residue has only one label_comp_id) */
residuesByLabelCompIdIsPure: boolean,
residuesByAuthCompId: Mapping<ChainIndex, Mapping<string, readonly ResidueIndex[]>>,
/** Indicates if each residue is listed only once in `residuesByAuthCompId` (i.e. if each residue has only one auth_comp_id) */
residuesByAuthCompIdIsPure: boolean,
atomsById: Mapping<number, ElementIndex>,
atomsByIndex: Mapping<number, ElementIndex>,
}
@@ -36,6 +42,7 @@ export const IndicesAndSortings = {
const nChains = h.chains._rowCount;
const { label_entity_id, label_asym_id, auth_asym_id } = h.chains;
const { label_seq_id, auth_seq_id, pdbx_PDB_ins_code } = h.residues;
const { label_comp_id, auth_comp_id } = h.atoms;
const { Present } = Column.ValueKind;
const chainsByLabelEntityId = new MultiMap<string, ChainIndex>();
@@ -44,9 +51,16 @@ export const IndicesAndSortings = {
const residuesSortedByLabelSeqId = new Map<ChainIndex, Sorting<ResidueIndex, number>>();
const residuesSortedByAuthSeqId = new Map<ChainIndex, Sorting<ResidueIndex, number>>();
const residuesByInsCode = new Map<ChainIndex, MultiMap<string, ResidueIndex>>();
const residuesByLabelCompId = new Map<ChainIndex, MultiMap<string, ResidueIndex>>();
let residuesByLabelCompIdIsPure = true;
const residuesByAuthCompId = new Map<ChainIndex, MultiMap<string, ResidueIndex>>();
let residuesByAuthCompIdIsPure = true;
const atomsById = new NumberMap<number, ElementIndex>(nAtoms + 1);
const atomsByIndex = new NumberMap<number, ElementIndex>(nAtoms);
const _labelCompIdSet = new Set<string>();
const _authCompIdSet = new Set<string>();
for (let iChain = 0 as ChainIndex; iChain < nChains; iChain++) {
chainsByLabelEntityId.add(label_entity_id.value(iChain), iChain);
chainsByLabelAsymId.add(label_asym_id.value(iChain), iChain);
@@ -62,12 +76,28 @@ export const IndicesAndSortings = {
residuesSortedByAuthSeqId.set(iChain, Sorting.create(residuesWithAuthSeqId, auth_seq_id.value));
const residuesHereByInsCode = new MultiMap<string, ResidueIndex>();
const residuesHereByLabelCompId = new MultiMap<string, ResidueIndex>();
const residuesHereByAuthCompId = new MultiMap<string, ResidueIndex>();
for (let iRes = iResFrom; iRes < iResTo; iRes++) {
if (pdbx_PDB_ins_code.valueKind(iRes) === Present) {
residuesHereByInsCode.add(pdbx_PDB_ins_code.value(iRes), iRes);
}
const iAtomFrom = h.residueAtomSegments.offsets[iRes];
const iAtomTo = h.residueAtomSegments.offsets[iRes + 1];
for (let iAtom = iAtomFrom; iAtom < iAtomTo; iAtom++) {
_labelCompIdSet.add(label_comp_id.value(iAtom));
_authCompIdSet.add(auth_comp_id.value(iAtom));
}
if (_labelCompIdSet.size > 1) residuesByLabelCompIdIsPure = false;
if (_authCompIdSet.size > 1) residuesByAuthCompIdIsPure = false;
for (const labelCompId of _labelCompIdSet) residuesHereByLabelCompId.add(labelCompId, iRes);
for (const authCompId of _authCompIdSet) residuesHereByAuthCompId.add(authCompId, iRes);
_labelCompIdSet.clear();
_authCompIdSet.clear();
}
residuesByInsCode.set(iChain, residuesHereByInsCode);
residuesByLabelCompId.set(iChain, residuesHereByLabelCompId);
residuesByAuthCompId.set(iChain, residuesHereByAuthCompId);
}
const atomId = model.atomicConformation.atomId.value;
@@ -80,6 +110,7 @@ export const IndicesAndSortings = {
return {
chainsByLabelEntityId, chainsByLabelAsymId, chainsByAuthAsymId,
residuesSortedByLabelSeqId, residuesSortedByAuthSeqId, residuesByInsCode,
residuesByLabelCompId, residuesByLabelCompIdIsPure, residuesByAuthCompId, residuesByAuthCompIdIsPure,
atomsById, atomsByIndex,
};
},

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2023-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Adam Midlik <midlik@gmail.com>
*/
@@ -7,13 +7,13 @@
import { Sphere3D } from '../../../mol-math/geometry';
import { BoundaryHelper } from '../../../mol-math/geometry/boundary-helper';
import { Vec3 } from '../../../mol-math/linear-algebra';
import { ElementIndex, Model, Structure, StructureElement, StructureProperties } from '../../../mol-model/structure';
import { UUID } from '../../../mol-util';
import { ElementIndex, Model, Structure, StructureElement, StructureProperties, Unit } from '../../../mol-model/structure';
import { arrayExtend } from '../../../mol-util/array';
import { AtomRanges } from './atom-ranges';
import { IndicesAndSortings } from './indexing';
import { MVSAnnotationRow } from './schemas';
import { getAtomRangesForRows } from './selections';
import { isDefined } from './utils';
/** Properties describing position, size, etc. of a text in 3D */
@@ -34,9 +34,25 @@ const boundaryHelper = new BoundaryHelper('98');
const outAtoms: ElementIndex[] = [];
const outFirstAtomIndex: { value?: number } = {};
/** Helper for caching atom ranges qualifying to a group of annotation rows, per `Unit`. */
class AtomRangesCache {
private readonly cache: { [key: string]: AtomRanges } = {};
private readonly hasOperators: boolean;
constructor(private readonly rows: MVSAnnotationRow[]) {
this.hasOperators = rows.some(row => isDefined(row.instance_id));
}
get(unit: Unit): AtomRanges {
const instanceId = unit.conformation.operator.instanceId;
const key = this.hasOperators ? `${unit.model.id}:${instanceId}` : unit.model.id;
return this.cache[key] ??= getAtomRangesForRows(this.rows, unit.model, instanceId, IndicesAndSortings.get(unit.model));
}
}
/** Return `TextProps` (position, size, etc.) for a text that is to be bound to a substructure of `structure` defined by union of `rows`.
* Derives `center` and `depth` from the boundary sphere of the substructure, `scale` from the number of heavy atoms in the substructure. */
export function textPropsForSelection(structure: Structure, sizeFunction: (location: StructureElement.Location) => number, rows: MVSAnnotationRow | MVSAnnotationRow[], onlyInModel?: Model): TextProps | undefined {
export function textPropsForSelection(structure: Structure, sizeFunction: (location: StructureElement.Location) => number, rows: MVSAnnotationRow[], onlyInModel?: Model): TextProps | undefined {
const loc = StructureElement.Location.create(structure);
const { units } = structure;
const { type_symbol } = StructureProperties.atom;
@@ -45,11 +61,11 @@ export function textPropsForSelection(structure: Structure, sizeFunction: (locat
let includedHeavyAtoms = 0;
let group: number | undefined = undefined;
let atomSize: number | undefined = undefined;
const rangesByModel: { [modelId: UUID]: AtomRanges } = {};
const atomRangesCache = new AtomRangesCache(rows);
for (let iUnit = 0, nUnits = units.length; iUnit < nUnits; iUnit++) {
const unit = units[iUnit];
if (onlyInModel && unit.model.id !== onlyInModel.id) continue;
const ranges = rangesByModel[unit.model.id] ??= getAtomRangesForRows(unit.model, rows, IndicesAndSortings.get(unit.model));
const ranges = atomRangesCache.get(unit);
loc.unit = unit;
AtomRanges.selectAtomsInRanges(unit.elements, ranges, outAtoms, outFirstAtomIndex);
for (const atom of outAtoms) {

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2023-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Adam Midlik <midlik@gmail.com>
*/
@@ -7,29 +7,44 @@
import { ParamDefinition as PD } from '../../../mol-util/param-definition';
/** Similar to `PD.Numeric` but allows leaving empty field in UI (treated as `undefined`) */
export function MaybeIntegerParamDefinition(defaultValue?: number, info?: PD.Info): PD.Base<number | undefined> {
return PD.Converted<number | undefined, PD.Text>(stringifyMaybeInt, parseMaybeInt, PD.Text(stringifyMaybeInt(defaultValue), info));
/** Similar to `PD.Numeric` but allows leaving empty field in UI (treated as `undefined`), and can only have integer values */
export function MaybeIntegerParamDefinition(info?: PD.Info & { placeholder?: string }): PD.Converted<number | null, string> {
const defaultValue = null; // the default must be null, otherwise real nulls would be replaced by the default
return PD.Converted(stringifyMaybeInt, parseMaybeInt, PD.Text(stringifyMaybeInt(defaultValue), { ...info, disableInteractiveUpdates: true, placeholder: info?.placeholder ?? 'null' }));
}
/** The magic with negative zero looks crazy, but it's needed if we want to be able to write negative numbers, LOL. Please help if you know a better solution. */
function parseMaybeInt(input: string): number | undefined {
if (input.trim() === '-') return -0;
function parseMaybeInt(input: string): number | null {
const num = parseInt(input);
return isNaN(num) ? undefined : num;
return isNaN(num) ? null : num;
}
function stringifyMaybeInt(num: number | undefined): string {
if (num === undefined) return '';
if (Object.is(num, -0)) return '-';
function stringifyMaybeInt(num: number | null): string {
if (num === null) return '';
return num.toString();
}
/** Similar to `PD.Numeric` but allows leaving empty field in UI (treated as `undefined`) */
export function MaybeFloatParamDefinition(info?: PD.Info & { placeholder?: string }): PD.Converted<number | null, string> {
const defaultValue = null; // the default must be null, otherwise real nulls would be replaced by the default
return PD.Converted(stringifyMaybeFloat, parseMaybeFloat, PD.Text(stringifyMaybeFloat(defaultValue), { ...info, disableInteractiveUpdates: true, placeholder: info?.placeholder ?? 'null' }));
}
function parseMaybeFloat(input: string): number | null {
const num = parseFloat(input);
return isNaN(num) ? null : num;
}
function stringifyMaybeFloat(num: number | null): string {
if (num === null) return '';
return num.toString();
}
/** Similar to `PD.Text` but leaving empty field in UI is treated as `undefined` */
export function MaybeStringParamDefinition(defaultValue?: string, info?: PD.Info): PD.Base<string | undefined> {
return PD.Converted<string | undefined, PD.Text>(stringifyMaybeString, parseMaybeString, PD.Text(stringifyMaybeString(defaultValue), info));
export function MaybeStringParamDefinition(info?: PD.Info & { placeholder?: string }): PD.Converted<string | null, string> {
const defaultValue = null; // the default must be null, otherwise real nulls would be replaced by the default
return PD.Converted(stringifyMaybeString, parseMaybeString, PD.Text(stringifyMaybeString(defaultValue), { ...info, placeholder: info?.placeholder ?? 'null' }));
}
function parseMaybeString(input: string): string | undefined {
return input === '' ? undefined : input;
function parseMaybeString(input: string): string | null {
return input === '' ? null : input;
}
function stringifyMaybeString(str: string | undefined): string {
return str === undefined ? '' : str;
function stringifyMaybeString(str: string | null): string {
return str === null ? '' : str;
}

View File

@@ -63,6 +63,9 @@ const AllAtomicCifAnnotationSchema = {
beg_auth_seq_id: int,
/** Maximum auth_seq_id (inclusive) */
end_auth_seq_id: int,
label_comp_id: str,
auth_comp_id: str,
// residue_index: int, // 0-based residue index in the source file // TODO this is defined in Python builder but not supported by Molstar yet
/** Atom name like 'CA', 'N', 'O'... */
label_atom_id: str,
@@ -74,6 +77,9 @@ const AllAtomicCifAnnotationSchema = {
atom_id: int,
/** 0-based index of the atom in the source data */
atom_index: int,
/** Instance identifier to distinguish instances of the same chain created by applying different symmetry operators,
* like 'ASM-X0-1' for assemblies or '1_555' for crystals */
instance_id: str,
} satisfies Table.Schema;
/** Allowed fields (i.e. CIF columns or JSON keys) for each annotation schema

View File

@@ -19,13 +19,19 @@ const EmptyArray: readonly any[] = [];
/** Return atom ranges in `model` which satisfy criteria given by `row` */
export function getAtomRangesForRow(model: Model, row: MVSAnnotationRow, indices: IndicesAndSortings): AtomRanges {
export function getAtomRangesForRow(row: MVSAnnotationRow, model: Model, instanceId: string, indices: IndicesAndSortings): AtomRanges {
if (isDefined(row.instance_id) && row.instance_id !== instanceId) return AtomRanges.empty();
const h = model.atomicHierarchy;
const nAtoms = h.atoms._rowCount;
const hasAtomIds = isAnyDefined(row.atom_id, row.atom_index);
const hasAtomFilter = isAnyDefined(row.label_atom_id, row.auth_atom_id, row.type_symbol);
const hasResidueFilter = isAnyDefined(row.label_seq_id, row.auth_seq_id, row.pdbx_PDB_ins_code, row.beg_label_seq_id, row.end_label_seq_id, row.beg_auth_seq_id, row.end_auth_seq_id);
const hasAtomFilter = isAnyDefined(row.label_atom_id, row.auth_atom_id, row.type_symbol)
|| isDefined(row.label_comp_id) && !indices.residuesByLabelCompIdIsPure
|| isDefined(row.auth_comp_id) && !indices.residuesByAuthCompIdIsPure;
const hasResidueFilter = isAnyDefined(row.label_seq_id, row.auth_seq_id, row.pdbx_PDB_ins_code,
row.beg_label_seq_id, row.end_label_seq_id, row.beg_auth_seq_id, row.end_auth_seq_id,
row.label_comp_id, row.auth_comp_id);
const hasChainFilter = isAnyDefined(row.label_asym_id, row.auth_asym_id, row.label_entity_id);
if (hasAtomIds) {
@@ -66,12 +72,8 @@ export function getAtomRangesForRow(model: Model, row: MVSAnnotationRow, indices
}
/** Return atom ranges in `model` which satisfy criteria given by any of `rows` (atoms that satisfy more rows are still included only once) */
export function getAtomRangesForRows(model: Model, rows: MVSAnnotationRow | MVSAnnotationRow[], indices: IndicesAndSortings): AtomRanges {
if (Array.isArray(rows)) {
return AtomRanges.union(rows.map(row => getAtomRangesForRow(model, row, indices)));
} else {
return getAtomRangesForRow(model, rows, indices);
}
export function getAtomRangesForRows(rows: MVSAnnotationRow[], model: Model, instanceId: string, indices: IndicesAndSortings): AtomRanges {
return AtomRanges.union(rows.map(row => getAtomRangesForRow(row, model, instanceId, indices)));
}
@@ -103,6 +105,8 @@ function getQualifyingChains(model: Model, row: MVSAnnotationRow, indices: Indic
/** Return an array of residue indexes which satisfy criteria given by `row` */
function getQualifyingResidues(model: Model, row: MVSAnnotationRow, indices: IndicesAndSortings, fromChains: readonly ChainIndex[]): ResidueIndex[] {
const { label_seq_id, auth_seq_id, pdbx_PDB_ins_code } = model.atomicHierarchy.residues;
const { label_comp_id, auth_comp_id } = model.atomicHierarchy.atoms;
const { residueAtomSegments, chainAtomSegments } = model.atomicHierarchy;
const { Present } = Column.ValueKind;
const result: ResidueIndex[] = [];
for (const iChain of fromChains) {
@@ -152,8 +156,37 @@ function getQualifyingResidues(model: Model, row: MVSAnnotationRow, indices: Ind
residuesHere = Sorting.getKeysWithValueInRange(sorting, row.beg_auth_seq_id, row.end_auth_seq_id);
}
}
if (isDefined(row.label_comp_id)) {
if (residuesHere) {
if (indices.residuesByLabelCompIdIsPure) {
residuesHere = residuesHere.filter(i => label_comp_id.value(residueAtomSegments.offsets[i]) === row.label_comp_id);
} else {
residuesHere = residuesHere.filter(i => {
for (let iAtom = residueAtomSegments.offsets[i], stop = residueAtomSegments.offsets[i + 1]; iAtom < stop; iAtom++) {
if (label_comp_id.value(iAtom) === row.label_comp_id) return true;
}
});
}
} else {
residuesHere = indices.residuesByLabelCompId.get(iChain)!.get(row.label_comp_id) ?? EmptyArray;
}
}
if (isDefined(row.auth_comp_id)) {
if (residuesHere) {
if (indices.residuesByAuthCompIdIsPure) {
residuesHere = residuesHere.filter(i => auth_comp_id.value(residueAtomSegments.offsets[i]) === row.auth_comp_id);
} else {
residuesHere = residuesHere.filter(i => {
for (let iAtom = residueAtomSegments.offsets[i], stop = residueAtomSegments.offsets[i + 1]; iAtom < stop; iAtom++) {
if (auth_comp_id.value(iAtom) === row.auth_comp_id) return true;
}
});
}
} else {
residuesHere = indices.residuesByAuthCompId.get(iChain)!.get(row.auth_comp_id) ?? EmptyArray;
}
}
if (!residuesHere) {
const { residueAtomSegments, chainAtomSegments } = model.atomicHierarchy;
const firstResidueForChain = residueAtomSegments.index[chainAtomSegments.offsets[iChain]];
const firstResidueAfterChain = residueAtomSegments.index[chainAtomSegments.offsets[iChain + 1] - 1] + 1;
residuesHere = range(firstResidueForChain, firstResidueAfterChain) as ResidueIndex[];
@@ -165,7 +198,7 @@ function getQualifyingResidues(model: Model, row: MVSAnnotationRow, indices: Ind
/** Return an array of atom indexes which satisfy criteria given by `row` */
function getQualifyingAtoms(model: Model, row: MVSAnnotationRow, indices: IndicesAndSortings, fromResidues: readonly ResidueIndex[]): ElementIndex[] {
const { label_atom_id, auth_atom_id, type_symbol } = model.atomicHierarchy.atoms;
const { label_atom_id, auth_atom_id, type_symbol, label_comp_id, auth_comp_id } = model.atomicHierarchy.atoms;
const residueAtomSegments_offsets = model.atomicHierarchy.residueAtomSegments.offsets;
const result: ElementIndex[] = [];
for (const iRes of fromResidues) {
@@ -179,6 +212,12 @@ function getQualifyingAtoms(model: Model, row: MVSAnnotationRow, indices: Indice
if (isDefined(row.type_symbol)) {
filterInPlace(atomIdcs, iAtom => type_symbol.value(iAtom) === row.type_symbol?.toUpperCase());
}
if (isDefined(row.label_comp_id) && !indices.residuesByLabelCompIdIsPure) {
filterInPlace(atomIdcs, iAtom => label_comp_id.value(iAtom) === row.label_comp_id);
}
if (isDefined(row.auth_comp_id) && !indices.residuesByAuthCompIdIsPure) {
filterInPlace(atomIdcs, iAtom => auth_comp_id.value(iAtom) === row.auth_comp_id);
}
arrayExtend(result, atomIdcs);
}
return result;
@@ -228,11 +267,15 @@ export function atomQualifies(model: Model, iAtom: ElementIndex, row: MVSAnnotat
if (!matchesRange(row.beg_label_seq_id, row.end_label_seq_id, label_seq_id)) return false;
if (!matchesRange(row.beg_auth_seq_id, row.end_auth_seq_id, auth_seq_id)) return false;
const label_comp_id = h.atoms.label_comp_id.value(iAtom);
const auth_comp_id = h.atoms.auth_comp_id.value(iAtom);
const label_atom_id = h.atoms.label_atom_id.value(iAtom);
const auth_atom_id = h.atoms.auth_atom_id.value(iAtom);
const type_symbol = h.atoms.type_symbol.value(iAtom);
const atom_id = model.atomicConformation.atomId.value(iAtom);
const atom_index = h.atomSourceIndex.value(iAtom);
if (!matches(row.label_comp_id, label_comp_id)) return false;
if (!matches(row.auth_comp_id, auth_comp_id)) return false;
if (!matches(row.label_atom_id, label_atom_id)) return false;
if (!matches(row.auth_atom_id, auth_atom_id)) return false;
if (!matches(row.type_symbol?.toUpperCase(), type_symbol)) return false;
@@ -287,7 +330,7 @@ export function groupRows(rows: readonly MVSAnnotationRow[]): GroupedArray<numbe
const groups: number[] = [];
for (let i = 0; i < rows.length; i++) {
const group_id = rows[i].group_id;
if (group_id === undefined) {
if (!isDefined(group_id)) {
groups.push(counter++);
} else {
const groupIndex = groupMap.get(group_id);

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2023-2024 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2023-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Adam Midlik <midlik@gmail.com>
* @author David Sehnal <david.sehnal@gmail.com>
@@ -9,6 +9,7 @@ import { hashString } from '../../../mol-data/util';
import { StateObject } from '../../../mol-state';
import { Color } from '../../../mol-util/color';
import { ColorNames } from '../../../mol-util/color/names';
import { decodeColor as _decodeColor } from '../../../mol-util/color/utils';
/** Represents either the result or the reason of failure of an operation that might have failed */
@@ -75,7 +76,7 @@ export function isDefined<T>(value: T | undefined | null): value is T {
}
/** Return `true` if at least one of `values` is not `undefined` or `null`. */
export function isAnyDefined(...values: any[]): boolean {
return values.some(v => isDefined(v));
return values.some(isDefined);
}
/** Return filtered array containing all original elements except `undefined` or `null`. */
export function filterDefined<T>(elements: (T | undefined | null)[]): T[] {
@@ -99,20 +100,11 @@ export type ElementOfSet<S> = S extends Set<infer T> ? T : never
/** Convert `colorString` (either X11 color name like 'magenta' or hex code like '#ff00ff') to Color.
* Return `undefined` if `colorString` cannot be converted. */
export function decodeColor(colorString: string | undefined | null): Color | undefined {
if (colorString === undefined || colorString === null) return undefined;
let result: Color | undefined;
if (HexColor.is(colorString)) {
if (colorString.length === 4) {
// convert short form to full form (#f0f -> #ff00ff)
colorString = `#${colorString[1]}${colorString[1]}${colorString[2]}${colorString[2]}${colorString[3]}${colorString[3]}`;
}
result = Color.fromHexStyle(colorString);
if (result !== undefined && !isNaN(result)) return result;
export function decodeColor(colorString: string | number | undefined | null): Color | undefined {
if (typeof colorString === 'number') {
return Color(colorString);
}
result = ColorNames[colorString.toLowerCase() as keyof typeof ColorNames];
if (result !== undefined) return result;
return undefined;
return _decodeColor(colorString);
}
/** Regular expression matching a hexadecimal color string, e.g. '#FF1100' or '#f10' */
@@ -160,4 +152,25 @@ export function collectMVSReferences<T extends StateObject.Ctor>(type: T[], depe
}
return ret;
}
export function getMVSReferenceObject<T extends StateObject.Ctor>(type: T[], dependencies: Record<string, StateObject> | undefined, ref: string): StateObject | undefined {
if (!dependencies) return undefined;
for (const key of Object.keys(dependencies)) {
const o = dependencies[key];
let okType = false;
for (const t of type) {
if (t.is(o)) {
okType = true;
break;
}
}
if (!okType || !o.tags) continue;
for (const tag of o.tags) {
if (tag.startsWith('mvs-ref:')) {
if (tag.substring(8) === ref) return o;
}
}
}
}

View File

@@ -0,0 +1,17 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { MolstarLoadingExtension } from '../load';
export const IsHiddenCustomStateExtension: MolstarLoadingExtension<{}> = {
id: 'ww-pdb/is-hidden-custom-state',
description: 'Allow updating initial visibility of nodes',
createExtensionContext: () => ({}),
action: (updateTarget, node) => {
if (!node.custom || !node.custom?.is_hidden) return;
updateTarget.update.to(updateTarget.selector).updateState({ isHidden: true });
},
};

View File

@@ -37,21 +37,6 @@ export interface LoadingExtension<TTree extends Tree, TContext, TExtensionContex
}
/** Load a tree into Mol*, by applying loading actions in DFS order and then commiting at once.
* If `options.replaceExisting`, remove all objects in the current Mol* state; otherwise add to the current state. */
export async function loadTree<TTree extends Tree, TContext>(
plugin: PluginContext,
tree: TTree,
loadingActions: LoadingActions<TTree, TContext>,
context: TContext,
options?: { replaceExisting?: boolean, extensions?: LoadingExtension<TTree, TContext, any>[] }
) {
const updateRoot: UpdateTarget = UpdateTarget.create(plugin, options?.replaceExisting ?? false);
loadTreeInUpdate(updateRoot, tree, loadingActions, context, options);
await UpdateTarget.commit(updateRoot);
}
export function loadTreeVirtual<TTree extends Tree, TContext>(
plugin: PluginContext,
tree: TTree,
@@ -61,7 +46,7 @@ export function loadTreeVirtual<TTree extends Tree, TContext>(
) {
const updateRoot: UpdateTarget = UpdateTarget.create(plugin, options?.replaceExisting ?? false);
loadTreeInUpdate(updateRoot, tree, loadingActions, context, options);
const stateTree: StateTree = updateRoot.update.getTree();
const stateTree: StateTree = updateRoot.update.getTree({ useHashVersion: true });
const stateSnapshot: State.Snapshot = { tree: StateTree.toJSON(stateTree) };
const pluginStateSnapshot: PluginState.Snapshot = { id: UUID.create22(), data: stateSnapshot };
return pluginStateSnapshot;
@@ -133,8 +118,8 @@ export const UpdateTarget = {
/** Create a new update, with `selector` pointing to the root. */
create(plugin: PluginContext, replaceExisting: boolean): UpdateTarget {
const update = plugin.build();
const msTarget = update.toRoot().selector;
return { update, selector: msTarget, targetManager: new TargetManager(plugin, replaceExisting), mvsDependencyRefs: new Set() };
const msTarget = update.toRoot();
return { update, selector: msTarget.selector, targetManager: new TargetManager(plugin, replaceExisting), mvsDependencyRefs: new Set() };
},
/** Add a child node to `target.selector`, return a new `UpdateTarget` pointing to the new child. */
apply<A extends StateObject, B extends StateObject, P extends {}>(target: UpdateTarget, transformer: StateTransformer<A, B, P>, params?: Partial<P>, options?: Partial<StateTransform.Options>): UpdateTarget {
@@ -144,8 +129,8 @@ export const UpdateTarget = {
refSuffix += `:${reprType}`;
}
const ref = target.targetManager.getChildRef(target.selector, refSuffix);
const msResult = target.update.to(target.selector).apply(transformer, params, { ...options, ref }).selector;
const result: UpdateTarget = { ...target, selector: msResult, mvsDependencyRefs: new Set(), transformer, transformParams: params };
const apply = target.update.to(target.selector).apply(transformer, params, { ...options, ref });
const result: UpdateTarget = { ...target, selector: apply.selector, mvsDependencyRefs: new Set(), transformer, transformParams: params };
target.targetManager.allTargets.push(result);
return result;
},

View File

@@ -5,16 +5,20 @@
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { Mat3, Mat4, Vec3 } from '../../mol-math/linear-algebra';
import { Mat3, Mat4, Quat, Vec3 } from '../../mol-math/linear-algebra';
import { Volume } from '../../mol-model/volume';
import { StructureComponentParams } from '../../mol-plugin-state/helpers/structure-component';
import { StructureFromModel, TransformStructureConformation } from '../../mol-plugin-state/transforms/model';
import { StructureFromModel, StructureInstances, TransformStructureConformation } from '../../mol-plugin-state/transforms/model';
import { StructureRepresentation3D, VolumeRepresentation3D } from '../../mol-plugin-state/transforms/representation';
import { VolumeInstances, VolumeTransform } from '../../mol-plugin-state/transforms/volume';
import { StateTransformer } from '../../mol-state';
import { arrayDistinct } from '../../mol-util/array';
import { Clip } from '../../mol-util/clip';
import { Color } from '../../mol-util/color';
import { ColorListEntry } from '../../mol-util/color/color';
import { canonicalJsonString } from '../../mol-util/json';
import { stringToWords } from '../../mol-util/string';
import { MVSAnnotationColorThemeProps, MVSAnnotationColorThemeProvider } from './components/annotation-color-theme';
import { MVSAnnotationColorThemeProps, MVSAnnotationColorThemeProvider, MVSCategoricalPaletteProps, MVSContinuousPaletteProps, MVSDiscretePaletteProps } from './components/annotation-color-theme';
import { MVSAnnotationLabelRepresentationProvider } from './components/annotation-label/representation';
import { MVSAnnotationSpec } from './components/annotation-prop';
import { MVSAnnotationStructureComponentProps } from './components/annotation-structure-component';
@@ -23,13 +27,16 @@ import { CustomLabelTextProps } from './components/custom-label/visual';
import { CustomTooltipsProps } from './components/custom-tooltips-prop';
import { MultilayerColorThemeName, MultilayerColorThemeProps, NoColor } from './components/multilayer-color-theme';
import { SelectorAll } from './components/selector';
import { MvsNamedColorDicts, MvsNamedColorLists } from './helpers/colors';
import { rowToExpression, rowsToExpression } from './helpers/selections';
import { ElementOfSet, decodeColor, isDefined, stringHash } from './helpers/utils';
import { MolstarLoadingContext } from './load';
import { mvsRefTags, UpdateTarget } from './load-generic';
import { Subtree, getChildren } from './tree/generic/tree-schema';
import { dfs, formatObject } from './tree/generic/tree-utils';
import { MolstarKind, MolstarNode, MolstarNodeParams, MolstarSubtree, MolstarTree } from './tree/molstar/molstar-tree';
import { DefaultColor } from './tree/mvs/mvs-tree';
import { CategoricalPalette, CategoricalPaletteDefaults, ColorDictNameT, ColorListNameT, ContinuousPalette, ContinuousPaletteDefaults, DiscretePalette, DiscretePaletteDefaults } from './tree/mvs/param-types';
export const AnnotationFromUriKinds = new Set(['color_from_uri', 'component_from_uri', 'label_from_uri', 'tooltip_from_uri'] satisfies MolstarKind[]);
@@ -55,6 +62,19 @@ export function transformFromRotationTranslation(rotation: number[] | null | und
return T;
}
export function decomposeRotationMatrix(rotation: number[] | null | undefined) {
if (rotation && rotation.length !== 9) throw new Error(`'rotation' param for 'transform' node must be array of 9 elements, found ${rotation}`);
if (rotation) {
const rotMatrix = Mat3.fromArray(Mat3(), rotation, 0);
ensureRotationMatrix(rotMatrix, rotMatrix);
const quat = Quat.fromMat3(Quat(), rotMatrix);
const axis = Vec3();
const angle = Quat.getAxisAngle(axis, quat) * 180 / Math.PI;
return { axis, angle };
}
return { axis: Vec3.create(1, 0, 0), angle: 0 };
}
/** Adjust values in a close-to-rotation matrix `a` to ensure it is a proper rotation matrix
* (i.e. its columns and rows are orthonormal and determinant equal to 1, within available precission). */
function ensureRotationMatrix(out: Mat3, a: Mat3) {
@@ -71,15 +91,65 @@ const _tmpVecX = Vec3();
const _tmpVecY = Vec3();
const _tmpVecZ = Vec3();
/** Create an array of props for `TransformStructureConformation` transformers from all 'transform' nodes applied to a 'structure' node. */
export function transformProps(node: MolstarSubtree<'structure'>): StateTransformer.Params<TransformStructureConformation>[] {
const result = [] as StateTransformer.Params<TransformStructureConformation>[];
const transforms = getChildren(node).filter(c => c.kind === 'transform') as MolstarNode<'transform'>[];
for (const transform of transforms) {
const { rotation, translation } = transform.params;
const matrix = transformFromRotationTranslation(rotation, translation);
result.push({ transform: { name: 'matrix', params: { data: matrix, transpose: false } } });
export function transformAndInstantiateStructure(
target: UpdateTarget,
node: MolstarSubtree<'structure' | 'component' | 'component_from_source' | 'component_from_uri'>,
) {
return applyTransformAndInstances(target, node, TransformStructureConformation, StructureInstances);
}
export function transformAndInstantiateVolume(target: UpdateTarget, node: MolstarSubtree<'volume'>) {
return applyTransformAndInstances(target, node, VolumeTransform, VolumeInstances);
}
function applyTransformAndInstances(target: UpdateTarget, node: MolstarSubtree, transform: StateTransformer, instantiate: StateTransformer) {
let modified = target;
for (const { params, ref } of transformProps(node, 'transform')) {
modified = UpdateTarget.apply(modified, transform, params);
UpdateTarget.tag(modified, mvsRefTags(ref));
}
const instances = transformProps(node, 'instance');
if (instances.length > 0) {
modified = UpdateTarget.apply(modified, instantiate, { transforms: instances.map(i => i.params) });
}
return modified;
}
/** Create an array of props for `TransformStructureConformation` transformers from all 'transform' nodes applied to a 'structure' node. */
function transformProps(node: MolstarSubtree, kind: 'transform' | 'instance') {
const result = [] as { params: StateTransformer.Params<TransformStructureConformation>, ref?: string }[];
const transforms = getChildren(node).filter(c => c.kind === kind) as MolstarNode<'transform'>[];
for (const transform of transforms) {
let matrix: Mat4 | undefined = transform.params.matrix as Mat4 | undefined;
if (!matrix) {
const { rotation, translation, rotation_center } = transform.params;
if (rotation_center) {
const axisAngle = decomposeRotationMatrix(rotation);
result.push({
params: {
transform: {
name: 'components',
params: {
translation: translation ? Vec3.fromArray(Vec3(), translation, 0) : Vec3.create(0, 0, 0),
angle: axisAngle.angle,
axis: axisAngle.axis,
rotationCenter: rotation_center === 'centroid'
? { name: 'centroid', params: {} }
: { name: 'point', params: { point: Vec3.fromArray(Vec3(), rotation_center, 0) } }
}
}
},
ref: transform.ref
});
continue;
}
matrix = transformFromRotationTranslation(rotation, translation);
}
result.push({ params: { transform: { name: 'matrix', params: { data: matrix, transpose: false } } }, ref: transform.ref });
}
return result;
}
@@ -90,10 +160,18 @@ export function collectAnnotationReferences(tree: Subtree<MolstarTree>, context:
let spec: Omit<MVSAnnotationSpec, 'id'> | undefined = undefined;
if (AnnotationFromUriKinds.has(node.kind as any)) {
const p = (node as MolstarNode<AnnotationFromUriKind>).params;
spec = { source: { name: 'url', params: { url: p.uri, format: p.format } }, schema: p.schema, cifBlock: blockSpec(p.block_header, p.block_index), cifCategory: p.category_name ?? undefined };
spec = {
source: { name: 'url', params: { url: p.uri, format: p.format } }, schema: p.schema,
cifBlock: blockSpec(p.block_header, p.block_index), cifCategory: p.category_name,
fieldRemapping: Object.entries(p.field_remapping).map(([key, value]) => ({ standardName: key, actualName: value })),
};
} else if (AnnotationFromSourceKinds.has(node.kind as any)) {
const p = (node as MolstarNode<AnnotationFromSourceKind>).params;
spec = { source: { name: 'source-cif', params: {} }, schema: p.schema, cifBlock: blockSpec(p.block_header, p.block_index), cifCategory: p.category_name ?? undefined };
spec = {
source: { name: 'source-cif', params: {} }, schema: p.schema,
cifBlock: blockSpec(p.block_header, p.block_index), cifCategory: p.category_name,
fieldRemapping: Object.entries(p.field_remapping).map(([key, value]) => ({ standardName: key, actualName: value })),
};
}
if (spec) {
const key = canonicalJsonString(spec as any);
@@ -282,7 +360,7 @@ export function componentFromXProps(node: MolstarNode<'component_from_uri' | 'co
}
/** Create props for `StructureRepresentation3D` transformer from a representation node. */
export function representationProps(node: MolstarSubtree<'representation'>): Partial<StateTransformer.Params<StructureRepresentation3D>> {
function representationPropsBase(node: MolstarSubtree<'representation'>): Partial<StateTransformer.Params<StructureRepresentation3D>> {
const alpha = alphaForNode(node);
const params = node.params;
switch (params.type) {
@@ -291,10 +369,20 @@ export function representationProps(node: MolstarSubtree<'representation'>): Par
type: { name: 'cartoon', params: { alpha, tubularHelices: params.tubular_helices } },
sizeTheme: { name: 'uniform', params: { value: params.size_factor } },
};
case 'backbone':
return {
type: { name: 'backbone', params: { alpha } },
sizeTheme: { name: 'uniform', params: { value: params.size_factor } },
};
case 'ball_and_stick':
return {
type: { name: 'ball-and-stick', params: { sizeFactor: (params.size_factor ?? 1) * 0.5, sizeAspectRatio: 0.5, alpha, ignoreHydrogens: params.ignore_hydrogens } },
};
case 'line':
return {
type: { name: 'line', params: { alpha, ignoreHydrogens: params.ignore_hydrogens } },
sizeTheme: { name: 'uniform', params: { value: params.size_factor } },
};
case 'spacefill':
return {
type: { name: 'spacefill', params: { alpha, ignoreHydrogens: params.ignore_hydrogens } },
@@ -304,16 +392,32 @@ export function representationProps(node: MolstarSubtree<'representation'>): Par
return {
type: { name: 'carbohydrate', params: { alpha, sizeFactor: params.size_factor ?? 1 } },
};
case 'surface':
case 'surface': {
return {
type: { name: 'molecular-surface', params: { alpha, ignoreHydrogens: params.ignore_hydrogens } },
type: {
name: params.surface_type === 'gaussian' ? 'gaussian-surface' : 'molecular-surface',
params: { alpha, ignoreHydrogens: params.ignore_hydrogens }
},
sizeTheme: { name: 'physical', params: { scale: params.size_factor } },
};
}
default:
throw new Error('NotImplementedError');
}
}
export function representationProps(node: MolstarSubtree<'representation'>): Partial<StateTransformer.Params<StructureRepresentation3D>> {
const base = representationPropsBase(node);
const clip = clippingForNode(node);
if (clip) {
base.type!.params = { ...base.type?.params, clip };
}
if (node.custom?.molstar_representation_params) {
base.type!.params = { ...base.type!.params, ...node.custom.molstar_representation_params };
}
return base;
}
/** Create value for `type.params.alpha` prop for `StructureRepresentation3D` transformer from a representation node based on 'opacity' nodes in its subtree. */
export function alphaForNode(node: MolstarSubtree<'representation' | 'volume_representation'>): number {
const children = getChildren(node).filter(c => c.kind === 'opacity');
@@ -324,6 +428,67 @@ export function alphaForNode(node: MolstarSubtree<'representation' | 'volume_rep
}
}
function getCommonClipParams(node: MolstarNode<'clip'>): Pick<Clip.Props['objects'][number], 'invert' | 'transform'> {
return {
invert: !!node.params.invert,
transform: node.params.check_transform ? Mat4.fromArray(Mat4(), node.params.check_transform, 0) : Mat4.identity(),
};
}
function getClipObject(node: MolstarNode<'clip'>): Clip.Props['objects'][number] | undefined {
switch (node.params.type) {
case 'sphere':
return {
type: 'sphere',
position: Vec3.ofArray(node.params.center),
scale: typeof node.params.radius === 'number'
? Vec3.create(2 * node.params.radius, 2 * node.params.radius, 2 * node.params.radius)
: Vec3.create(2, 2, 2),
rotation: { axis: Vec3.create(1, 0, 0), angle: 0 },
...getCommonClipParams(node),
};
case 'plane': {
const up = Vec3.create(0, 1, 0);
const n = Vec3.normalize(Vec3(), Vec3.ofArray(node.params.normal));
const axis = Vec3.cross(Vec3(), up, n);
const isSingular = Vec3.magnitude(axis) < 1e-6;
return {
type: 'plane',
position: Vec3.ofArray(node.params.point),
scale: Vec3.create(1, 1, 1),
rotation: {
axis: isSingular ? Vec3.unitX : axis,
angle: isSingular ? 0 : Vec3.angle(up, n) * 180 / Math.PI,
},
...getCommonClipParams(node),
};
}
case 'box':
const q = Quat.fromMat3(Quat(), Mat3.fromArray(Mat3(), node.params.rotation, 0));
const axis = Vec3();
const angle = Quat.getAxisAngle(axis, q) * 180 / Math.PI;
return {
type: 'cube',
position: Vec3.ofArray(node.params.center),
scale: Vec3.ofArray(node.params.size),
rotation: { axis, angle },
...getCommonClipParams(node),
};
default:
console.warn(`Mol* MVS: Unsupported clip type "${(node as MolstarNode<'clip'>).params.type}" in node ${node.ref}.`);
}
}
export function clippingForNode(node: MolstarSubtree<'representation' | 'volume_representation'>): Clip.Props | undefined {
const children = getChildren(node).filter(c => c.kind === 'clip');
if (!children.length) return;
const variant = children[0].params.variant === 'object' ? 'instance' : 'pixel';
const objects: Clip.Props['objects'] = children.map(getClipObject).filter(o => !!o);
return { variant, objects } satisfies Clip.Props;
}
function hasMolStarUseDefaultColoring(node: MolstarNode): boolean {
if (!node.custom) return false;
return 'molstar_use_default_coloring' in node.custom || 'molstar_color_theme_name' in node.custom;
@@ -361,31 +526,27 @@ export function colorThemeForNode(node: MolstarSubtree<'color' | 'color_from_uri
};
}
}
let annotationId: string | undefined = undefined;
let fieldName: string | undefined = undefined;
let color: string | undefined = undefined;
switch (node?.kind) {
case 'color_from_uri':
case 'color_from_source':
annotationId = context.annotationMap.get(node);
fieldName = node.params.field_name;
break;
case 'color':
color = node.params.color;
break;
}
if (annotationId) {
return {
name: MVSAnnotationColorThemeProvider.name,
params: { annotationId, fieldName, background: NoColor } satisfies Partial<MVSAnnotationColorThemeProps>,
};
} else {
if (node?.kind === 'color') {
return {
name: 'uniform',
params: { value: decodeColor(color) },
params: { value: decodeColor(node.params.color) },
};
}
if (node?.kind === 'color_from_uri' || node?.kind === 'color_from_source') {
const annotationId = context.annotationMap.get(node);
if (annotationId === undefined) return {
name: 'uniform',
params: {},
};
const fieldName = node.params.field_name;
return {
name: MVSAnnotationColorThemeProvider.name,
params: { annotationId, fieldName, background: NoColor, palette: palettePropsFromMVSPalette(node.params.palette) } satisfies Partial<MVSAnnotationColorThemeProps>,
};
}
}
function appliesColorToWholeRepr(node: MolstarNode<'color' | 'color_from_uri' | 'color_from_source'>): boolean {
if (node.kind === 'color') {
return !isDefined(node.params.selector) || node.params.selector === 'all';
@@ -394,6 +555,153 @@ function appliesColorToWholeRepr(node: MolstarNode<'color' | 'color_from_uri' |
}
}
const FALLBACK_COLOR = decodeColor(DefaultColor)!;
export function palettePropsFromMVSPalette(palette: MolstarNode<'color_from_uri' | 'color_from_source'>['params']['palette']): MVSAnnotationColorThemeProps['palette'] {
if (!palette) {
return { name: 'direct', params: {} };
}
if (palette.kind === 'categorical') {
const fullParams: Required<CategoricalPalette> = objMerge(CategoricalPaletteDefaults, palette);
return {
name: 'categorical',
params: {
colors: categoricalPalettePropsFromMVSColors(fullParams.colors),
repeatColorList: fullParams.repeat_color_list,
sort: fullParams.sort,
sortDirection: fullParams.sort_direction,
caseInsensitive: fullParams.case_insensitive,
setMissingColor: !!fullParams.missing_color,
missingColor: decodeColor(fullParams.missing_color) ?? FALLBACK_COLOR,
} satisfies MVSCategoricalPaletteProps,
};
}
if (palette.kind === 'discrete') {
const fullParams: Required<DiscretePalette> = objMerge(DiscretePaletteDefaults, palette);
return {
name: 'discrete',
params: {
colors: discretePalettePropsFromMVSColors(fullParams.colors, fullParams.reverse),
mode: fullParams.mode,
xMin: fullParams.value_domain[0],
xMax: fullParams.value_domain[1],
} satisfies MVSDiscretePaletteProps,
};
}
if (palette.kind === 'continuous') {
const fullParams: Required<ContinuousPalette> = objMerge(ContinuousPaletteDefaults, palette);
const colors = continuousPalettePropsFromMVSColors(fullParams.colors, fullParams.reverse);
return {
name: 'continuous',
params: {
colors: colors,
mode: fullParams.mode,
xMin: fullParams.value_domain[0],
xMax: fullParams.value_domain[1],
setUnderflowColor: !!fullParams.underflow_color,
underflowColor: (fullParams.underflow_color === 'auto' ? minColor(colors.colors) : decodeColor(fullParams.underflow_color)) ?? FALLBACK_COLOR,
setOverflowColor: !!fullParams.overflow_color,
overflowColor: (fullParams.overflow_color === 'auto' ? maxColor(colors.colors) : decodeColor(fullParams.overflow_color)) ?? FALLBACK_COLOR,
} satisfies MVSContinuousPaletteProps,
};
}
throw new Error(`NotImplementedError: palettePropsFromMVSPalette is not implemented for palette kind "${(palette as any).kind}"`);
}
/** Merge properties of two object into a new object. Property values from `second` override those from `first`, but `undefined` is treated as if property missing while `null` as a regular value. */
function objMerge<T extends object, U extends object>(first: T, second: U): T & U {
const out: Partial<T & U> = { ...first };
for (const key in second) {
const value = second[key];
if (value !== undefined) out[key] = value as any;
}
return out as T & U;
}
function categoricalPalettePropsFromMVSColors(colors: Required<CategoricalPalette>['colors']): MVSCategoricalPaletteProps['colors'] {
if (typeof colors === 'string') {
if (colors in MvsNamedColorLists) {
const colorList = MvsNamedColorLists[colors as ColorListNameT];
return { name: 'list', params: { kind: 'set', colors: colorList.list } };
}
if (colors in MvsNamedColorDicts) {
const colorDict = MvsNamedColorDicts[colors as ColorDictNameT];
return { name: 'dictionary', params: Object.entries(colorDict).map(([value, color]) => ({ value, color })) };
}
console.warn(`Could not find named color palette "${colors}"`);
}
if (Array.isArray(colors)) {
return { name: 'list', params: { kind: 'set', colors: colors.map(c => decodeColor(c) ?? FALLBACK_COLOR) } };
}
if (typeof colors === 'object') {
return { name: 'dictionary', params: Object.entries(colors).map(([value, color]) => ({ value, color: decodeColor(color) ?? FALLBACK_COLOR })) };
}
return { name: 'list', params: { kind: 'set', colors: [] } };
}
function discretePalettePropsFromMVSColors(colors: Required<DiscretePalette>['colors'], reverse: boolean): MVSDiscretePaletteProps['colors'] {
if (typeof colors === 'string') {
if (colors in MvsNamedColorLists) {
const colorList = MvsNamedColorLists[colors];
const list = reverse ? colorList.list.slice().reverse() : colorList.list;
const sectionLength = 1 / list.length;
return list.map((e, i) => ({ color: Color.fromColorListEntry(e), fromValue: i * sectionLength, toValue: (i + 1) * sectionLength }));
}
console.warn(`Could not find named color palette "${colors}"`);
}
if (Array.isArray(colors) && colors.every(t => typeof t === 'string')) {
const list = reverse ? colors.slice().reverse() : colors;
const sectionLength = 1 / colors.length;
return list.map((c, i) => ({ color: decodeColor(c) ?? NoColor, fromValue: i * sectionLength, toValue: (i + 1) * sectionLength }));
}
if (Array.isArray(colors) && colors.every(t => Array.isArray(t) && t.length === 2)) {
return colors.map((t, i) => ({ color: decodeColor(t[0]) ?? NoColor, fromValue: t[1], toValue: colors[i + 1]?.[1] ?? Infinity }));
}
if (Array.isArray(colors) && colors.every(t => Array.isArray(t) && t.length === 3)) {
return colors.map(t => ({ color: decodeColor(t[0]) ?? NoColor, fromValue: t[1] ?? -Infinity, toValue: t[2] ?? Infinity }));
}
return [];
}
function continuousPalettePropsFromMVSColors(colors: Required<ContinuousPalette>['colors'], reverse: boolean): MVSContinuousPaletteProps['colors'] {
if (typeof colors === 'string') {
// Named color list
if (colors in MvsNamedColorLists) {
const colorList = MvsNamedColorLists[colors];
const list = reverse ? colorList.list.slice().reverse() : colorList.list;
const n = list.length - 1;
return { kind: 'interpolate', colors: list.map((col, i) => [Color.fromColorListEntry(col), i / n]) };
}
console.warn(`Could not find named color palette "${colors}"`);
}
if (Array.isArray(colors)) {
if (colors.every(t => Array.isArray(t))) {
// Color list with checkpoints
// Not applying `reverse` here, as it would have no effect
return { kind: 'interpolate', colors: colors.map(t => [decodeColor(t[0]) ?? FALLBACK_COLOR, t[1]]) };
} else {
// Color list without checkpoints
const list = reverse ? colors.slice().reverse() : colors;
const n = list.length - 1;
return { kind: 'interpolate', colors: list.map((col, i) => [decodeColor(col) ?? FALLBACK_COLOR, i / n]) };
}
}
return { kind: 'interpolate', colors: [] };
}
/** Return the color with the lowest checkpoint, or the first color if checkpoints not available. */
function minColor(colors: ColorListEntry[]): Color | undefined {
if (colors.length === 0) return undefined;
if (colors.every(t => Array.isArray(t))) return Color.fromColorListEntry(colors.reduce((a, b) => a[1] < b[1] ? a : b));
return Color.fromColorListEntry(colors[0]);
}
/** Return the color with the highest checkpoint, or the last color if checkpoints not available. */
function maxColor(colors: ColorListEntry[]): Color | undefined {
if (colors.length === 0) return undefined;
if (colors.every(t => Array.isArray(t))) return Color.fromColorListEntry(colors.reduce((a, b) => a[1] > b[1] ? a : b));
return Color.fromColorListEntry(colors[colors.length - 1]);
}
/** Create a mapping of nearest representation nodes for each node in the tree
* (to transfer coloring to label nodes smartly).
* Only considers nodes within the same 'structure' subtree. */
@@ -420,15 +728,26 @@ export function makeNearestReprMap(root: MolstarTree) {
/** Create props for `VolumeRepresentation3D` transformer from a representation node. */
export function volumeRepresentationProps(node: MolstarSubtree<'volume_representation'>): Partial<StateTransformer.Params<VolumeRepresentation3D>> {
const alpha = alphaForNode(node);
const clip = clippingForNode(node);
const params = node.params;
const isoValue = typeof params.absolute_isovalue === 'number' ? Volume.IsoValue.absolute(params.absolute_isovalue) : Volume.IsoValue.relative(params.relative_isovalue ?? 0);
switch (params.type) {
case 'isosurface':
const isoValue = typeof params.absolute_isovalue === 'number' ? Volume.IsoValue.absolute(params.absolute_isovalue) : Volume.IsoValue.relative(params.relative_isovalue ?? 0);
const visuals: ('wireframe' | 'solid')[] = [];
if (params.show_wireframe) visuals.push('wireframe');
if (params.show_faces) visuals.push('solid');
return {
type: { name: 'isosurface', params: { alpha, isoValue, visuals } },
type: { name: 'isosurface', params: { alpha, isoValue, visuals, clip } },
};
case 'grid_slice':
const isRelative = params.relative_index !== undefined;
const dimension = {
name: isRelative ? `relative${params.dimension.toUpperCase()}` : params.dimension,
params: params.relative_index ?? params.relative_index
};
return {
type: { name: 'slice', params: { alpha, dimension, isoValue, clip } },
};
default:
throw new Error('NotImplementedError');
@@ -448,4 +767,4 @@ export function volumeColorThemeForNode(node: MolstarSubtree<'volume_representat
} if (children.length === 1) {
return colorThemeForNode(children[0], context);
}
}
}

View File

@@ -8,26 +8,32 @@
import { PluginStateSnapshotManager } from '../../mol-plugin-state/manager/snapshots';
import { PluginStateObject } from '../../mol-plugin-state/objects';
import { Download, ParseCcp4, ParseCif } from '../../mol-plugin-state/transforms/data';
import { CustomModelProperties, CustomStructureProperties, ModelFromTrajectory, StructureComponent, StructureFromModel, TrajectoryFromMmCif, TrajectoryFromPDB, TransformStructureConformation } from '../../mol-plugin-state/transforms/model';
import { ShapeRepresentation3D, StructureRepresentation3D, VolumeRepresentation3D } from '../../mol-plugin-state/transforms/representation';
import { Download, ParseCif, ParseCcp4 } from '../../mol-plugin-state/transforms/data';
import { CoordinatesFromLammpstraj, CoordinatesFromXtc, CustomModelProperties, CustomStructureProperties, ModelFromTrajectory, StructureComponent, StructureFromModel, TrajectoryFromGRO, TrajectoryFromLammpsTrajData, TrajectoryFromMmCif, TrajectoryFromMOL, TrajectoryFromMOL2, TrajectoryFromPDB, TrajectoryFromSDF, TrajectoryFromXYZ } from '../../mol-plugin-state/transforms/model';
import { StructureRepresentation3D, VolumeRepresentation3D } from '../../mol-plugin-state/transforms/representation';
import { VolumeFromCcp4, VolumeFromDensityServerCif } from '../../mol-plugin-state/transforms/volume';
import { PluginCommands } from '../../mol-plugin/commands';
import { PluginContext } from '../../mol-plugin/context';
import { StateObjectSelector } from '../../mol-state';
import { PluginState } from '../../mol-plugin/state';
import { StateObjectSelector, StateTree } from '../../mol-state';
import { RuntimeContext, Task } from '../../mol-task';
import { MolViewSpec } from './behavior';
import { createPluginStateSnapshotCamera, modifyCanvasProps, setCamera, setCanvas, setFocus, suppressCameraAutoreset } from './camera';
import { createPluginStateSnapshotCamera, modifyCanvasProps, resetCanvasProps } from './camera';
import { MVSAnnotationsProvider } from './components/annotation-prop';
import { MVSAnnotationStructureComponent } from './components/annotation-structure-component';
import { MVSAnnotationTooltipsProvider } from './components/annotation-tooltips-prop';
import { CustomLabelProps, CustomLabelRepresentationProvider } from './components/custom-label/representation';
import { CustomTooltipsProvider } from './components/custom-tooltips-prop';
import { IsMVSModelProps, IsMVSModelProvider } from './components/is-mvs-model-prop';
import { getPrimitiveStructureRefs, MVSBuildPrimitiveShape, MVSDownloadPrimitiveData, MVSInlinePrimitiveData } from './components/primitives';
import { getPrimitiveStructureRefs, MVSBuildPrimitiveShape, MVSDownloadPrimitiveData, MVSInlinePrimitiveData, MVSShapeRepresentation3D } from './components/primitives';
import { MVSTrajectoryWithCoordinates } from './components/trajectory';
import { generateStateTransition } from './helpers/animation';
import { IsHiddenCustomStateExtension } from './load-extensions/is-hidden-custom-state';
import { NonCovalentInteractionsExtension } from './load-extensions/non-covalent-interactions';
import { LoadingActions, LoadingExtension, loadTree, loadTreeVirtual, UpdateTarget } from './load-generic';
import { AnnotationFromSourceKind, AnnotationFromUriKind, collectAnnotationReferences, collectAnnotationTooltips, collectInlineLabels, collectInlineTooltips, colorThemeForNode, componentFromXProps, componentPropsFromSelector, isPhantomComponent, labelFromXProps, makeNearestReprMap, prettyNameFromSelector, representationProps, structureProps, transformProps, volumeColorThemeForNode, volumeRepresentationProps } from './load-helpers';
import { MVSData, SnapshotMetadata } from './mvs-data';
import { LoadingActions, LoadingExtension, loadTreeVirtual, UpdateTarget } from './load-generic';
import { AnnotationFromSourceKind, AnnotationFromUriKind, collectAnnotationReferences, collectAnnotationTooltips, collectInlineLabels, collectInlineTooltips, colorThemeForNode, componentFromXProps, componentPropsFromSelector, isPhantomComponent, labelFromXProps, makeNearestReprMap, prettyNameFromSelector, representationProps, structureProps, transformAndInstantiateStructure, transformAndInstantiateVolume, volumeColorThemeForNode, volumeRepresentationProps } from './load-helpers';
import { MVSData, MVSData_States, Snapshot, SnapshotMetadata } from './mvs-data';
import { MVSAnimationNode } from './tree/animation/animation-tree';
import { validateTree } from './tree/generic/tree-schema';
import { convertMvsToMolstar, mvsSanityCheck } from './tree/molstar/conversion';
import { MolstarNode, MolstarNodeParams, MolstarSubtree, MolstarTree, MolstarTreeSchema } from './tree/molstar/molstar-tree';
@@ -35,54 +41,70 @@ import { MVSTreeSchema } from './tree/mvs/mvs-tree';
export interface MVSLoadOptions {
replaceExisting?: boolean,
/** Add snapshots from MVS into current snapshot list, instead of replacing the list. */
appendSnapshots?: boolean,
/** Ignore any camera positioning from the MVS state and keep the current camera position instead, ignore any camera positioning when generating snapshots. */
keepCamera?: boolean,
keepSnapshotCamera?: boolean,
/** Specifies a set of MVS-loading extensions (not a part of standard MVS specification). If undefined, apply all builtin extensions. If `[]`, do not apply builtin extensions. */
extensions?: MolstarLoadingExtension<any>[],
/** Run some sanity checks and print potential issues to the console. */
sanityChecks?: boolean,
/** Base for resolving relative URLs/URIs. May itself be a relative URL (relative to the window URL). */
sourceUrl?: string,
doNotReportErrors?: boolean
}
/** Load a MolViewSpec (MVS) tree into the Mol* plugin.
* If `options.replaceExisting`, remove all objects in the current Mol* state; otherwise add to the current state.
* If `options.keepCamera`, ignore any camera positioning from the MVS state and keep the current camera position instead.
* If `options.keepSnapshotCamera`, ignore any camera positioning when generating snapshots.
* If `options.sanityChecks`, run some sanity checks and print potential issues to the console.
* If `options.extensions` is provided, apply specified set of MVS-loading extensions (not a part of standard MVS specification); default: apply all builtin extensions; use `extensions: []` to avoid applying builtin extensions.
* `options.sourceUrl` serves as the base for resolving relative URLs/URIs and may itself be relative to the window URL. */
export async function loadMVS(plugin: PluginContext, data: MVSData, options: MVSLoadOptions = {}) {
export function loadMVS(plugin: PluginContext, data: MVSData, options: MVSLoadOptions = {}) {
const task = Task.create('Load MVS', ctx => _loadMVS(ctx, plugin, data, options));
return plugin.runTask(task);
}
/** Load a MolViewSpec (MVS) state(s) into the Mol* plugin as plugin state snapshots. */
async function _loadMVS(ctx: RuntimeContext, plugin: PluginContext, data: MVSData, options: MVSLoadOptions = {}) {
plugin.errorContext.clear('mvs');
try {
const mvsExtensionLoaded = plugin.state.hasBehavior(MolViewSpec);
if (!mvsExtensionLoaded) throw new Error('MolViewSpec extension is not loaded.');
// Stop any currently running audio
plugin.managers.markdownExtensions.audio.dispose();
// Reset canvas props to default so that modifyCanvasProps works as expected
resetCanvasProps(plugin);
// console.log(`MVS tree:\n${MVSData.toPrettyString(data)}`)
if (data.kind === 'multiple') {
const entries: PluginStateSnapshotManager.Entry[] = [];
for (let i = 0; i < data.snapshots.length; i++) {
const snapshot = data.snapshots[i];
const previousSnapshot = i > 0 ? data.snapshots[i - 1] : data.snapshots[data.snapshots.length - 1];
validateTree(MVSTreeSchema, snapshot.root, 'MVS');
if (options.sanityChecks) mvsSanityCheck(snapshot.root);
const molstarTree = convertMvsToMolstar(snapshot.root, options.sourceUrl);
validateTree(MolstarTreeSchema, molstarTree, 'Converted Molstar');
const entry = molstarTreeToEntry(plugin, molstarTree, { ...snapshot.metadata, previousTransitionDurationMs: previousSnapshot.metadata.transition_duration_ms }, options);
entries.push(entry);
}
plugin.managers.snapshot.clear();
for (const entry of entries) {
plugin.managers.snapshot.add(entry);
}
if (entries.length > 0) {
await PluginCommands.State.Snapshots.Apply(plugin, { id: entries[0].snapshot.id });
}
} else {
validateTree(MVSTreeSchema, data.root, 'MVS');
if (options.sanityChecks) mvsSanityCheck(data.root);
const molstarTree = convertMvsToMolstar(data.root, options.sourceUrl);
// console.log(`Converted MolStar tree:\n${MVSData.toPrettyString({ root: molstarTree, metadata: { version: 'x', timestamp: 'x' } })}`)
const multiData: MVSData_States = data.kind === 'multiple' ? data : MVSData.stateToStates(data);
const entries: PluginStateSnapshotManager.Entry[] = [];
for (let i = 0; i < multiData.snapshots.length; i++) {
const snapshot = multiData.snapshots[i];
const previousSnapshot = i > 0 ? multiData.snapshots[i - 1] : multiData.snapshots[multiData.snapshots.length - 1];
validateTree(MVSTreeSchema, snapshot.root, 'MVS');
if (options.sanityChecks) mvsSanityCheck(snapshot.root);
const molstarTree = convertMvsToMolstar(snapshot.root, options.sourceUrl);
validateTree(MolstarTreeSchema, molstarTree, 'Converted Molstar');
await loadMolstarTree(plugin, molstarTree, options);
const entry = molstarTreeToEntry(
plugin,
molstarTree,
snapshot.animation,
{ ...snapshot.metadata, previousTransitionDurationMs: previousSnapshot.metadata.transition_duration_ms },
options
);
await assignStateTransition(ctx, plugin, entry, snapshot, options, i, multiData.snapshots.length);
entries.push(entry);
if (ctx.shouldUpdate) {
await ctx.update({ message: 'Loading MVS...', current: i, max: multiData.snapshots.length });
}
}
if (!options.appendSnapshots) {
plugin.managers.snapshot.clear();
}
for (const entry of entries) {
plugin.managers.snapshot.add(entry);
}
if (entries.length > 0) {
await PluginCommands.State.Snapshots.Apply(plugin, { id: entries[0].snapshot.id });
}
} catch (err) {
plugin.log.error(`${err}`);
@@ -102,41 +124,65 @@ export async function loadMVS(plugin: PluginContext, data: MVSData, options: MVS
}
}
async function assignStateTransition(ctx: RuntimeContext, plugin: PluginContext, parentEntry: PluginStateSnapshotManager.Entry, parent: Snapshot, options: MVSLoadOptions, snapshotIndex: number, snapshotCount: number) {
const transitions = await generateStateTransition(ctx, parent, snapshotIndex, snapshotCount);
if (!transitions?.frames.length) return;
/** Load a `MolstarTree` into the Mol* plugin.
* If `replaceExisting`, remove all objects in the current Mol* state; otherwise add to the current state. */
async function loadMolstarTree(plugin: PluginContext, tree: MolstarTree, options?: { replaceExisting?: boolean, keepCamera?: boolean, extensions?: MolstarLoadingExtension<any>[] }) {
const mvsExtensionLoaded = plugin.state.hasBehavior(MolViewSpec);
if (!mvsExtensionLoaded) throw new Error('MolViewSpec extension is not loaded.');
const animation: PluginState.StateTransition = {
autoplay: !!transitions.tree.params?.autoplay,
loop: !!transitions.tree.params?.loop,
frames: [],
};
const context = MolstarLoadingContext.create();
for (let i = 0; i < transitions.frames.length; i++) {
const frame = transitions.frames[i];
const molstarTree = convertMvsToMolstar(frame[0], options.sourceUrl);
const entry = molstarTreeToEntry(
plugin,
molstarTree,
parent.animation,
{ ...parent.metadata, previousTransitionDurationMs: transitions.frametimeMs },
options
);
await loadTree(plugin, tree, MolstarLoadingActions, context, { ...options, extensions: options?.extensions ?? BuiltinLoadingExtensions });
StateTree.reuseTransformParams(entry.snapshot.data!.tree, parentEntry.snapshot.data!.tree);
setCanvas(plugin, context.canvas);
animation.frames.push({
durationInMs: frame[1],
data: entry.snapshot.data!,
camera: transitions.tree.params?.include_camera ? entry.snapshot.camera : undefined,
canvas3d: transitions.tree.params?.include_canvas ? entry.snapshot.canvas3d : undefined,
});
if (options?.keepCamera) {
await suppressCameraAutoreset(plugin);
} else {
if (context.camera.cameraParams !== undefined) {
await setCamera(plugin, context.camera.cameraParams);
} else {
await setFocus(plugin, context.camera.focuses); // This includes implicit camera (i.e. no 'camera' or 'focus' nodes)
if (ctx.shouldUpdate) {
await ctx.update({ message: `Loading animation for snapshot ${snapshotIndex + 1}/${snapshotCount}...`, current: i + 1, max: transitions.frames.length });
}
}
parentEntry.snapshot.transition = animation;
}
function molstarTreeToEntry(plugin: PluginContext, tree: MolstarTree, metadata: SnapshotMetadata & { previousTransitionDurationMs?: number }, options?: { replaceExisting?: boolean, keepCamera?: boolean, keepSnapshotCamera?: boolean, extensions?: MolstarLoadingExtension<any>[] }) {
function molstarTreeToEntry(
plugin: PluginContext,
tree: MolstarTree,
animation: MVSAnimationNode<'animation'> | undefined,
metadata: SnapshotMetadata & { previousTransitionDurationMs?: number },
options: { keepCamera?: boolean, extensions?: MolstarLoadingExtension<any>[] }
) {
const context = MolstarLoadingContext.create();
const snapshot = loadTreeVirtual(plugin, tree, MolstarLoadingActions, context, { ...options, extensions: options?.extensions ?? BuiltinLoadingExtensions });
const snapshot = loadTreeVirtual(plugin, tree, MolstarLoadingActions, context, { replaceExisting: true, extensions: options?.extensions ?? BuiltinLoadingExtensions });
snapshot.canvas3d = {
props: plugin.canvas3d ? modifyCanvasProps(plugin.canvas3d.props, context.canvas) : undefined,
props: plugin.canvas3d ? modifyCanvasProps(plugin.canvas3d.props, context.canvas, animation) : undefined,
};
if (!options?.keepSnapshotCamera) {
if (!options?.keepCamera) {
snapshot.camera = createPluginStateSnapshotCamera(plugin, context, metadata);
}
snapshot.durationInMs = metadata.linger_duration_ms + (metadata.previousTransitionDurationMs ?? 0);
if (tree.custom?.molstar_on_load_markdown_commands) {
snapshot.onLoadMarkdownCommands = tree.custom.molstar_on_load_markdown_commands;
}
const entryParams: PluginStateSnapshotManager.EntryParams = {
key: metadata.key,
name: metadata.title,
@@ -157,7 +203,7 @@ export interface MolstarLoadingContext {
cameraParams?: MolstarNodeParams<'camera'>,
focuses: { target: StateObjectSelector, params: MolstarNodeParams<'focus'> }[],
},
canvas?: MolstarNodeParams<'canvas'>,
canvas?: MolstarNode<'canvas'>,
}
export const MolstarLoadingContext = {
create(): MolstarLoadingContext {
@@ -183,31 +229,72 @@ const MolstarLoadingActions: LoadingActions<MolstarTree, MolstarLoadingContext>
},
parse(updateParent: UpdateTarget, node: MolstarNode<'parse'>): UpdateTarget | undefined {
const format = node.params.format;
if (format === 'cif') {
return UpdateTarget.apply(updateParent, ParseCif, {});
} else if (format === 'pdb') {
return updateParent;
} else if (format === 'map') {
return UpdateTarget.apply(updateParent, ParseCcp4, {});
} else {
console.error(`Unknown format in "parse" node: "${format}"`);
return undefined;
switch (format) {
case 'cif':
return UpdateTarget.apply(updateParent, ParseCif, {});
case 'pdb':
case 'pdbqt':
case 'gro':
case 'xyz':
case 'mol':
case 'sdf':
case 'mol2':
case 'xtc':
case 'lammpstrj':
return updateParent;
case 'map':
return UpdateTarget.apply(updateParent, ParseCcp4, {});
default:
console.error(`Unknown format in "parse" node: "${format}"`);
return undefined;
}
},
coordinates(updateParent: UpdateTarget, node: MolstarNode<'coordinates'>): UpdateTarget | undefined {
const format = node.params.format;
switch (format) {
case 'xtc':
return UpdateTarget.apply(updateParent, CoordinatesFromXtc);
case 'lammpstrj':
return UpdateTarget.apply(updateParent, CoordinatesFromLammpstraj);
default:
console.error(`Unknown format in "coordinates" node: "${format}"`);
return undefined;
}
},
trajectory(updateParent: UpdateTarget, node: MolstarNode<'trajectory'>): UpdateTarget | undefined {
const format = node.params.format;
if (format === 'cif') {
return UpdateTarget.apply(updateParent, TrajectoryFromMmCif, {
blockHeader: node.params.block_header ?? '', // Must set to '' because just undefined would get overwritten by createDefaults
blockIndex: node.params.block_index ?? undefined,
});
} else if (format === 'pdb') {
return UpdateTarget.apply(updateParent, TrajectoryFromPDB, {});
} else {
console.error(`Unknown format in "trajectory" node: "${format}"`);
return undefined;
switch (format) {
case 'cif':
return UpdateTarget.apply(updateParent, TrajectoryFromMmCif, {
blockHeader: node.params.block_header ?? '', // Must set to '' because just undefined would get overwritten by createDefaults
blockIndex: node.params.block_index ?? undefined,
});
case 'pdb':
case 'pdbqt':
return UpdateTarget.apply(updateParent, TrajectoryFromPDB, { isPdbqt: format === 'pdbqt' });
case 'gro':
return UpdateTarget.apply(updateParent, TrajectoryFromGRO);
case 'xyz':
return UpdateTarget.apply(updateParent, TrajectoryFromXYZ);
case 'mol':
return UpdateTarget.apply(updateParent, TrajectoryFromMOL);
case 'sdf':
return UpdateTarget.apply(updateParent, TrajectoryFromSDF);
case 'mol2':
return UpdateTarget.apply(updateParent, TrajectoryFromMOL2);
case 'lammpstrj':
return UpdateTarget.apply(updateParent, TrajectoryFromLammpsTrajData);
default:
console.error(`Unknown format in "trajectory" node: "${format}"`);
return undefined;
}
},
trajectory_with_coordinates(updateParent: UpdateTarget, node: MolstarNode<'trajectory_with_coordinates'>): UpdateTarget | undefined {
const result = UpdateTarget.apply(updateParent, MVSTrajectoryWithCoordinates, {
coordinatesRef: node.params.coordinates_ref,
});
return UpdateTarget.setMvsDependencies(result, [node.params.coordinates_ref]);
},
model(updateParent: UpdateTarget, node: MolstarSubtree<'model'>, context: MolstarLoadingContext): UpdateTarget {
const annotations = collectAnnotationReferences(node, context);
const model = UpdateTarget.apply(updateParent, ModelFromTrajectory, {
@@ -228,10 +315,7 @@ const MolstarLoadingActions: LoadingActions<MolstarTree, MolstarLoadingContext>
structure(updateParent: UpdateTarget, node: MolstarSubtree<'structure'>, context: MolstarLoadingContext): UpdateTarget {
const props = structureProps(node);
const struct = UpdateTarget.apply(updateParent, StructureFromModel, props);
let transformed = struct;
for (const t of transformProps(node)) {
transformed = UpdateTarget.apply(transformed, TransformStructureConformation, t); // applying to the result of previous transform, to get the correct transform order
}
const transformed = transformAndInstantiateStructure(struct, node);
const annotationTooltips = collectAnnotationTooltips(node, context);
const inlineTooltips = collectInlineTooltips(node, context);
if (annotationTooltips.length + inlineTooltips.length > 0) {
@@ -257,7 +341,7 @@ const MolstarLoadingActions: LoadingActions<MolstarTree, MolstarLoadingContext>
colorTheme: colorThemeForNode(nearestReprNode, context),
});
}
return struct;
return transformed;
},
tooltip: undefined, // No action needed, already loaded in `structure`
tooltip_from_uri: undefined, // No action needed, already loaded in `structure`
@@ -267,21 +351,21 @@ const MolstarLoadingActions: LoadingActions<MolstarTree, MolstarLoadingContext>
return updateParent;
}
const selector = node.params.selector;
return UpdateTarget.apply(updateParent, StructureComponent, {
return transformAndInstantiateStructure(UpdateTarget.apply(updateParent, StructureComponent, {
type: componentPropsFromSelector(selector),
label: prettyNameFromSelector(selector),
nullIfEmpty: false,
});
}), node);
},
component_from_uri(updateParent: UpdateTarget, node: MolstarSubtree<'component_from_uri'>, context: MolstarLoadingContext): UpdateTarget | undefined {
if (isPhantomComponent(node)) return undefined;
const props = componentFromXProps(node, context);
return UpdateTarget.apply(updateParent, MVSAnnotationStructureComponent, props);
return transformAndInstantiateStructure(UpdateTarget.apply(updateParent, MVSAnnotationStructureComponent, props), node);
},
component_from_source(updateParent: UpdateTarget, node: MolstarSubtree<'component_from_source'>, context: MolstarLoadingContext): UpdateTarget | undefined {
if (isPhantomComponent(node)) return undefined;
const props = componentFromXProps(node, context);
return UpdateTarget.apply(updateParent, MVSAnnotationStructureComponent, props);
return transformAndInstantiateStructure(UpdateTarget.apply(updateParent, MVSAnnotationStructureComponent, props), node);
},
representation(updateParent: UpdateTarget, node: MolstarNode<'representation'>, context: MolstarLoadingContext): UpdateTarget {
return UpdateTarget.apply(updateParent, StructureRepresentation3D, {
@@ -290,14 +374,16 @@ const MolstarLoadingActions: LoadingActions<MolstarTree, MolstarLoadingContext>
});
},
volume(updateParent: UpdateTarget, node: MolstarNode<'volume'>): UpdateTarget | undefined {
let volume: UpdateTarget;
if (updateParent.transformer?.definition.to.includes(PluginStateObject.Format.Ccp4)) {
return UpdateTarget.apply(updateParent, VolumeFromCcp4, {});
volume = UpdateTarget.apply(updateParent, VolumeFromCcp4, {});
} else if (updateParent.transformer?.definition.to.includes(PluginStateObject.Format.Cif)) {
return UpdateTarget.apply(updateParent, VolumeFromDensityServerCif, { blockHeader: node.params.channel_id || undefined });
volume = UpdateTarget.apply(updateParent, VolumeFromDensityServerCif, { blockHeader: node.params.channel_id || undefined });
} else {
console.error(`Unsupported volume format`);
return undefined;
}
return transformAndInstantiateVolume(volume, node);
},
volume_representation(updateParent: UpdateTarget, node: MolstarNode<'volume_representation'>, context: MolstarLoadingContext): UpdateTarget {
return UpdateTarget.apply(updateParent, VolumeRepresentation3D, {
@@ -326,12 +412,12 @@ const MolstarLoadingActions: LoadingActions<MolstarTree, MolstarLoadingContext>
return updateParent;
},
canvas(updateParent: UpdateTarget, node: MolstarNode<'canvas'>, context: MolstarLoadingContext): UpdateTarget {
context.canvas = node.params;
context.canvas = node;
return updateParent;
},
primitives(updateParent: UpdateTarget, tree: MolstarSubtree<'primitives'>, context: MolstarLoadingContext): UpdateTarget {
const refs = getPrimitiveStructureRefs(tree);
const data = UpdateTarget.apply(updateParent, MVSInlinePrimitiveData, { node: tree });
const data = UpdateTarget.apply(updateParent, MVSInlinePrimitiveData, { node: tree as any });
return applyPrimitiveVisuals(data, refs);
},
primitives_from_uri(updateParent: UpdateTarget, tree: MolstarNode<'primitives_from_uri'>, context: MolstarLoadingContext): UpdateTarget {
@@ -342,11 +428,11 @@ const MolstarLoadingActions: LoadingActions<MolstarTree, MolstarLoadingContext>
function applyPrimitiveVisuals(data: UpdateTarget, refs: Set<string>) {
const mesh = UpdateTarget.setMvsDependencies(UpdateTarget.apply(data, MVSBuildPrimitiveShape, { kind: 'mesh' }, { state: { isGhost: true } }), refs);
UpdateTarget.apply(mesh, ShapeRepresentation3D);
UpdateTarget.apply(mesh, MVSShapeRepresentation3D);
const labels = UpdateTarget.setMvsDependencies(UpdateTarget.apply(data, MVSBuildPrimitiveShape, { kind: 'labels' }, { state: { isGhost: true } }), refs);
UpdateTarget.apply(labels, ShapeRepresentation3D);
UpdateTarget.apply(labels, MVSShapeRepresentation3D);
const lines = UpdateTarget.setMvsDependencies(UpdateTarget.apply(data, MVSBuildPrimitiveShape, { kind: 'lines' }, { state: { isGhost: true } }), refs);
UpdateTarget.apply(lines, ShapeRepresentation3D);
UpdateTarget.apply(lines, MVSShapeRepresentation3D);
return data;
}
@@ -354,4 +440,5 @@ export type MolstarLoadingExtension<TExtensionContext> = LoadingExtension<Molsta
export const BuiltinLoadingExtensions: MolstarLoadingExtension<any>[] = [
NonCovalentInteractionsExtension,
IsHiddenCustomStateExtension,
];

View File

@@ -1,11 +1,13 @@
/**
* Copyright (c) 2023-2024 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2023-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Adam Midlik <midlik@gmail.com>
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { treeValidationIssues } from './tree/generic/tree-schema';
import { treeToString } from './tree/generic/tree-utils';
import { MVSAnimationSchema, MVSAnimationTree } from './tree/animation/animation-tree';
import { Root, createMVSBuilder } from './tree/mvs/mvs-builder';
import { MVSTree, MVSTreeSchema } from './tree/mvs/mvs-tree';
@@ -53,6 +55,8 @@ export interface Snapshot {
root: MVSTree,
/** Associated metadata */
metadata: SnapshotMetadata,
/** Optional animation */
animation?: MVSAnimationTree,
}
/** MVSData with a single state */
@@ -157,6 +161,25 @@ export const MVSData = {
metadata: GlobalMetadata.create(metadata),
};
},
/** Convert single-state MVSData into multi-state MVSData with one state. */
stateToStates(state: MVSData_State): MVSData_States {
return {
kind: 'multiple',
metadata: state.metadata,
snapshots: [{
metadata: {
title: state.metadata.title,
description: state.metadata.description,
description_format: state.metadata.description_format,
key: undefined,
linger_duration_ms: 1000,
transition_duration_ms: 250,
},
root: state.root,
}],
};
},
};
@@ -170,7 +193,14 @@ function majorVersion(semanticVersion: string | number): number | undefined {
function snapshotValidationIssues(snapshot: MVSData_State | Snapshot, options: { noExtra?: boolean } = {}): string[] | undefined {
if (snapshot.root === undefined) return [`"root" missing in snapshot`];
return treeValidationIssues(MVSTreeSchema, snapshot.root, options);
const state = treeValidationIssues(MVSTreeSchema, snapshot.root, options);
const animation = 'animation' in snapshot && snapshot.animation !== undefined
? treeValidationIssues(MVSAnimationSchema, snapshot.animation, options)
: undefined;
if (state && animation) return [...state, ...animation];
if (state) return state;
if (animation) return animation;
return undefined;
}
/** Return the current universal time, in ISO format, e.g. '2023-11-24T10:45:49.873Z' */

View File

@@ -0,0 +1,143 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { bool, float, int, list, OptionalField, RequiredField, str, union, nullable, literal, ValueFor } from '../generic/field-schema';
import { SimpleParamsSchema, UnionParamsSchema } from '../generic/params-schema';
import { NodeFor, ParamsOfKind, SubtreeOfKind, TreeFor, TreeSchema } from '../generic/tree-schema';
import { ColorT, ContinuousPalette, DiscretePalette, Matrix, Vector3 } from '../mvs/param-types';
const Easing = literal(
'linear',
'bounce-in', 'bounce-out', 'bounce-in-out',
'circle-in', 'circle-out', 'circle-in-out',
'cubic-in', 'cubic-out', 'cubic-in-out',
'exp-in', 'exp-out', 'exp-in-out',
'quad-in', 'quad-out', 'quad-in-out',
'sin-in', 'sin-out', 'sin-in-out',
);
export type MVSAnimationEasing = ValueFor<typeof Easing>;
const _Noise = {
noise_magnitude: OptionalField(float, 0, 'Magnitude of the noise to apply to the interpolated value.')
// support cummulative noise?
};
const _Common = {
target_ref: RequiredField(str, 'Reference to the node.'),
property: RequiredField(union(str, list(union(str, int))), 'Value accessor.'),
start_ms: OptionalField(float, 0, 'Start time of the transition in milliseconds.'),
duration_ms: RequiredField(float, 'Duration of the transition in milliseconds.'),
};
const _Frequency = {
frequency: OptionalField(int, 1, 'Determines how many times the interpolation loops. Current T = frequency * t mod 1.'),
alternate_direction: OptionalField(bool, false, 'Whether to alternate the direction of the interpolation for frequency > 1.'),
};
const _Easing = {
easing: OptionalField(Easing, 'linear', 'Easing function to use for the transition.'),
};
const ScalarInterpolation = {
..._Common,
..._Frequency,
..._Easing,
start: OptionalField(nullable(union(float, list(float))), null, 'Start value. If a list of values is provided, each element will be interpolated separately. If unset, parent state value is used.'),
end: OptionalField(nullable(union(float, list(float))), null, 'End value. If a list of values is provided, each element will be interpolated separately. If unset, only noise is applied.'),
discrete: OptionalField(bool, false, 'Whether to round the values to the closest integer. Useful for example for trajectory animation.'),
..._Noise,
};
const Vec3Interpolation = {
..._Common,
..._Frequency,
..._Easing,
start: OptionalField(nullable(list(float)), null, 'Start value. If unset, parent state value is used. Must be array of length 3N (x1, y1, z1, x2, y2, z2, ...).'),
end: OptionalField(nullable(list(float)), null, 'End value. Must be array of length 3N (x1, y1, z1, x2, y2, z2, ...). If unset, only noise is applied.'),
spherical: OptionalField(bool, false, 'Whether to use spherical interpolation.'),
..._Noise,
};
const RotationMatrixInterpolation = {
..._Common,
..._Frequency,
..._Easing,
start: OptionalField(nullable(Matrix), null, 'Start value. If unset, parent state value is used.'),
end: OptionalField(nullable(Matrix), null, 'End value. If unset, only noise is applied.'),
..._Noise,
};
const ColorInterpolation = {
..._Common,
..._Frequency,
..._Easing,
start: OptionalField(nullable(ColorT), null, 'Start value. If unset, parent state value is used.'),
end: OptionalField(nullable(ColorT), null, 'End value.'),
palette: OptionalField(nullable(union(DiscretePalette, ContinuousPalette)), null, 'Palette to sample colors from. Overrides start and end values.'),
};
const TransformationMatrixInterpolation = {
..._Common,
pivot: OptionalField(nullable(Vector3), null, 'Pivot point for rotation and scale.'),
rotation_start: OptionalField(nullable(Matrix), null, 'Start rotation value. If unset, parent state value is used.'),
rotation_end: OptionalField(nullable(Matrix), null, 'End rotation value. If unset, only noise is applied.'),
rotation_noise_magnitude: OptionalField(float, 0, 'Magnitude of the noise to apply to the rotation.'),
rotation_easing: OptionalField(Easing, 'linear', 'Easing function to use for the rotation.'),
rotation_frequency: OptionalField(int, 1, 'Determines how many times the rotation interpolation loops. Current T = frequency * t mod 1.'),
rotation_alternate_direction: OptionalField(bool, false, 'Whether to alternate the direction of the interpolation for frequency > 1.'),
translation_start: OptionalField(nullable(Vector3), null, 'Start translation value. If unset, parent state value is used.'),
translation_end: OptionalField(nullable(Vector3), null, 'End translation value. If unset, only noise is applied.'),
translation_noise_magnitude: OptionalField(float, 0, 'Magnitude of the noise to apply to the translation.'),
translation_easing: OptionalField(Easing, 'linear', 'Easing function to use for the translation.'),
translation_frequency: OptionalField(int, 1, 'Determines how many times the translation interpolation loops. Current T = frequency * t mod 1.'),
translation_alternate_direction: OptionalField(bool, false, 'Whether to alternate the direction of the interpolation for frequency > 1.'),
scale_start: OptionalField(nullable(Vector3), null, 'Start scale value. If unset, parent state value is used.'),
scale_end: OptionalField(nullable(Vector3), null, 'End scale value. If unset, only noise is applied.'),
scale_noise_magnitude: OptionalField(float, 0, 'Magnitude of the noise to apply to the scale.'),
scale_easing: OptionalField(Easing, 'linear', 'Easing function to use for the scale.'),
scale_frequency: OptionalField(int, 1, 'Determines how many times the scale interpolation loops. Current T = frequency * t mod 1.'),
scale_alternate_direction: OptionalField(bool, false, 'Whether to alternate the direction of the interpolation for frequency > 1.'),
};
export const MVSAnimationSchema = TreeSchema({
rootKind: 'animation',
nodes: {
animation: {
description: 'Animation root node',
parent: [],
params: SimpleParamsSchema({
frame_time_ms: OptionalField(float, 1000 / 60, 'Frame time in milliseconds'),
duration_ms: OptionalField(nullable(float), null, 'Total duration of the animation. If not specified, computed as maximum of all transitions.'),
autoplay: OptionalField(bool, true, 'Determines whether the animation should autoplay when a snapshot is loaded'),
loop: OptionalField(bool, false, 'Determines whether the animation should loop when it reaches the end'),
include_camera: OptionalField(bool, false, 'Determines whether the camera state should be included in the animation'),
include_canvas: OptionalField(bool, false, 'Determines whether the canvas state should be included in the animation'),
}),
},
interpolate: {
description: 'This node enables interpolating between values',
parent: ['animation'],
params: UnionParamsSchema(
'kind',
'Interpolation kind',
{
scalar: SimpleParamsSchema(ScalarInterpolation),
vec3: SimpleParamsSchema(Vec3Interpolation),
rotation_matrix: SimpleParamsSchema(RotationMatrixInterpolation),
transform_matrix: SimpleParamsSchema(TransformationMatrixInterpolation),
color: SimpleParamsSchema(ColorInterpolation),
},
)
}
}
});
export type MVSAnimationKind = keyof typeof MVSAnimationSchema.nodes
export type MVSAnimationNode<TKind extends MVSAnimationKind = MVSAnimationKind> = NodeFor<typeof MVSAnimationSchema, TKind>
export type MVSAnimationTree = TreeFor<typeof MVSAnimationSchema>
export type MVSAnimationNodeParams<TKind extends MVSAnimationKind> = ParamsOfKind<MVSAnimationTree, TKind>
export type MVSAnimationSubtree<TKind extends MVSAnimationKind = MVSAnimationKind> = SubtreeOfKind<MVSAnimationTree, TKind>

Some files were not shown because too many files have changed in this diff Show More