mirror of
https://github.com/molstar/molstar.git
synced 2026-06-06 14:44:22 +08:00
Compare commits
226 Commits
v4.13.0
...
experiment
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14e619d6d2 | ||
|
|
42d969bbeb | ||
|
|
fdc33e44dc | ||
|
|
b0aa889a0a | ||
|
|
4d7bd53231 | ||
|
|
c11cf665c9 | ||
|
|
a4b09d3a0c | ||
|
|
6e488b0f80 | ||
|
|
6164281a50 | ||
|
|
2db7171e2a | ||
|
|
edfc094952 | ||
|
|
b3e1e2900b | ||
|
|
1e498d535a | ||
|
|
6ed969cd1b | ||
|
|
27bb4f4bca | ||
|
|
6ce2139272 | ||
|
|
13cf6613a6 | ||
|
|
c5bb13e295 | ||
|
|
34c8257848 | ||
|
|
fcbf39c935 | ||
|
|
46c8150b2b | ||
|
|
af1a864daa | ||
|
|
3babd9399a | ||
|
|
e57564486f | ||
|
|
464a91ac29 | ||
|
|
27fa50a5de | ||
|
|
1e323f18f7 | ||
|
|
2685b2b77d | ||
|
|
d71b47a515 | ||
|
|
88cc720dd2 | ||
|
|
201433cc91 | ||
|
|
8582303491 | ||
|
|
655c3edadd | ||
|
|
a4323a4bd8 | ||
|
|
1b5a7d9546 | ||
|
|
f165cc4629 | ||
|
|
db247d6fbd | ||
|
|
138796862b | ||
|
|
1b236f1ae5 | ||
|
|
b6c2e25395 | ||
|
|
b7816986aa | ||
|
|
437c70a75a | ||
|
|
de85e0fbae | ||
|
|
c527b59782 | ||
|
|
3bbbac66c7 | ||
|
|
c0980bf18a | ||
|
|
45eab19493 | ||
|
|
1e2a5a5bfd | ||
|
|
45edfa8014 | ||
|
|
899203c855 | ||
|
|
ef823b066b | ||
|
|
33dc2015df | ||
|
|
fcf5ea420b | ||
|
|
8d97327f8d | ||
|
|
abc7ebba3e | ||
|
|
73d593907e | ||
|
|
0dc05e1138 | ||
|
|
dd11cacae4 | ||
|
|
b503259758 | ||
|
|
1e98741e16 | ||
|
|
f879519700 | ||
|
|
c6e175e5da | ||
|
|
add75bf9c9 | ||
|
|
57cbcd5fbf | ||
|
|
50a820b0ae | ||
|
|
0a33936e06 | ||
|
|
7291025e09 | ||
|
|
0cb2c3621b | ||
|
|
86da258280 | ||
|
|
477a80d1ca | ||
|
|
86b68018a9 | ||
|
|
da095d6ef9 | ||
|
|
dc304b9e08 | ||
|
|
c905fa17c4 | ||
|
|
a06c64e8e0 | ||
|
|
f5441290dd | ||
|
|
9f23124317 | ||
|
|
8299cd638c | ||
|
|
50cb08e74d | ||
|
|
89552652ba | ||
|
|
37ce577813 | ||
|
|
4d9a003141 | ||
|
|
6f0311a53f | ||
|
|
bfd2d6b055 | ||
|
|
3072e60709 | ||
|
|
62ed8d10e3 | ||
|
|
13d3c34864 | ||
|
|
cac433efca | ||
|
|
b25ffe7151 | ||
|
|
31074dc74c | ||
|
|
c98c01a076 | ||
|
|
8966fc9396 | ||
|
|
fdbdc551e8 | ||
|
|
bb232ac3a4 | ||
|
|
735c25ef8d | ||
|
|
298043313a | ||
|
|
77cd181b91 | ||
|
|
b5bee042e8 | ||
|
|
4faf17ddc7 | ||
|
|
28774b2277 | ||
|
|
6a7444f44e | ||
|
|
15bfa8416a | ||
|
|
e6895ec833 | ||
|
|
2099ad728a | ||
|
|
72ae3fae65 | ||
|
|
bb5ad78681 | ||
|
|
f10e88612f | ||
|
|
a2e582d4a9 | ||
|
|
572874f4ae | ||
|
|
b9c0347497 | ||
|
|
089148198f | ||
|
|
6fc04c3294 | ||
|
|
dc55577e22 | ||
|
|
f7ba7c0511 | ||
|
|
ed5374fab9 | ||
|
|
9a04b4f0df | ||
|
|
9350e539b6 | ||
|
|
c38377af46 | ||
|
|
9804febd95 | ||
|
|
7936dc1840 | ||
|
|
a033a8be36 | ||
|
|
4b84c6dcba | ||
|
|
309d792fdb | ||
|
|
c437254680 | ||
|
|
6fbf7c7a22 | ||
|
|
86a7520b90 | ||
|
|
cd10043447 | ||
|
|
146e95cb23 | ||
|
|
13b1e5d59c | ||
|
|
ae3efa53d6 | ||
|
|
2e67fbe870 | ||
|
|
56df6f82a7 | ||
|
|
fdd874b7a6 | ||
|
|
f142c3ef1b | ||
|
|
978b53e7d8 | ||
|
|
2f3197479d | ||
|
|
6536d0ab91 | ||
|
|
3bee224e7d | ||
|
|
3e63137977 | ||
|
|
38d6bc6c27 | ||
|
|
fafe22d56b | ||
|
|
a6a92bcf91 | ||
|
|
82c681f445 | ||
|
|
fbbd58b4db | ||
|
|
2dc13f082c | ||
|
|
ab5eb5993d | ||
|
|
2384003f5d | ||
|
|
3675c0afe0 | ||
|
|
d9bae488e9 | ||
|
|
e31e5321ba | ||
|
|
8c7f8b8a56 | ||
|
|
e4dfb5148c | ||
|
|
39e2591b60 | ||
|
|
f8a5237024 | ||
|
|
6c2d5b9da7 | ||
|
|
e128d85356 | ||
|
|
08a929bb2f | ||
|
|
5a54b3ef66 | ||
|
|
a0c897547a | ||
|
|
89ce8394fd | ||
|
|
ea0331e95c | ||
|
|
9f220b55c2 | ||
|
|
acf248d58f | ||
|
|
c83b859766 | ||
|
|
33a2564893 | ||
|
|
d409c4f5ea | ||
|
|
ab61e31230 | ||
|
|
ae9c2dd9d8 | ||
|
|
c17edb4928 | ||
|
|
528377eb47 | ||
|
|
c9819369d0 | ||
|
|
cdbbbfa6dd | ||
|
|
a1e31c79e9 | ||
|
|
e027fe46c1 | ||
|
|
05c4006e9d | ||
|
|
191ea65c9d | ||
|
|
3c1ee16376 | ||
|
|
9ac34ee13b | ||
|
|
6778452d07 | ||
|
|
7e01af1e0d | ||
|
|
85469cbf28 | ||
|
|
299bdc72cd | ||
|
|
ae9f879139 | ||
|
|
b50d83d6ea | ||
|
|
2d99d8a1d0 | ||
|
|
ea00cca1c8 | ||
|
|
27c3b4e698 | ||
|
|
52942e7021 | ||
|
|
5904f694b5 | ||
|
|
92c0b82784 | ||
|
|
e1226fa384 | ||
|
|
ac2f7d1c38 | ||
|
|
ae1742f68e | ||
|
|
04e2da86fd | ||
|
|
510182ff60 | ||
|
|
4e1da19bdd | ||
|
|
a3eae15446 | ||
|
|
4334f4d1fa | ||
|
|
e33ed54121 | ||
|
|
ae8f037192 | ||
|
|
01271941dd | ||
|
|
7f8be5b8c6 | ||
|
|
2ab6e4b2e7 | ||
|
|
aa22840b12 | ||
|
|
c1e33fac94 | ||
|
|
a7336095ca | ||
|
|
4a88546181 | ||
|
|
edbc70cf6e | ||
|
|
c22ad2910c | ||
|
|
28a2b52e3c | ||
|
|
449d572ed5 | ||
|
|
470227af43 | ||
|
|
a0ccf46939 | ||
|
|
0ce8931fc5 | ||
|
|
3ddb29fc6f | ||
|
|
1a0c65df21 | ||
|
|
daad1923ea | ||
|
|
f34f879cf1 | ||
|
|
f47b76c8af | ||
|
|
6ee9eb8b60 | ||
|
|
915703a46d | ||
|
|
61c3c19ae3 | ||
|
|
6da9557531 | ||
|
|
29e6d69d21 | ||
|
|
6b2b87e6c5 | ||
|
|
5299d5c0c4 |
@@ -1,4 +0,0 @@
|
||||
node_modules/*
|
||||
build/*
|
||||
docs/site/*
|
||||
lib/*
|
||||
122
.eslintrc.json
122
.eslintrc.json
@@ -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
7
.gitignore
vendored
@@ -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
|
||||
|
||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -6,9 +6,4 @@
|
||||
"*.vert.ts": "glsl",
|
||||
"*.gql.ts": "graphql"
|
||||
},
|
||||
"eslint.options": {
|
||||
"overrideConfig": {
|
||||
"ignorePatterns": ["webpack.config.js", "scripts/*"],
|
||||
},
|
||||
}
|
||||
}
|
||||
172
CHANGELOG.md
172
CHANGELOG.md
@@ -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 `` 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
216
build-dev.mjs
216
build-dev.mjs
@@ -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);
|
||||
@@ -24,7 +24,7 @@ npm install
|
||||
Afterwards, build the project source:
|
||||
|
||||
```
|
||||
npm run build-tsc
|
||||
npm run build:lib
|
||||
```
|
||||
|
||||
and run the server by
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
107
docs/docs/plugin/managers/markdown-extensions.md
Normal file
107
docs/docs/plugin/managers/markdown-extensions.md
Normal 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 `` 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:
|
||||
-  [polymer](!highlight-refs=polymer&focus-refs=polymer)
|
||||
-  [ligand](!highlight-refs=ligand&focus-refs=ligand)
|
||||
- [both](!highlight-refs=polymer,ligand&focus-refs=polymer,ligand)
|
||||
|
||||
### Color Palettes
|
||||
|name|visual|
|
||||
|---:|---|
|
||||
|viridis||
|
||||
|rainbow (discrete)||
|
||||
|custom|)|
|
||||
|
||||
### Camera controls
|
||||
- [center](!center-camera)
|
||||
|
||||
### Image embedded in MVSX file
|
||||

|
||||
```
|
||||
|
||||
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
|
||||
)
|
||||
```
|
||||
@@ -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
111
eslint.config.mjs
Normal 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],
|
||||
},
|
||||
}]);
|
||||
BIN
examples/audio/AudioMOM1_A.mp3
Normal file
BIN
examples/audio/AudioMOM1_A.mp3
Normal file
Binary file not shown.
BIN
examples/audio/AudioMOM1_B.mp3
Normal file
BIN
examples/audio/AudioMOM1_B.mp3
Normal file
Binary file not shown.
BIN
examples/audio/AudioMOM1_C.mp3
Normal file
BIN
examples/audio/AudioMOM1_C.mp3
Normal file
Binary file not shown.
BIN
examples/audio/AudioMOM1_D.mp3
Normal file
BIN
examples/audio/AudioMOM1_D.mp3
Normal file
Binary file not shown.
4052
examples/mvs/kinase-story.mvsj
Normal file
4052
examples/mvs/kinase-story.mvsj
Normal file
File diff suppressed because it is too large
Load Diff
16059
package-lock.json
generated
16059
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
108
package.json
108
package.json
@@ -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
307
scripts/build.mjs
Normal 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);
|
||||
});
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
16
scripts/write-version.mjs
Normal 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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
39
src/apps/mvs-stories/context.ts
Normal file
39
src/apps/mvs-stories/context.ts
Normal 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];
|
||||
}
|
||||
2
src/apps/mvs-stories/elements/index.ts
Normal file
2
src/apps/mvs-stories/elements/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import './snapshot-markdown';
|
||||
import './viewer';
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
BIN
src/apps/mvs-stories/favicon.ico
Normal file
BIN
src/apps/mvs-stories/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
148
src/apps/mvs-stories/index.html
Normal file
148
src/apps/mvs-stories/index.html
Normal 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> <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> <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>
|
||||
64
src/apps/mvs-stories/index.tsx
Normal file
64
src/apps/mvs-stories/index.tsx
Normal 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 };
|
||||
66
src/apps/mvs-stories/readme.md
Normal file
66
src/apps/mvs-stories/readme.md
Normal 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>
|
||||
|
||||
```
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
7
src/apps/mvs-stories/version.ts
Normal file
7
src/apps/mvs-stories/version.ts
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(', ');
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
219
src/examples/ligand-editor/edits.ts
Normal file
219
src/examples/ligand-editor/edits.ts
Normal 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 };
|
||||
}
|
||||
53
src/examples/ligand-editor/example-data.ts
Normal file
53
src/examples/ligand-editor/example-data.ts
Normal 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`;
|
||||
58
src/examples/ligand-editor/index.html
Normal file
58
src/examples/ligand-editor/index.html
Normal 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>
|
||||
433
src/examples/ligand-editor/index.tsx
Normal file
433
src/examples/ligand-editor/index.tsx
Normal 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>;
|
||||
}
|
||||
98
src/examples/ligand-editor/molfile.ts
Normal file
98
src/examples/ligand-editor/molfile.ts
Normal 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');
|
||||
}
|
||||
110
src/examples/ligand-editor/r-groups.ts
Normal file
110
src/examples/ligand-editor/r-groups.ts
Normal 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`
|
||||
};
|
||||
12
src/examples/ligand-editor/readme.md
Normal file
12
src/examples/ligand-editor/readme.md
Normal 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.
|
||||
@@ -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];
|
||||
}
|
||||
BIN
src/examples/mvs-stories/favicon.ico
Normal file
BIN
src/examples/mvs-stories/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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:
|
||||
|
||||
|
||||
360
src/examples/mvs-stories/stories/animation.ts
Normal file
360
src/examples/mvs-stories/stories/animation.ts
Normal 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(),
|
||||
}
|
||||
};
|
||||
}
|
||||
186
src/examples/mvs-stories/stories/audio.ts
Normal file
186
src/examples/mvs-stories/stories/audio.ts
Normal file
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
1020
src/examples/mvs-stories/stories/motm1.ts
Normal file
1020
src/examples/mvs-stories/stories/motm1.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, '');
|
||||
}
|
||||
|
||||
@@ -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, '');
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
148
src/extensions/json-cif/_spec/json-cif.spec.ts
Normal file
148
src/extensions/json-cif/_spec/json-cif.spec.ts
Normal 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`;
|
||||
114
src/extensions/json-cif/encoder.ts
Normal file
114
src/extensions/json-cif/encoder.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
307
src/extensions/json-cif/ligand-graph.ts
Normal file
307
src/extensions/json-cif/ligand-graph.ts
Normal 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,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
31
src/extensions/json-cif/model.ts
Normal file
31
src/extensions/json-cif/model.ts
Normal 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;
|
||||
}
|
||||
94
src/extensions/json-cif/parser.ts
Normal file
94
src/extensions/json-cif/parser.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
5
src/extensions/json-cif/readme.md
Normal file
5
src/extensions/json-cif/readme.md
Normal 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.
|
||||
30
src/extensions/json-cif/transformers.ts
Normal file
30
src/extensions/json-cif/transformers.ts
Normal 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' });
|
||||
});
|
||||
}
|
||||
});
|
||||
34
src/extensions/json-cif/utils.ts
Normal file
34
src/extensions/json-cif/utils.ts
Normal 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()
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {} },
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
36
src/extensions/mvs/components/trajectory.ts
Normal file
36
src/extensions/mvs/components/trajectory.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
26
src/extensions/mvs/export.ts
Normal file
26
src/extensions/mvs/export.ts
Normal 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);
|
||||
}
|
||||
605
src/extensions/mvs/helpers/animation.ts
Normal file
605
src/extensions/mvs/helpers/animation.ts
Normal 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 };
|
||||
}
|
||||
145
src/extensions/mvs/helpers/colors.ts
Normal file
145
src/extensions/mvs/helpers/colors.ts
Normal 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'],
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/extensions/mvs/load-extensions/is-hidden-custom-state.ts
Normal file
17
src/extensions/mvs/load-extensions/is-hidden-custom-state.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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' */
|
||||
|
||||
143
src/extensions/mvs/tree/animation/animation-tree.ts
Normal file
143
src/extensions/mvs/tree/animation/animation-tree.ts
Normal 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
Reference in New Issue
Block a user