Add mvs-stories app (#1523)

* mvs-stories app

* update mvs-stories example

* fix build

* fix UI bug

* support search params in stories app

* merge fixes

* PR feedback

* customize build filenames

* mvs-stories loading state & dev build script fixes

* multiple context example
This commit is contained in:
David Sehnal
2025-05-22 06:49:17 +02:00
committed by GitHub
parent 6778452d07
commit 9ac34ee13b
24 changed files with 4608 additions and 722 deletions

View File

@@ -5,6 +5,8 @@ Note that since we don't clearly distinguish between a public and private interf
## [Unreleased]
- Remove `xhr2` dependency for NodeJS, use `fetch`
- Add `mvs-stories` app included in the `molstar` NPM package
- Use the app in the corresponding example
## [v4.16.0] - 2025-05-20
- Load potentially big text files as `StringLike` to bypass string size limit

View File

@@ -11,23 +11,28 @@ import * as argparse from 'argparse';
import { sassPlugin } from 'esbuild-sass-plugin';
import * as os from 'os';
const AllApps = [
'viewer',
'docking-viewer',
'mesoscale-explorer'
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' },
];
const AllExamples = [
'proteopedia-wrapper',
'basic-wrapper',
'lighting',
'alpha-orbitals',
'alphafolddb-pae',
'mvs-stories',
'ihm-restraints',
'interactions',
'ligand-editor',
];
function findApp(name, kind) {
return Apps.find(a => a.name === name && a.kind === kind);
}
function mkDir(dir) {
try {
@@ -92,7 +97,9 @@ function examplesCssRenamePlugin({ root }) {
};
}
async function watch(name, kind) {
async function watch(app) {
const { name, kind } = app;
const prefix = kind === 'app'
? `./build/${name}`
: `./build/examples/${name}`;
@@ -102,14 +109,19 @@ async function watch(name, kind) {
entry = `./src/${kind}s/${name}/index.tsx`;
}
let filename = app.filename;
if (!filename) {
filename = kind === 'app' ? 'molstar.js' : 'index.js';
}
const ctx = await esbuild.context({
entryPoints: [entry],
tsconfig: './tsconfig.json',
bundle: true,
globalName: 'molstar',
globalName: app.globalName || 'molstar',
outfile: kind === 'app'
? `./build/${name}/molstar.js`
: `./build/examples/${name}/index.js`,
? `./build/${name}/${filename}`
: `./build/examples/${name}/${filename}`,
plugins: [
fileLoaderPlugin({ out: prefix }),
sassPlugin({
@@ -162,11 +174,11 @@ argParser.add_argument('--host', {
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));
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')));
console.log('Apps:', apps);
console.log('Examples:', examples);
console.log('Apps:', apps.map(a => a.name));
console.log('Examples:', examples.map(e => e.name));
console.log('');
function getLocalIPs() {
@@ -186,8 +198,8 @@ function getLocalIPs() {
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'));
for (const app of apps) promises.push(watch(app));
for (const example of examples) promises.push(watch(example));
console.log('Initial build...');

File diff suppressed because it is too large Load Diff

568
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -51,7 +51,8 @@
},
"files": [
"lib/",
"build/viewer/"
"build/viewer/",
"build/mvs-stories/"
],
"bin": {
"cif2bcif": "lib/commonjs/cli/cif2bcif/index.js",

View File

@@ -6,19 +6,20 @@
import { BehaviorSubject } from 'rxjs';
import { MVSData } from '../../extensions/mvs/mvs-data';
import type { MolComponentViewerModel } from './elements/viewer';
import type { MVSStoriesViewerModel } from './elements/viewer';
export type MolComponentCommand =
| { kind: 'load-mvs', format?: 'mvsj' | 'mvsx', url?: string, data?: MVSData }
export type MVSStoriesCommand =
| { kind: 'load-mvs', format?: 'mvsj' | 'mvsx', url?: string, data?: MVSData | string | Uint8Array }
export class MolComponentContext {
commands = new BehaviorSubject<MolComponentCommand | undefined>(undefined);
behavior = {
viewers: new BehaviorSubject<{ name?: string, model: MolComponentViewerModel }[]>([]),
export class MVSStoriesContext {
commands = new BehaviorSubject<MVSStoriesCommand | undefined>(undefined);
state = {
viewers: new BehaviorSubject<{ name?: string, model: MVSStoriesViewerModel }[]>([]),
isLoading: new BehaviorSubject(false),
};
dispatch(command: MolComponentCommand) {
dispatch(command: MVSStoriesCommand) {
this.commands.next(command);
}
@@ -26,12 +27,12 @@ export class MolComponentContext {
}
}
export function getMolComponentContext(options?: { name?: string, container?: object }) {
export function getMVSStoriesContext(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);
container.componentContexts[name] = new MVSStoriesContext(options?.name);
}
return container.componentContexts[name];
}

View File

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

View File

@@ -6,17 +6,18 @@
import { BehaviorSubject, distinctUntilChanged, map } from 'rxjs';
import { PluginComponent } from '../../../mol-plugin-state/component';
import { getMolComponentContext, MolComponentContext } from '../context';
import { MolComponentViewerModel } from './viewer';
import { getMVSStoriesContext, MVSStoriesContext } from '../context';
import { MVSStoriesViewerModel } from './viewer';
import Markdown from 'react-markdown';
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';
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 +27,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 +42,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 +67,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>
@@ -95,11 +106,11 @@ export function MolComponentSnapshotMarkdownUI({ model }: { model: MolComponentS
</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 +127,4 @@ export class MolComponentSnapshotMarkdownViewer extends HTMLElement {
}
}
window.customElements.define('mc-snapshot-markdown', MolComponentSnapshotMarkdownViewer);
window.customElements.define('mvs-stories-snapshot-markdown', MVSStoriesSnapshotMarkdownViewer);

View File

@@ -5,20 +5,19 @@
*/
import { MolViewSpec } from '../../../extensions/mvs/behavior';
import { loadMVS } from '../../../extensions/mvs/load';
import { MVSData } from '../../../extensions/mvs/mvs-data';
import { StringLike } from '../../../mol-io/common/string-like';
import { loadMVSData } from '../../../extensions/mvs/components/formats';
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) {
@@ -52,36 +51,46 @@ 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(StringLike.toString(data));
await loadMVS(this.plugin!, mvsData, { sanityChecks: true, sourceUrl: cmd.url });
} else if (cmd.data) {
await loadMVS(this.plugin!, cmd.data, { sanityChecks: true });
try {
this.context.state.isLoading.next(true);
if (cmd.kind === 'load-mvs') {
if (cmd.url) {
const data = await this.plugin.runTask(this.plugin.fetch({ url: cmd.url, type: cmd.format === 'mvsx' ? 'binary' : 'string' }));
await loadMVSData(this.plugin, data, cmd.format ?? 'mvsj', { sourceUrl: cmd.url });
} else if (cmd.data) {
await loadMVSData(this.plugin, cmd.data, cmd.format ?? 'mvsj');
}
}
} 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);
}
}
}
@@ -90,11 +99,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 },
});
@@ -111,4 +120,4 @@ export class MolComponentViewer extends HTMLElement {
}
}
window.customElements.define('mc-viewer', MolComponentViewer);
window.customElements.define('mvs-stories-viewer', MVSStoriesViewer);

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,94 @@
<!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;
}
@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" />
</div>
<div id="controls">
<mvs-stories-snapshot-markdown context-name="story1" style="flex-grow: 1;" />
</div>
<script>
var urlParams = new URLSearchParams(window.location.search);
var storyUrl = urlParams.get('story-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';
// }
mvsStories.loadFromURL(
storyUrl,
{ format: format || 'mvsj', contextName: 'story1' },
);
</script>
</body>
</html>

View File

@@ -0,0 +1,40 @@
/**
* 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 './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);
}
export { MVSData };

View File

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

View File

@@ -1,22 +1,4 @@
.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;
}
}
.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 +161,14 @@
border-width: 0;
border-top: 1px solid #E1E1E1;
}
}
@media (orientation:portrait) {
.mvs-stories-markdown-explanation {
font-size: 0.9rem;
}
.mvs-stories-markdown-explanation h3 {
font-size: 1.5rem;
}
}

View File

@@ -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';
@@ -536,27 +536,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?: { appendSnapshots?: 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[]) {
@@ -641,7 +622,7 @@ export const ViewerAutoPreset = StructureRepresentationPresetProvider({
export const PluginExtensions = {
wwPDBStructConn: wwPDBStructConnExtensionFunctions,
mvs: { MVSData, loadMVS },
mvs: { MVSData, loadMVS, loadMVSData },
modelArchive: {
qualityAssessment: {
config: MAQualityAssessmentConfig

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -4,6 +4,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
<link rel="icon" href="./favicon.ico" type="image/x-icon">
<title>Molecular Stories</title>
<style>
* {
@@ -70,32 +71,38 @@
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 />
</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;" />
</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>

View File

@@ -4,26 +4,23 @@
* @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);
@@ -50,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,
@@ -59,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(),
});
@@ -86,14 +83,13 @@ function init() {
createRoot(document.getElementById('select-story')!).render(<SelectStoryUI subject={CurrentStory} />);
}
(window as any).mc = MC;
(window as any).downloadStory = () => {
if (CurrentStory.value?.kind !== 'built-in') return;
const id = CurrentStory.value.id;
const story = Stories.find(s => s.id === id);
if (!story) return;
const data = JSON.stringify(story.buildStory(), null, 2);
download(new Blob([data], { type: 'application/json' }), 'story.mvsj');
download(new Blob([data], { type: 'application/json' }), `${id}-story.mvsj`);
};
(window as any).initStories = init;
(window as any).CurrentStory = CurrentStory;

View File

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

View File

@@ -15,7 +15,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';
@@ -131,6 +131,36 @@ 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, sourceUrl: parsed.sourceUrl, ...options });
}));
} else {
throw new Error(`Unknown MolViewSpec format: ${format}`);
}
}
/** 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);

View File

@@ -207,7 +207,8 @@ class StateTreeNodeLabel extends PluginUIComponent<{ cell: StateObjectCell, dept
setCurrentRoot = (e?: React.MouseEvent<HTMLElement>) => {
e?.preventDefault();
e?.currentTarget.blur();
PluginCommands.State.SetCurrentObject(this.plugin, { state: this.props.cell.parent!, ref: StateTransform.RootRef });
if (!this.props.cell.parent) return;
PluginCommands.State.SetCurrentObject(this.plugin, { state: this.props.cell.parent, ref: StateTransform.RootRef });
};
remove = (e?: React.MouseEvent<HTMLElement>) => {

View File

@@ -13,61 +13,63 @@ class VersionFilePlugin {
}
}
const sharedConfig = {
module: {
rules: [
{
test: /\.(html|ico)$/,
use: [{
loader: 'file-loader',
options: { name: '[name].[ext]' }
}]
},
{
test: /\.(s*)css$/,
use: [
MiniCssExtractPlugin.loader,
{ loader: 'css-loader', options: { sourceMap: false } },
{ loader: 'sass-loader', options: { sourceMap: false } },
]
},
{
test: /\.(jpg)$/i,
type: 'asset/resource',
},
]
},
plugins: [
new ExtraWatchWebpackPlugin({
files: [
'./lib/**/*.scss',
'./lib/**/*.html'
],
}),
new webpack.DefinePlugin({
'process.env.DEBUG': JSON.stringify(process.env.DEBUG),
'__MOLSTAR_DEBUG_TIMESTAMP__': webpack.DefinePlugin.runtimeValue(() => `${new Date().valueOf()}`, true)
}),
new MiniCssExtractPlugin({ filename: 'molstar.css' }),
new VersionFilePlugin(),
],
resolve: {
modules: [
'node_modules',
path.resolve(__dirname, 'lib/')
function sharedConfig(options) {
return {
module: {
rules: [
{
test: /\.(html|ico)$/,
use: [{
loader: 'file-loader',
options: { name: '[name].[ext]' }
}]
},
{
test: /\.(s*)css$/,
use: [
MiniCssExtractPlugin.loader,
{ loader: 'css-loader', options: { sourceMap: false } },
{ loader: 'sass-loader', options: { sourceMap: false } },
]
},
{
test: /\.(jpg)$/i,
type: 'asset/resource',
},
]
},
plugins: [
new ExtraWatchWebpackPlugin({
files: [
'./lib/**/*.scss',
'./lib/**/*.html'
],
}),
new webpack.DefinePlugin({
'process.env.DEBUG': JSON.stringify(process.env.DEBUG),
'__MOLSTAR_DEBUG_TIMESTAMP__': webpack.DefinePlugin.runtimeValue(() => `${new Date().valueOf()}`, true)
}),
new MiniCssExtractPlugin({ filename: (options && options.cssFilename) || 'molstar.css' }),
new VersionFilePlugin(),
],
fallback: {
fs: false,
vm: false,
buffer: false,
crypto: require.resolve('crypto-browserify'),
path: require.resolve('path-browserify'),
stream: require.resolve('stream-browserify'),
resolve: {
modules: [
'node_modules',
path.resolve(__dirname, 'lib/')
],
fallback: {
fs: false,
vm: false,
buffer: false,
crypto: require.resolve('crypto-browserify'),
path: require.resolve('path-browserify'),
stream: require.resolve('stream-browserify'),
}
},
watchOptions: {
aggregateTimeout: 750
}
},
watchOptions: {
aggregateTimeout: 750
}
};
};
function createEntry(src, outFolder, outFilename, isNode) {
@@ -75,15 +77,16 @@ function createEntry(src, outFolder, outFilename, isNode) {
target: isNode ? 'node' : void 0,
entry: path.resolve(__dirname, `lib/${src}.js`),
output: { filename: `${outFilename}.js`, path: path.resolve(__dirname, `build/${outFolder}`) },
...sharedConfig
...sharedConfig()
};
}
function createEntryPoint(name, dir, out, library) {
function createEntryPoint(name, dir, out, library, options) {
const filename = options && options.filename ? options.filename : `${library || name}.js`;
return {
entry: path.resolve(__dirname, `lib/${dir}/${name}.js`),
output: { filename: `${library || name}.js`, path: path.resolve(__dirname, `build/${out}`), library: library || out, libraryTarget: 'umd', assetModuleFilename: 'images/[hash][ext][query]', 'publicPath': '' },
...sharedConfig
output: { filename: filename, path: path.resolve(__dirname, `build/${out}`), library: library || out, libraryTarget: 'umd', assetModuleFilename: 'images/[hash][ext][query]', 'publicPath': '' },
...sharedConfig(options)
};
}
@@ -97,11 +100,11 @@ function createNodeEntryPoint(name, dir, out) {
'node-fetch': 'require("node-fetch")',
'util.promisify': 'require("util.promisify")',
},
...sharedConfig
...sharedConfig()
};
}
function createApp(name, library) { return createEntryPoint('index', `apps/${name}`, name, library); }
function createApp(name, library, options) { return createEntryPoint('index', `apps/${name}`, name, library, options); }
function createExample(name) { return createEntry(`examples/${name}/index`, `examples/${name}`, 'index'); }
function createBrowserTest(name) { return createEntryPoint(name, 'tests/browser', 'tests'); }
function createNodeApp(name) { return createNodeEntryPoint('index', `apps/${name}`, name); }

View File

@@ -22,6 +22,7 @@ module.exports = [
createApp('viewer', 'molstar'),
createApp('docking-viewer', 'molstar'),
createApp('mesoscale-explorer', 'molstar'),
createApp('mvs-stories', 'mvsStories', { filename: 'mvs-stories.js', cssFilename: 'mvs-stories.css' }),
...examples.map(createExample),
...tests.map(createBrowserTest)
];

View File

@@ -15,5 +15,6 @@ module.exports = [
createApp('viewer', 'molstar'),
createApp('docking-viewer', 'molstar'),
createApp('mesoscale-explorer', 'molstar'),
createApp('mvs-stories', 'mvsStories', { filename: 'mvs-stories.js', cssFilename: 'mvs-stories.css' }),
...examples.map(createExample)
];