Compare commits

...

6 Commits

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

* fixes

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

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

* fixes
2025-08-25 20:12:10 +02:00
17 changed files with 489 additions and 192 deletions

View File

@@ -13,6 +13,7 @@ Note that since we don't clearly distinguish between a public and private interf
- 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

View File

@@ -20,6 +20,10 @@ Generally, the command should be URL encoded, e.g., `a b` => `a%20b` (in JS, `en
- `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=...`
@@ -28,7 +32,7 @@ Generally, the command should be URL encoded, e.g., `a b` => `a%20b` (in JS, `en
- (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[=str]`, `stop-audio`, `pause-audio` - Audio playback support
- `play-audio=src`, `toggle-audio[=src]`, `stop-audio`, `pause-audio`, `dispose-audio` - Audio playback support
## Custom Content

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "molstar",
"version": "5.0.0-dev.9",
"version": "5.0.0-dev.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "molstar",
"version": "5.0.0-dev.9",
"version": "5.0.0-dev.10",
"license": "MIT",
"dependencies": {
"@types/argparse": "^2.0.17",

View File

@@ -1,6 +1,6 @@
{
"name": "molstar",
"version": "5.0.0-dev.9",
"version": "5.0.0-dev.10",
"description": "A comprehensive macromolecular library.",
"homepage": "https://github.com/molstar/molstar#readme",
"repository": {
@@ -122,7 +122,8 @@
"Lukáš Polák <admin@lukaspolak.cz>",
"Chetan Mishra <chetan.s115@gmail.com>",
"Zach Charlop-Powers <zach.charlop.powers@gmail.com>",
"Kim Juho <juho_kim@outlook.com>"
"Kim Juho <juho_kim@outlook.com>",
"Victoria Doshchenko <doshchenko.victoria@gmail.com>"
],
"license": "MIT",
"devDependencies": {
@@ -203,4 +204,4 @@
"optional": true
}
}
}
}

View File

@@ -182,6 +182,15 @@
max-width: 100%;
height: auto;
}
a {
text-decoration: none;
color: #1d4ed7;
&:hover {
text-decoration: underline;
}
}
}
@media (orientation:portrait) {

View File

@@ -22,7 +22,15 @@ const Steps = [
header: 'Animation Demo',
key: 'intro',
description: `### Molecular Animation
A story showcasing MolViewSpec animation capabilities.`,
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 => {
@@ -41,7 +49,7 @@ A story showcasing MolViewSpec animation capabilities.`,
custom: {
molstar_trackball: {
name: 'rock',
params: { speed: 1.5 },
params: { speed: 0.5 },
}
}
});
@@ -49,11 +57,23 @@ A story showcasing MolViewSpec animation capabilities.`,
kind: 'scalar',
ref: 'prims-opacity',
target_ref: 'prims',
duration_ms: 1000,
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
@@ -119,14 +139,32 @@ A story showcasing MolViewSpec animation capabilities.`,
anim.interpolate({
kind: 'scalar',
ref: 'clip-transition',
target_ref: 'clip',
duration_ms: 2000,
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',

View File

@@ -22,15 +22,16 @@ const Steps = [
{
header: 'Audio Demo',
key: 'intro',
description: `### Audio Playback
description: `
**Audio**
[ **▶ Play** ](${encodeURIComponent(`!play-audio=${_Audio}`)})
[ **⏸ Pause** ](!pause-audio)
[ **⏹ Stop** ](!stop-audio)
[ **Hide** ](!dispose-audio)
### Audio Playback
A simple example showcasing audio playback.
Basic controls:
[Play](${encodeURIComponent(`!play-audio=${_Audio}`)})
[Pause](!pause-audio)
[Stop](!stop-audio)
`,
linger_duration_ms: 2000,
transition_duration_ms: 500,
@@ -50,7 +51,7 @@ Basic controls:
}
}
});
prims.label({ text: 'Click to Play', position: { label_asym_id: 'A' }, label_size: 10 });
prims.label({ text: 'Click to Play', position: { label_asym_id: 'A' }, label_size: 10 });
return builder;
},
@@ -67,7 +68,7 @@ Basic controls:
If you clicked "Click to Play" in the previous snapshot, the audio should be playing now.
[Stop](!stop-audio)
[Stop Audio](!stop-audio)
`,
linger_duration_ms: 2000,
transition_duration_ms: 500,
@@ -103,7 +104,7 @@ If you clicked "Click to Play" in the previous snapshot, the audio should be pla
If you browser security permissions allow it, the audio should start playing automatically when the snapshot gets loaded
[Stop](!stop-audio)
[Stop Audio](!stop-audio)
`,
linger_duration_ms: 2000,
transition_duration_ms: 500,

View File

@@ -3,6 +3,7 @@
*
* @author David Sehnal <david.sehnal@gmail.com>
* @author Ludovic Autin <autin@scripps.edu>
* @author Victoria Doshchenko <doshchenko.victoria@gmail.com>
*/
import { decodeColor } from '../../../extensions/mvs/helpers/utils';
@@ -84,15 +85,21 @@ const _Audio4 = audioPathBase + '/examples/audio/AudioMOM1_D.mp3';
const q = (expr: string, lang = 'pymol') =>
`!query=${encodeURIComponent(expr)}&lang=${lang}&action=highlight,focus`;
const description_intro = `
# Molecule of the Month: Myoglobin
const desc_intro = `
# Introduction
A story based on the orginal [first Molecule of the Month](https://pdb101.rcsb.org/motm/1) made by David Goodsell in January 2000.
🔊 *This story includes short audio commentaries to guide you through the structures.*
For the best experience, please keep your sound on or use headphones.
`;
const description_p0 = `
# Molecule of the Month: Myoglobin
Basic controls for the audio comments:
[Play](${encodeURIComponent(`!play-audio=${_Audio1}`)})
[Pause](!pause-audio)
[Stop](!stop-audio)
${createAudioControls(_Audio1)}
Myoglobin was the first protein to have its atomic structure determined, revealing how it stores oxygen in muscle cells.
@@ -161,9 +168,7 @@ const charged_residues = q(formatMolScript(query3), 'mol-script');
const description_p1 = `
# Myoglobin and Whales
Basic controls for the audio comments:
[Play](${encodeURIComponent(`!play-audio=${_Audio2}`)})
[Pause](!pause-audio)
[Stop](!stop-audio)
${createAudioControls(_Audio2)}
If you look at John Kendrew's PDB file, you'll notice that the myoglobin he used was taken
from sperm whale muscles. Whales and dolphin have a great need for myoglobin, so that they can
@@ -184,9 +189,7 @@ high concentrations.
const description_p2 = `
# Oxygen Bound to Myoglobin
Basic controls for the audio comments:
[Play](${encodeURIComponent(`!play-audio=${_Audio3}`)})
[Pause](!pause-audio)
[Stop](!stop-audio)
${createAudioControls(_Audio3)}
A later structure of myoglobin, PDB entry [1mbo](https://www.rcsb.org/structure/1mbo),
shows that [oxygen](${q('index 1276+1277')}) binds to
@@ -202,9 +205,7 @@ appear and disappear, allowing oxygen in and out.
const description_p3 = `
# Molecule of the Month: Myoglobin
Basic controls for the audio comments:
[Play](${encodeURIComponent(`!play-audio=${_Audio1}`)})
[Pause](!pause-audio)
[Stop](!stop-audio)
${createAudioControls(_Audio4)}
The atomic structure of myoglobin revealed many of the basic principles
of protein structure and stability. For instance, the structure showed
@@ -235,10 +236,67 @@ PDB entry [2jho](https://www.rcsb.org/structure/2jho) includes myoglobin poisone
`;
const Steps = [
{
header: 'Introduction',
key: 'first-slide',
description: desc_intro,
linger_duration_ms: 0,
state: (): Root => {
const builder = createMVSBuilder();
const _1mbn = build1mbn(builder, '1MBN');
builder.extendRootCustomState({
molstar_on_load_markdown_commands: {
'dispose-audio': _Audio1,
}
});
const anim = builder.animation(
{
custom: {
molstar_trackball: {
name: 'spin',
params: { speed: -0.05 },
}
}
}
);
const prims = _1mbn.struct.primitives({
ref: 'start-story',
label_opacity: 0,
label_background_color: 'grey',
snapshot_key: 'intro'
});
prims.label({
text: 'Start story',
position: [13.5, -4, 7.7],
label_size: 8
});
anim.interpolate({
kind: 'scalar',
target_ref: 'start-story',
duration_ms: 1000,
start_ms: 1,
property: 'label_opacity',
start: 0.0,
end: 1.0,
});
return builder;
},
camera: {
position: [13.5, 21.1, 73.1],
target: [13.5, 21.1, 7.7],
up: [0, 1, 0],
} satisfies MVSNodeParams<'camera'>,
},
{
header: 'Molecule of the Month: Myoglobin',
key: 'intro',
description: description_intro,
description: description_p0,
linger_duration_ms: 45000,
transition_duration_ms: 500,
state: (): Root => {
@@ -247,39 +305,10 @@ const Steps = [
builder.canvas({ custom: { molstar_postprocessing: { enable_outline: false } } });
const _1mbn = structure(builder, '1MBN');
_1mbn.component({ selector: 'ligand' })
.representation({ ref: 'ligand', type: 'ball_and_stick' })
.color({ color: 'orange' });
// FE and O should be spacefill
_1mbn.component({ selector: { auth_seq_id: 155, label_atom_id: 'F' } })
.representation({ type: 'spacefill' })
.color({ color: 'yellow' });
_1mbn.component({ selector: { auth_seq_id: 154 } })
.representation({ type: 'spacefill' })
.color({ color: 'blue' });
_1mbn.component({ selector: { auth_seq_id: 154 } })
.representation({ type: 'spacefill' })
.color({ color: 'blue' });
const chA = _1mbn.component({ selector: { label_asym_id: 'A' } });
chA.representation({ type: 'surface', surface_type: 'gaussian' })
.color({ color: '#ff0303' })
.opacity({ ref: 'surfopa', opacity: 0.0 });
chA.representation({ type: 'line' })
.color({ custom: { molstar_color_theme_name: 'element-symbol' } })
.opacity({ ref: 'lineopa', opacity: 0.0 });
chA.representation({ type: 'cartoon' })
.color({ custom: { molstar_color_theme_name: 'secondary-structure' } });
const _1mbn = build1mbn(builder, '1MBN');
// whale
_1mbn.component({ selector: { label_asym_id: 'A' } })
_1mbn.struct.component({ selector: { label_asym_id: 'A' } })
.representation({ type: 'spacefill', custom: { molstar_representation_params: { ignoreLight: true } } })
.colorFromSource({
schema: 'all_atomic',
@@ -296,29 +325,12 @@ const Steps = [
}
}).opacity({ ref: 'cpkopa1', opacity: 0.0 });
_1mbn.component({ selector: { auth_seq_id: 155 } })
_1mbn.struct.component({ selector: { auth_seq_id: 155 } })
.representation({ type: 'spacefill', custom: { molstar_representation_params: { ignoreLight: true } } })
.color({ custom: GColors2 }).opacity({ ref: 'cpkopa2', opacity: 0.0 });
const prims = _1mbn.primitives({
ref: 'prims',
label_opacity: 1,
label_background_color: 'grey',
custom: {
molstar_markdown_commands: {
// 'apply-snapshot': 'interlude',
'play-audio': _Audio1,
}
}
});
prims.label({
text: 'Start Comments',
position: [13.5, 45.1, 7.7],
label_size: 5
});
addNextButton(builder, 'whale', [13.5, 0, 7.7]);
addNextButton(builder, 'whale', [13.5, -4, 7.7]);
// doesnt work for first slide, but work afterward
builder.extendRootCustomState({
molstar_on_load_markdown_commands: {
'play-audio': _Audio1,
@@ -543,7 +555,7 @@ const Steps = [
start: 0.0,
end: 1.0,
});
addNextButton(builder, 'oxygen', [-18.9, 10, 7.3]);
addNextButton(builder, 'oxygen', [-18.9, -4, 7.3]);
anim.interpolate({
kind: 'scalar',
target_ref: 'next',
@@ -897,7 +909,7 @@ function addNextButton(builder: any, snapshotKey: string, position: [number, num
.label({
ref: 'next_label',
position: position,
text: 'Click me to go next',
text: 'Next Scene →',
label_color: 'white',
label_size: 5
});
@@ -955,4 +967,54 @@ export function buildStory(): MVSData_States {
timestamp: new Date().toISOString(),
}
};
}
function build1mbn(builder: any, pdbId: string) {
const struct = structure(builder, '1MBN');
struct.component({ selector: 'ligand' })
.representation({ ref: 'ligand', type: 'ball_and_stick' })
.color({ color: 'orange' });
// FE and O should be spacefill
struct.component({ selector: { auth_seq_id: 155, label_atom_id: 'FE' } })
.representation({ type: 'spacefill' })
.color({ color: 'yellow' });
struct.component({ selector: { auth_seq_id: 154 } })
.representation({ type: 'spacefill' })
.color({ color: 'blue' });
struct.component({ selector: { auth_seq_id: 154 } })
.representation({ type: 'spacefill' })
.color({ color: 'blue' });
const chA = struct.component({ selector: { label_asym_id: 'A' } });
chA.representation({ type: 'surface', surface_type: 'gaussian' })
.color({ color: '#ff0303' })
.opacity({ ref: 'surfopa', opacity: 0.0 });
chA.representation({ type: 'line' })
.color({ custom: { molstar_color_theme_name: 'element-symbol' } })
.opacity({ ref: 'lineopa', opacity: 0.0 });
chA.representation({ type: 'cartoon' })
.color({ custom: { molstar_color_theme_name: 'secondary-structure' } });
return {
struct,
refs: {
surfaceOpacity: 'surfopa',
lineOpacity: 'lineopa',
}
};
}
function createAudioControls(url: string) {
return `
[ **▶ Play** ](${encodeURIComponent(`!play-audio=${url}`)})
[ **⏸ Pause** ](!pause-audio)
[ **⏹ Stop** ](!stop-audio)
[ **Hide** ](!dispose-audio)
`;
}

View File

@@ -5,21 +5,22 @@
* @author Ludovic Autin <ludovic.autin@gmail.com>
*/
import { Snapshot } from '../mvs-data';
import { Tree } from '../tree/generic/tree-schema';
import { clamp, lerp } from '../../../mol-math/interpolate';
import { MVSAnimationEasing, MVSAnimationNode, MVSAnimationSchema } from '../tree/animation/animation-tree';
import { MVSTree } from '../tree/mvs/mvs-tree';
import { SortedArray } from '../../../mol-data/int';
import * as EasingFns from '../../../mol-math/easing';
import { addDefaults } from '../tree/generic/tree-utils';
import { RuntimeContext } from '../../../mol-task';
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 { SortedArray } from '../../../mol-data/int';
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';
import { produce } from '../../../mol-util/produce';
export async function generateStateTransition(ctx: RuntimeContext, snapshot: Snapshot, snapshotIndex: number, snapshotCount: number) {
if (!snapshot.animation) return undefined;
@@ -33,17 +34,27 @@ export async function generateStateTransition(ctx: RuntimeContext, snapshot: Sna
...transitions.map(t => (t.params.start_ms ?? 0) + t.params.duration_ms)
);
const frames: MVSTree[] = [];
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, transitions, t, cache, nodeMap);
frames.push(root);
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 });
@@ -80,71 +91,91 @@ interface InterpolationCacheEntry {
rotation?: { axis: Vec3, angle: number, start: Quat, end: Quat },
}
function createSnapshot(tree: MVSTree, transitions: MVSAnimationNode<'interpolate'>[], time: number, cache: Map<any, InterpolationCacheEntry>, nodeMap: Map<string, (string | number)[]>) {
return produce(tree, (draft) => {
for (const transition of transitions) {
const nodePath = nodeMap.get(transition.params.target_ref);
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 = transition.params.property[0] === 'custom' ? node?.custom : node?.params;
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 = transition.params.start_ms ?? 0;
let t = clamp((time - startTime) / transition.params.duration_ms, 0, 1);
if (transition.params.kind === 'transform_matrix') {
processTransformMatrix(transition, target, t, cacheEntry);
continue;
}
t = applyFrequency(t, transition.params.frequency ?? 1, !!transition.params.alternate_direction);
const offset = transition.params.property[0] === 'custom' ? 1 : 0;
const startBase = transition.params.start ?? 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 <= startTime) {
assign(target, transition.params.property, startValue, offset);
continue;
}
const easing = EasingFnMap[transition.params.easing ?? 'linear'] ?? EasingFnMap['linear'];
t = easing(t);
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 === 'scalar') {
next = interpolateScalars(startValue, endValue, t, transition.params.noise_magnitude ?? 0, !!transition.params.discrete);
} else if (transition.params.kind === 'vec3') {
next = interpolateVectors(startValue, endValue, t, transition.params.noise_magnitude ?? 0, !!transition.params.spherical);
} else if (transition.params.kind === 'rotation_matrix') {
next = interpolateRotation(startValue, endValue, t, transition.params.noise_magnitude ?? 0, cacheEntry);
} else if (transition.params.kind === 'color') {
const color = paletteFn(t);
next = Color.toHexStyle(color);
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) {
@@ -163,6 +194,55 @@ function applyFrequency(t: number, frequency: number, alternate: boolean) {
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(),
@@ -172,31 +252,41 @@ const TransformState = {
pivotNeg: Vec3(),
temp: Mat4(),
};
function processTransformMatrix(transition: MVSAnimationNode<'interpolate'>, target: any, time: number, cache: InterpolationCacheEntry) {
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 offset = transition.params.property[0] === 'custom' ? 1 : 0;
const transform = select(target, transition.params.property, offset) ?? Mat4.identity();
const startRotation = transition.params.rotation_start ?? Mat3.fromMat4(Mat3(), transform);
const startTranslation = transition.params.translation_start ?? Mat4.getTranslation(Vec3(), transform);
const startScale = transition.params.scale_start ?? Mat4.getScaling(Vec3(), transform);
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 t = applyFrequency(time, transition.params.rotation_frequency ?? 1, !!transition.params.rotation_alternate_direction);
let easing = EasingFnMap[transition.params.rotation_easing ?? 'linear'] ?? EasingFnMap['linear'];
const rotation = interpolateRotation(startRotation as Mat3, endRotation as Mat3, easing(t), transition.params.rotation_noise_magnitude ?? 0, cache);
let rotation, translation, scale;
t = applyFrequency(time, transition.params.translation_frequency ?? 1, !!transition.params.translation_alternate_direction);
easing = EasingFnMap[transition.params.translation_easing ?? 'linear'] ?? EasingFnMap['linear'];
const translation = interpolateVec3(startTranslation as Vec3, endTranslation as Vec3 | undefined, easing(t), transition.params.translation_noise_magnitude ?? 0, false);
if (time <= 0) {
rotation = startRotation as Mat3;
translation = startTranslation as Vec3;
scale = startScale as Vec3;
} else {
const clampedTime = clamp(time, 0, 1);
t = applyFrequency(time, transition.params.scale_frequency ?? 1, !!transition.params.scale_alternate_direction);
easing = EasingFnMap[transition.params.scale_easing ?? 'linear'] ?? EasingFnMap['linear'];
const scale = interpolateVec3(startScale as Vec3, endScale as Vec3 | undefined, easing(t), transition.params.scale_noise_magnitude ?? 0, false);
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();
@@ -213,7 +303,7 @@ function processTransformMatrix(transition: MVSAnimationNode<'interpolate'>, tar
Mat4.mul(result, TransformState.rotation, result);
Mat4.mul(result, TransformState.translation, result);
assign(target, transition.params.property, result, offset);
return result;
}
function interpolateScalars(start: number | number[], end: number | number[] | undefined, t: number, noise: number, discrete: boolean) {

View File

@@ -136,7 +136,7 @@ async function assignStateTransition(ctx: RuntimeContext, plugin: PluginContext,
for (let i = 0; i < transitions.frames.length; i++) {
const frame = transitions.frames[i];
const molstarTree = convertMvsToMolstar(frame, options.sourceUrl);
const molstarTree = convertMvsToMolstar(frame[0], options.sourceUrl);
const entry = molstarTreeToEntry(
plugin,
molstarTree,
@@ -148,7 +148,7 @@ async function assignStateTransition(ctx: RuntimeContext, plugin: PluginContext,
StateTree.reuseTransformParams(entry.snapshot.data!.tree, parentEntry.snapshot.data!.tree);
animation.frames.push({
durationInMs: transitions.frametimeMs,
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,

View File

@@ -175,7 +175,7 @@ export const AnimateStateSnapshotTransition = PluginStateAnimation.create({
if (t.current >= animState.totalDuration) {
if (snapshot?.transition && animState.isInitial) {
const frameIndex = snapshot.transition.frames.length - 1;
ctx.plugin.managers.snapshot.setSnapshotAnimationFrame(frameIndex, false);
ctx.plugin.managers.snapshot.setSnapshotAnimationFrame(animState.totalDuration, false);
await setPartialSnapshot(ctx.plugin, snapshot.transition.frames[frameIndex]);
}
return { kind: 'finished' };
@@ -193,7 +193,7 @@ export const AnimateStateSnapshotTransition = PluginStateAnimation.create({
return { kind: 'skip' };
}
ctx.plugin.managers.snapshot.setSnapshotAnimationFrame(frameIndex, false);
ctx.plugin.managers.snapshot.setSnapshotAnimationFrame(t.current, false);
if (frameIndex === 0) {
await setPartialSnapshot(ctx.plugin, {
...transition.frames[frameIndex],

View File

@@ -11,6 +11,7 @@ import { PluginContext } from '../../mol-plugin/context';
import { Script } from '../../mol-script/script';
import { QueryContext, QueryFn, StructureElement, StructureSelection } from '../../mol-model/structure';
import { BehaviorSubject } from 'rxjs';
import { AnimateStateSnapshotTransition } from '../animation/built-in/state-snapshots';
export type MarkdownExtensionEvent = 'click' | 'mouse-enter' | 'mouse-leave';
@@ -46,6 +47,17 @@ export const BuiltInMarkdownExtension: MarkdownExtension[] = [
manager.plugin.managers.snapshot.applyKey(key);
}
},
{
name: 'next-snapshot',
execute: ({ event, args, manager }) => {
if (event !== 'click' || !('next-snapshot' in args)) return;
let dir: -1 | 1 = (+args['next-snapshot'] || 1) as -1 | 1;
if (!dir) return;
if (dir < 0) dir = -1;
else dir = 1;
manager.plugin.managers.snapshot.applyNext(dir);
}
},
{
name: 'focus-refs',
execute: ({ event, args, manager }) => {
@@ -185,6 +197,34 @@ export const BuiltInMarkdownExtension: MarkdownExtension[] = [
manager.audio.stop();
}
},
{
name: 'dispose-audio',
execute: ({ event, args, manager }) => {
if (event !== 'click' || !('dispose-audio' in args)) return;
manager.audio.dispose();
}
},
{
name: 'play-transition',
execute: ({ event, args, manager }) => {
if (event !== 'click' || !('play-transition' in args)) return;
manager.plugin.managers.animation.play(AnimateStateSnapshotTransition, {});
}
},
{
name: 'play-snapshots',
execute: ({ event, args, manager }) => {
if (event !== 'click' || !('play-snapshots' in args)) return;
manager.plugin.managers.snapshot.play({ restart: true });
}
},
{
name: 'stop-animation',
execute: ({ event, args, manager }) => {
if (event !== 'click' || !('stop-animation' in args)) return;
manager.plugin.managers.snapshot.stop();
}
},
];
export class MarkdownExtensionManager {

View File

@@ -25,7 +25,7 @@ export { PluginStateSnapshotManager };
interface StateManagerState {
current?: UUID,
currentAnimationFrame?: number,
currentAnimationTimeMs?: number,
entries: List<PluginStateSnapshotManager.Entry>,
isPlaying: boolean,
nextSnapshotDelayInMs: number
@@ -38,8 +38,8 @@ class PluginStateSnapshotManager extends StatefulPluginComponent<StateManagerSta
private defaultSnapshotId: UUID | undefined = undefined;
protected updateState(state: Partial<StateManagerState>) {
if ('current' in state && !('curentAnimationFrame' in state)) {
return super.updateState({ ...state, currentAnimationFrame: 0 });
if ('current' in state && !('currentAnimationTimeMs' in state)) {
return super.updateState({ ...state, currentAnimationTimeMs: 0 });
} else {
return super.updateState(state);
}
@@ -168,18 +168,23 @@ class PluginStateSnapshotManager extends StatefulPluginComponent<StateManagerSta
}
private animationFrameQueue = new SingleTaskQueue();
setSnapshotAnimationFrame(frame: number, load = false) {
if (this.updateState({ currentAnimationFrame: frame })) {
setSnapshotAnimationFrame(currentAnimationTimeMs: number, load = false) {
const entry = this.getEntry(this.state.current);
if (!entry) return;
const frameIndex = PluginState.getStateTransitionFrameIndex(entry.snapshot, currentAnimationTimeMs) ?? 0;
if (this.updateState({ currentAnimationTimeMs })) {
this.events.changed.next(void 0);
}
if (load) {
this.animationFrameQueue.run(() => {
const entry = this.getEntry(this.state.current);
if (!entry) return Promise.resolve();
return this.plugin.state.setAnimationSnapshot(entry.snapshot, frame);
return this.plugin.state.setAnimationSnapshot(entry.snapshot, frameIndex ?? 0);
});
}
return frameIndex;
}
getNextId(id: string | undefined, dir: -1 | 1) {
@@ -233,7 +238,7 @@ class PluginStateSnapshotManager extends StatefulPluginComponent<StateManagerSta
const next = entry && entry.snapshot;
if (!next) return;
await this.plugin.state.setSnapshot(next);
if (snapshot.playback && snapshot.playback.isPlaying) this.play(true);
if (snapshot.playback?.isPlaying) this.play({ delayFirst: true });
return next;
}
@@ -380,10 +385,18 @@ class PluginStateSnapshotManager extends StatefulPluginComponent<StateManagerSta
}
}
play(delayFirst: boolean = false) {
async play(options?: { delayFirst?: boolean, restart?: boolean }) {
if (this.state.isPlaying && !options?.delayFirst) {
if (options?.restart) {
await this.stop();
} else {
return;
}
}
this.updateState({ isPlaying: true });
if (delayFirst) {
if (options?.delayFirst) {
const e = this.getEntry(this.state.current);
if (!e) {
this.next();
@@ -398,21 +411,20 @@ class PluginStateSnapshotManager extends StatefulPluginComponent<StateManagerSta
}
}
stop() {
this.plugin.managers.animation.stop();
async stop() {
await this.plugin.managers.animation.stop();
this.updateState({ isPlaying: false });
if (typeof this.timeoutHandle !== 'undefined') clearTimeout(this.timeoutHandle);
this.timeoutHandle = void 0;
this.events.changed.next(void 0);
}
togglePlay() {
async togglePlay() {
if (this.state.isPlaying) {
this.stop();
this.plugin.managers.animation.stop();
await this.stop();
this.plugin.managers.markdownExtensions.audio.pause();
} else {
this.play();
await this.play();
}
}

View File

@@ -11,6 +11,7 @@ import { StateTransformParameters } from './state/common';
export class PluginUIContext extends PluginContext {
readonly customParamEditors = new Map<string, StateTransformParameters.Class>();
readonly customUIState: Record<string, any> = {};
private initCustomParamEditors() {
if (!this.spec.customParamEditors) return;

View File

@@ -29,6 +29,7 @@ import { StructureQuickStylesControls } from './structure/quick-styles';
import { Markdown } from './controls/markdown';
import { Slider } from './controls/slider';
import { AnimateStateSnapshotTransition } from '../mol-plugin-state/animation/built-in/state-snapshots';
import { PluginState } from '../mol-plugin/state';
export class TrajectoryViewportControls extends PluginUIComponent<{}, { show: boolean, label: string }> {
state = { show: false, label: '' };
@@ -191,14 +192,12 @@ export class StateSnapshotViewportControls extends PluginUIComponent<{}, { isBus
{hasAnimation && this.state.showAnimation && !isPlaying && <>
<div className='msp-state-snapshot-animation-slider msp-form-control'>
<Slider
value={snapshots.state.currentAnimationFrame ?? 0}
min={1}
step={1}
max={(entry?.snapshot.transition?.frames.length ?? 1)}
value={Math.round(100 * (snapshots.state.currentAnimationTimeMs ?? 0)) /100}
min={0}
step={PluginState.getMinFrameDuration(entry?.snapshot)}
max={PluginState.getStateTransitionDuration(entry?.snapshot) ?? 1000}
onChange={() => { }}
onChangeImmediate={v => {
snapshots.setSnapshotAnimationFrame(v - 1, true);
}}
onChangeImmediate={v => snapshots.setSnapshotAnimationFrame(v, true)}
hideInput
disabled={this.state.isBusy}
/>

View File

@@ -24,6 +24,8 @@ import { elementLabel } from '../mol-theme/label';
import { Icon, HelpOutlineSvg } from './controls/icons';
import { StructureSelectionManager } from '../mol-plugin-state/manager/structure/selection';
import { arrayEqual } from '../mol-util/array';
import { Observable } from 'rxjs';
import { ThemeProvider } from '../mol-theme/theme';
const MaxDisplaySequenceLength = 5000;
// TODO: add virtualized Select controls (at best with a search box)?
@@ -244,6 +246,13 @@ export class SequenceView extends PluginUIComponent<{ defaultMode?: SequenceView
}
});
const experimentalSequenceTheme = this.plugin.customUIState.experimentalSequenceTheme as Observable<ThemeProvider<any, any> | undefined> | undefined;
if (experimentalSequenceTheme) {
this.subscribe(experimentalSequenceTheme as Observable<ThemeProvider<any, any>>, theme => {
// do stuff
});
}
const modeOptions = this.plugin.spec.components?.sequenceViewer?.modeOptions;
if (modeOptions) {
const modeSet = new Set(modeOptions);

View File

@@ -27,6 +27,7 @@ import { PluginConfig } from './config';
import { PluginContext } from './context';
import { AnimateStateSnapshotTransition } from '../mol-plugin-state/animation/built-in/state-snapshots';
import { Scheduler } from '../mol-task';
import { memoizeLatest } from '../mol-util/memoize';
export { PluginState };
@@ -264,7 +265,23 @@ namespace PluginState {
}[],
}
export function getStateTransitionDuration(snapshot: Snapshot): number | undefined {
export const getMinFrameDuration = memoizeLatest((snapshot: Snapshot | undefined): number => {
if (!snapshot) return 1000 / 60;
const { transition } = snapshot;
if (!transition) return 1000 / 60;
let minDuration = Infinity;
for (const frame of transition.frames) {
if (frame.durationInMs > 0 && frame.durationInMs < minDuration) {
minDuration = frame.durationInMs;
}
}
if (!Number.isFinite(minDuration)) return 1000 / 60;
return minDuration;
});
export const getStateTransitionDuration = memoizeLatest((snapshot: Snapshot | undefined): number | undefined => {
if (!snapshot) return undefined;
const { transition } = snapshot;
if (!transition) return undefined;
let totalDuration = 0;
@@ -273,7 +290,20 @@ namespace PluginState {
totalDuration += frame.durationInMs;
}
return totalDuration;
}
});
export const getStateTransitionFrameTime = memoizeLatest((snapshot: Snapshot | undefined, frameIndex: number | undefined): number => {
if (!snapshot || frameIndex === undefined) return 0;
const { transition } = snapshot;
if (!transition) return 0;
let currentDuration = 0;
for (let i = 0; i < frameIndex; i++) {
if (transition.frames.length <= i) break;
const frame = transition.frames[i];
currentDuration += frame.durationInMs;
}
return currentDuration;
});
export function getStateTransitionFrameIndex(snapshot: Snapshot, timestamp: number): number | undefined {
const { transition } = snapshot;