Переглянути джерело

Merge branch 'master' of https://github.com/molstar/molstar into forkdev

autin 5 роки тому
батько
коміт
56ea62af71

+ 1 - 1
package-lock.json

@@ -1,6 +1,6 @@
 {
   "name": "molstar",
-  "version": "0.7.0-dev.13",
+  "version": "0.7.0-dev.17",
   "lockfileVersion": 1,
   "requires": true,
   "dependencies": {

+ 3 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "molstar",
-  "version": "0.7.0-dev.13",
+  "version": "0.7.0-dev.17",
   "description": "A comprehensive macromolecular library.",
   "homepage": "https://github.com/molstar/molstar#readme",
   "repository": {
@@ -38,7 +38,8 @@
     "postversion": "git push && git push --tags"
   },
   "files": [
-    "lib/"
+    "lib/",
+    "build/viewer/"
   ],
   "bin": {
     "cif2bcif": "lib/apps/cif2bcif/index.js",

+ 63 - 0
src/apps/viewer/embedded.html

@@ -0,0 +1,63 @@
+<!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>Embedded Mol* Viewer</title>
+        <style>
+            #app {
+                position: absolute;
+                left: 100px;
+                top: 100px;
+                width: 800px;
+                height: 600px;
+            }
+        </style>
+        <link rel="stylesheet" type="text/css" href="molstar.css" />
+    </head>
+    <body>
+        <div id="app"></div>
+        <script type="text/javascript" src="./molstar.js"></script>
+        <script type="text/javascript">
+            var viewer = new molstar.Viewer('app', {
+                layoutIsExpanded: false,
+                layoutShowControls: false,
+                layoutShowRemoteState: false,
+                layoutShowSequence: true,
+                layoutShowLog: false,
+                layoutShowLeftPanel: true,
+
+                viewportShowExpand: true,
+                viewportShowSelectionMode: false,
+                viewportShowAnimation: false,
+
+                pdbProvider: 'rcsb',
+                emdbProvider: 'rcsb',
+            });
+            viewer.loadPdb('7bv2');
+            viewer.loadEmdb('EMD-30210');
+
+            // TODO add Volume.customProperty and load suggested isoValue via custom property
+            var sub = viewer.plugin.managers.volume.hierarchy.behaviors.selection.subscribe(function (value) {
+                if (value.volume?.representations[0]) {
+                    var ref = value.volume.representations[0].cell;
+                    var tree = viewer.plugin.state.data.build().to(ref).update({
+                        type: {
+                            name: 'isosurface',
+                            params: {
+                                isoValue: {
+                                    kind: 'relative',
+                                    relativeValue: 6
+                                }
+                            }
+                        },
+                        colorTheme: ref.transform.params?.colorTheme
+                    });
+                    viewer.plugin.runTask(viewer.plugin.state.data.updateTree(tree));
+                    if (typeof sub !== 'undefined') sub.unsubscribe();
+                }
+            });
+        </script>
+    </body>
+</html>

+ 35 - 2
src/apps/viewer/index.html

@@ -34,10 +34,43 @@
                 height: 600px;
             }
         </style>
-        <link rel="stylesheet" type="text/css" href="app.css" />
+        <link rel="stylesheet" type="text/css" href="molstar.css" />
     </head>
     <body>
         <div id="app"></div>
-        <script type="text/javascript" src="./index.js"></script>
+        <script type="text/javascript" src="./molstar.js"></script>
+        <script type="text/javascript">
+            function getParam(name, regex) {
+                var r = new RegExp(name + '=' + '(' + regex + ')[&]?', 'i');
+                return decodeURIComponent(((window.location.search || '').match(r) || [])[1] || '');
+            }
+
+            var hideControls = getParam('hide-controls', '[^&]+').trim() === '1';
+            var viewer = new molstar.Viewer('app', {
+                layoutShowControls: !hideControls,
+                viewportShowExpand: false,
+            });
+
+            var snapshotId = getParam('snapshot-id', '[^&]+').trim();
+            if (snapshotId) viewer.setRemoteSnapshot(snapshotId);
+
+            var snapshotUrl = getParam('snapshot-url', '[^&]+').trim();
+            var snapshotUrlType = getParam('snapshot-url-type', '[^&]+').toLowerCase().trim();
+            if (snapshotUrl && snapshotUrlType) viewer.loadSnapshotFromUrl(snapshotUrl, snapshotUrlType);
+
+            var structureUrl = getParam('structure-url', '[^&]+').trim();
+            var structureUrlFormat = getParam('structure-url-format', '[a-z]+').toLowerCase().trim();
+            var structureUrlIsBinary = getParam('structure-url-is-binary', '[^&]+').trim() === '1';
+            if (structureUrl) viewer.loadStructureFromUrl(structureUrl, structureUrlFormat, structureUrlIsBinary);
+
+            var pdb = getParam('pdb', '[^&]+').trim();
+            if (pdb) viewer.loadPdb(pdb);
+
+            var pdbDev = getParam('pdb-dev', '[^&]+').trim();
+            if (pdbDev) viewer.loadPdbDev(pdbDev);
+
+            var emdb = getParam('emdb', '[^&]+').trim();
+            if (emdb) viewer.loadEmdb(emdb);
+        </script>
     </body>
 </html>

+ 134 - 64
src/apps/viewer/index.ts

@@ -8,84 +8,110 @@
 import '../../mol-util/polyfill';
 import { createPlugin, DefaultPluginSpec } from '../../mol-plugin';
 import './index.html';
+import './embedded.html';
 import './favicon.ico';
 import { PluginContext } from '../../mol-plugin/context';
 import { PluginCommands } from '../../mol-plugin/commands';
 import { PluginSpec } from '../../mol-plugin/spec';
-import { DownloadStructure } from '../../mol-plugin-state/actions/structure';
+import { DownloadStructure, PdbDownloadProvider } from '../../mol-plugin-state/actions/structure';
 import { PluginConfig } from '../../mol-plugin/config';
 import { CellPack } from '../../extensions/cellpack';
 import { RCSBAssemblySymmetry, RCSBValidationReport } from '../../extensions/rcsb';
 import { PDBeStructureQualityReport } from '../../extensions/pdbe';
 import { Asset } from '../../mol-util/assets';
+import { ObjectKeys } from '../../mol-util/type-helpers';
+import { PluginState } from '../../mol-plugin/state';
+import { DownloadDensity } from '../../mol-plugin-state/actions/volume';
+import { PluginLayoutControlsDisplay } from '../../mol-plugin/layout';
 require('mol-plugin-ui/skin/light.scss');
 
-function getParam(name: string, regex: string): string {
-    let r = new RegExp(`${name}=(${regex})[&]?`, 'i');
-    return decodeURIComponent(((window.location.search || '').match(r) || [])[1] || '');
-}
+const Extensions = {
+    'cellpack': PluginSpec.Behavior(CellPack),
+    'pdbe-structure-quality-report': PluginSpec.Behavior(PDBeStructureQualityReport),
+    'rcsb-assembly-symmetry': PluginSpec.Behavior(RCSBAssemblySymmetry),
+    'rcsb-validation-report': PluginSpec.Behavior(RCSBValidationReport)
+};
 
-const hideControls = getParam('hide-controls', `[^&]+`) === '1';
+const DefaultViewerOptions = {
+    extensions: ObjectKeys(Extensions),
+    layoutIsExpanded: true,
+    layoutShowControls: true,
+    layoutShowRemoteState: true,
+    layoutControlsDisplay: 'reactive' as PluginLayoutControlsDisplay,
+    layoutShowSequence: true,
+    layoutShowLog: true,
+    layoutShowLeftPanel: true,
 
-function init() {
-    const spec: PluginSpec = {
-        actions: [...DefaultPluginSpec.actions],
-        behaviors: [
-            ...DefaultPluginSpec.behaviors,
-            PluginSpec.Behavior(CellPack),
-            PluginSpec.Behavior(PDBeStructureQualityReport),
-            PluginSpec.Behavior(RCSBAssemblySymmetry),
-            PluginSpec.Behavior(RCSBValidationReport),
-        ],
-        animations: [...DefaultPluginSpec.animations || []],
-        customParamEditors: DefaultPluginSpec.customParamEditors,
-        layout: {
-            initial: {
-                isExpanded: true,
-                showControls: !hideControls
+    viewportShowExpand: PluginConfig.Viewport.ShowExpand.defaultValue,
+    viewportShowSelectionMode: PluginConfig.Viewport.ShowSelectionMode.defaultValue,
+    viewportShowAnimation: PluginConfig.Viewport.ShowAnimation.defaultValue,
+    pluginStateServer: PluginConfig.State.DefaultServer.defaultValue,
+    volumeStreamingServer: PluginConfig.VolumeStreaming.DefaultServer.defaultValue,
+    pdbProvider: PluginConfig.Download.DefaultPdbProvider.defaultValue,
+    emdbProvider: PluginConfig.Download.DefaultEmdbProvider.defaultValue,
+};
+type ViewerOptions = typeof DefaultViewerOptions;
+
+export class Viewer {
+    plugin: PluginContext
+
+    constructor(elementId: string, options: Partial<ViewerOptions> = {}) {
+        const o = { ...DefaultViewerOptions, ...options };
+
+        const spec: PluginSpec = {
+            actions: [...DefaultPluginSpec.actions],
+            behaviors: [
+                ...DefaultPluginSpec.behaviors,
+                ...o.extensions.map(e => Extensions[e]),
+            ],
+            animations: [...DefaultPluginSpec.animations || []],
+            customParamEditors: DefaultPluginSpec.customParamEditors,
+            layout: {
+                initial: {
+                    isExpanded: o.layoutIsExpanded,
+                    showControls: o.layoutShowControls,
+                    controlsDisplay: o.layoutControlsDisplay,
+                },
+                controls: {
+                    ...DefaultPluginSpec.layout && DefaultPluginSpec.layout.controls,
+                    top: o.layoutShowSequence ? undefined : 'none',
+                    bottom: o.layoutShowLog ? undefined : 'none',
+                    left: o.layoutShowLeftPanel ? undefined : 'none',
+                }
             },
-            controls: {
-                ...DefaultPluginSpec.layout && DefaultPluginSpec.layout.controls
-            }
-        },
-        config: DefaultPluginSpec.config
-    };
-    spec.config?.set(PluginConfig.Viewport.ShowExpand, false);
-    const plugin = createPlugin(document.getElementById('app')!, spec);
-    trySetSnapshot(plugin);
-    tryLoadFromUrl(plugin);
-}
+            components: {
+                ...DefaultPluginSpec.components,
+                remoteState: o.layoutShowRemoteState ? 'default' : 'none',
+            },
+            config: DefaultPluginSpec.config
+        };
 
-async function trySetSnapshot(ctx: PluginContext) {
-    try {
-        const snapshotUrl = getParam('snapshot-url', `[^&]+`);
-        const snapshotId = getParam('snapshot-id', `[^&]+`);
-        if (!snapshotUrl && !snapshotId) return;
-        // TODO parametrize the server
-        const url = snapshotId
-            ? `https://webchem.ncbr.muni.cz/molstar-state/get/${snapshotId}`
-            : snapshotUrl;
-        await PluginCommands.State.Snapshots.Fetch(ctx, { url });
-    } catch (e) {
-        ctx.log.error('Failed to load snapshot.');
-        console.warn('Failed to load snapshot', e);
-    }
-}
+        spec.config?.set(PluginConfig.Viewport.ShowExpand, o.viewportShowExpand);
+        spec.config?.set(PluginConfig.Viewport.ShowSelectionMode, o.viewportShowSelectionMode);
+        spec.config?.set(PluginConfig.Viewport.ShowAnimation, o.viewportShowAnimation);
+        spec.config?.set(PluginConfig.State.DefaultServer, o.pluginStateServer);
+        spec.config?.set(PluginConfig.State.CurrentServer, o.pluginStateServer);
+        spec.config?.set(PluginConfig.VolumeStreaming.DefaultServer, o.volumeStreamingServer);
+        spec.config?.set(PluginConfig.Download.DefaultPdbProvider, o.pdbProvider);
+        spec.config?.set(PluginConfig.Download.DefaultEmdbProvider, o.emdbProvider);
 
-async function tryLoadFromUrl(ctx: PluginContext) {
-    const url = getParam('loadFromURL', '[^&]+').trim();
-    try {
-        if (!url) return;
+        const element = document.getElementById(elementId);
+        if (!element) throw new Error(`Could not get element with id '${elementId}'`);
+        this.plugin = createPlugin(element, spec);
+    }
 
-        let format = 'cif', isBinary = false;
-        switch (getParam('loadFromURLFormat', '[a-z]+').toLocaleLowerCase().trim()) {
-            case 'pdb': format = 'pdb'; break;
-            case 'mmbcif': isBinary = true; break;
-        }
+    async setRemoteSnapshot(id: string) {
+        const url = `${this.plugin.config.get(PluginConfig.State.CurrentServer)}/get/${id}`;
+        await PluginCommands.State.Snapshots.Fetch(this.plugin, { url });
+    }
 
-        const params = DownloadStructure.createDefaultParams(void 0 as any, ctx);
+    async loadSnapshotFromUrl(url: string, type: PluginState.SnapshotType) {
+        await PluginCommands.State.Snapshots.OpenUrl(this.plugin, { url, type });
+    }
 
-        return ctx.runTask(ctx.state.data.applyAction(DownloadStructure, {
+    async loadStructureFromUrl(url: string, format = 'cif', isBinary = false) {
+        const params = DownloadStructure.createDefaultParams(undefined, this.plugin);
+        return this.plugin.runTask(this.plugin.state.data.applyAction(DownloadStructure, {
             source: {
                 name: 'url',
                 params: {
@@ -96,10 +122,54 @@ async function tryLoadFromUrl(ctx: PluginContext) {
                 }
             }
         }));
-    } catch (e) {
-        ctx.log.error(`Failed to load from URL (${url})`);
-        console.warn(`Failed to load from URL (${url})`, e);
     }
-}
 
-init();
+    async loadPdb(pdb: string) {
+        const params = DownloadStructure.createDefaultParams(undefined, this.plugin);
+        const provider = this.plugin.config.get(PluginConfig.Download.DefaultPdbProvider)!;
+        return this.plugin.runTask(this.plugin.state.data.applyAction(DownloadStructure, {
+            source: {
+                name: 'pdb' as const,
+                params: {
+                    provider: {
+                        id: pdb,
+                        server: {
+                            name: provider,
+                            params: PdbDownloadProvider[provider].defaultValue as any
+                        }
+                    },
+                    options: params.source.params.options,
+                }
+            }
+        }));
+    }
+
+    async loadPdbDev(pdbDev: string) {
+        const params = DownloadStructure.createDefaultParams(undefined, this.plugin);
+        return this.plugin.runTask(this.plugin.state.data.applyAction(DownloadStructure, {
+            source: {
+                name: 'pdb-dev' as const,
+                params: {
+                    id: pdbDev,
+                    options: params.source.params.options,
+                }
+            }
+        }));
+    }
+
+    async loadEmdb(emdb: string) {
+        const provider = this.plugin.config.get(PluginConfig.Download.DefaultEmdbProvider)!;
+        return this.plugin.runTask(this.plugin.state.data.applyAction(DownloadDensity, {
+            source: {
+                name: 'pdb-emd-ds' as const,
+                params: {
+                    provider: {
+                        id: emdb,
+                        server: provider,
+                    },
+                    detail: 3,
+                }
+            }
+        }));
+    }
+}

+ 6 - 6
src/examples/basic-wrapper/index.html

@@ -41,7 +41,7 @@
                 display: block;
             }
         </style>
-        <link rel="stylesheet" type="text/css" href="app.css" />
+        <link rel="stylesheet" type="text/css" href="molstar.css" />
         <script type="text/javascript" src="./index.js"></script>
     </head>
     <body>
@@ -55,13 +55,13 @@
             </select>
         </div>
         <div id="app"></div>
-        <script>      
+        <script>
             function $(id) { return document.getElementById(id); }
-        
+
             var pdbId = '1grm', assemblyId= '1';
             var url = 'https://www.ebi.ac.uk/pdbe/static/entry/' + pdbId + '_updated.cif';
             var format = 'mmcif';
-            
+
             $('url').value = url;
             $('url').onchange = function (e) { url = e.target.value; }
             $('assemblyId').value = assemblyId;
@@ -86,7 +86,7 @@
 
             addHeader('Camera');
             addControl('Toggle Spin', () => BasicMolStarWrapper.toggleSpin());
-            
+
             addSeparator();
 
             addHeader('Animation');
@@ -115,7 +115,7 @@
             addControl('Static Superposition', () => BasicMolStarWrapper.tests.staticSuperposition());
             addControl('Dynamic Superposition', () => BasicMolStarWrapper.tests.dynamicSuperposition());
             addControl('Validation Tooltip', () => BasicMolStarWrapper.tests.toggleValidationTooltip());
-            
+
             addControl('Show Toasts', () => BasicMolStarWrapper.tests.showToasts());
             addControl('Hide Toasts', () => BasicMolStarWrapper.tests.hideToasts());
 

+ 3 - 3
src/examples/lighting/index.html

@@ -38,16 +38,16 @@
                 display: block;
             }
         </style>
-        <link rel="stylesheet" type="text/css" href="app.css" />
+        <link rel="stylesheet" type="text/css" href="molstar.css" />
         <script type="text/javascript" src="./index.js"></script>
     </head>
     <body>
         <div id='controls'></div>
         <div id="app"></div>
-        <script>      
+        <script>
             LightingDemo.init('app')
             LightingDemo.load({ url: 'https://files.rcsb.org/download/1M07.cif', assemblyId: '1' })
-            
+
             addHeader('Example PDB IDs');
             addControl('1M07', () => LightingDemo.load({ url: 'https://files.rcsb.org/download/1M07.cif', assemblyId: '1' }));
             addControl('6HY0', () => LightingDemo.load({ url: 'https://files.rcsb.org/download/6HY0.cif', assemblyId: '1' }));

+ 6 - 6
src/examples/proteopedia-wrapper/index.html

@@ -48,7 +48,7 @@
                 width: 300px;
             }
         </style>
-        <link rel="stylesheet" type="text/css" href="app.css" />
+        <link rel="stylesheet" type="text/css" href="molstar.css" />
         <script type="text/javascript" src="./index.js"></script>
     </head>
     <body>
@@ -65,7 +65,7 @@
         <div id="app"></div>
         <div id="volume-streaming-wrapper"></div>
         <script>
-            // it might be a good idea to define these colors in a separate script file 
+            // it might be a good idea to define these colors in a separate script file
             var CustomColors = [0x00ff00, 0x0000ff];
 
             // create an instance of the plugin
@@ -74,11 +74,11 @@
             console.log('Wrapper version', MolStarProteopediaWrapper.VERSION_MAJOR, MolStarProteopediaWrapper.VERSION_MINOR);
 
             function $(id) { return document.getElementById(id); }
-        
+
             var pdbId = '1cbs', assemblyId= 'preferred', isBinary = true;
             var url = 'https://www.ebi.ac.uk/pdbe/entry-files/download/' + pdbId + '.bcif'
             var format = 'cif';
-            
+
             $('url').value = url;
             $('url').onchange = function (e) { url = e.target.value; }
             $('assemblyId').value = assemblyId;
@@ -144,7 +144,7 @@
             // Same as "wheel icon" and Viewport options
             // addControl('Clip', () => PluginWrapper.viewport.setSettings({ clip: [33, 66] }));
             // addControl('Reset Clip', () => PluginWrapper.viewport.setSettings({ clip: [1, 100] }));
-            
+
             addSeparator();
 
             addHeader('Animation');
@@ -177,7 +177,7 @@
             addControl('Init', () => PluginWrapper.experimentalData.init($('volume-streaming-wrapper')));
             addControl('Remove', () => PluginWrapper.experimentalData.remove());
 
-            addSeparator(); 
+            addSeparator();
             addHeader('State');
 
             var snapshot;

+ 13 - 8
src/mol-plugin-state/actions/structure.ts

@@ -17,6 +17,7 @@ import { StateTransforms } from '../transforms';
 import { Download } from '../transforms/data';
 import { CustomModelProperties, CustomStructureProperties, TrajectoryFromModelAndCoordinates } from '../transforms/model';
 import { Asset } from '../../mol-util/assets';
+import { PluginConfig } from '../../mol-plugin/config';
 
 const DownloadModelRepresentationOptions = (plugin: PluginContext) => PD.Group({
     type: RootStructureDefinition.getParams(void 0, 'auto').type,
@@ -26,6 +27,16 @@ const DownloadModelRepresentationOptions = (plugin: PluginContext) => PD.Group({
     asTrajectory: PD.Optional(PD.Boolean(false, { description: 'Load all entries into a single trajectory.' }))
 }, { isExpanded: false });
 
+export const PdbDownloadProvider = {
+    'rcsb': PD.Group({
+        encoding: PD.Select('bcif', [['cif', 'cif'], ['bcif', 'bcif']] as ['cif' | 'bcif', string][]),
+    }, { label: 'RCSB PDB', isFlat: true }),
+    'pdbe': PD.Group({
+        variant: PD.Select('updated-bcif', [['updated-bcif', 'Updated (bcif)'], ['updated', 'Updated'], ['archival', 'Archival']] as ['updated' | 'archival', string][]),
+    }, { label: 'PDBe', isFlat: true }),
+};
+export type PdbDownloadProvider = keyof typeof PdbDownloadProvider;
+
 export { DownloadStructure };
 type DownloadStructure = typeof DownloadStructure
 const DownloadStructure = StateAction.build({
@@ -33,19 +44,13 @@ const DownloadStructure = StateAction.build({
     display: { name: 'Download Structure', description: 'Load a structure from the provided source and create its representation.' },
     params: (_, plugin: PluginContext) => {
         const options = DownloadModelRepresentationOptions(plugin);
+        const defaultPdbProvider = plugin.config.get(PluginConfig.Download.DefaultPdbProvider) || 'pdbe';
         return {
             source: PD.MappedStatic('pdb', {
                 'pdb': PD.Group({
                     provider: PD.Group({
                         id: PD.Text('1tqn', { label: 'PDB Id(s)', description: 'One or more comma/space separated PDB ids.' }),
-                        server: PD.MappedStatic('pdbe', {
-                            'rcsb': PD.Group({
-                                encoding: PD.Select('bcif', [['cif', 'cif'], ['bcif', 'bcif']] as ['cif' | 'bcif', string][]),
-                            }, { label: 'RCSB PDB', isFlat: true }),
-                            'pdbe': PD.Group({
-                                variant: PD.Select('updated-bcif', [['updated-bcif', 'Updated (bcif)'], ['updated', 'Updated'], ['archival', 'Archival']] as ['updated' | 'archival', string][]),
-                            }, { label: 'PDBe', isFlat: true }),
-                        }),
+                        server: PD.MappedStatic(defaultPdbProvider, PdbDownloadProvider),
                     }, { pivot: 'id' }),
                     options
                 }, { isFlat: true, label: 'PDB' }),

+ 3 - 1
src/mol-plugin-state/actions/volume.ts

@@ -16,6 +16,8 @@ import { DataFormatProvider } from '../formats/provider';
 import { Asset } from '../../mol-util/assets';
 import { StateTransforms } from '../transforms';
 
+export type EmdbDownloadProvider = 'pdbe' | 'rcsb'
+
 export { DownloadDensity };
 type DownloadDensity = typeof DownloadDensity
 const DownloadDensity = StateAction.build({
@@ -42,7 +44,7 @@ const DownloadDensity = StateAction.build({
                 'pdb-emd-ds': PD.Group({
                     provider: PD.Group({
                         id: PD.Text('emd-8004', { label: 'Id' }),
-                        server: PD.Select('pdbe', [['pdbe', 'PDBe'], ['rcsb', 'RCSB PDB']]),
+                        server: PD.Select<EmdbDownloadProvider>('pdbe', [['pdbe', 'PDBe'], ['rcsb', 'RCSB PDB']]),
                     }, { pivot: 'id' }),
                     detail: PD.Numeric(3, { min: 0, max: 10, step: 1 }, { label: 'Detail' }),
                 }, { isFlat: true }),

+ 13 - 1
src/mol-plugin-state/component.ts

@@ -7,6 +7,7 @@
 import { shallowMergeArray } from '../mol-util/object';
 import { RxEventHelper } from '../mol-util/rx-event-helper';
 import { Subscription, Observable } from 'rxjs';
+import { arraySetRemove } from '../mol-util/array';
 
 export class PluginComponent {
     private _ev: RxEventHelper | undefined;
@@ -14,7 +15,18 @@ export class PluginComponent {
 
     protected subscribe<T>(obs: Observable<T>, action: (v: T) => void) {
         if (typeof this.subs === 'undefined') this.subs = [];
-        this.subs.push(obs.subscribe(action));
+
+        let sub: Subscription | undefined = obs.subscribe(action);
+        this.subs.push(sub);
+
+        return {
+            unsubscribe: () => {
+                if (sub && this.subs && arraySetRemove(this.subs, sub)) {
+                    sub.unsubscribe();
+                    sub = void 0;
+                }
+            }
+        };
     }
 
     protected get ev() {

+ 1 - 1
src/mol-plugin-state/formats/volume.ts

@@ -176,7 +176,7 @@ export const DscifProvider = DataFormatProvider({
         if (volumes.length > 0) {
             visuals[0] = tree
                 .to(volumes[0])
-                .apply(StateTransforms.Representation.VolumeRepresentation3D, VolumeRepresentation3DHelpers.getDefaultParamsStatic(plugin, 'isosurface', { isoValue: VolumeIsoValue.relative(1.5), alpha: 0.3 }, 'uniform', { value: ColorNames.teal }))
+                .apply(StateTransforms.Representation.VolumeRepresentation3D, VolumeRepresentation3DHelpers.getDefaultParamsStatic(plugin, 'isosurface', { isoValue: VolumeIsoValue.relative(1.5), alpha: 1 }, 'uniform', { value: ColorNames.teal }))
                 .selector;
         }
 

+ 3 - 0
src/mol-plugin-state/manager/animation.ts

@@ -44,6 +44,7 @@ class PluginAnimationManager extends StatefulPluginComponent<PluginAnimationMana
     }
 
     updateParams(newParams: Partial<PluginAnimationManager.State['params']>) {
+        if (this.isEmpty) return;
         this.updateState({ params: { ...this.state.params, ...newParams } });
         const anim = this.map.get(this.state.params.current)!;
         const params = anim.params(this.context) as PD.Params;
@@ -59,6 +60,7 @@ class PluginAnimationManager extends StatefulPluginComponent<PluginAnimationMana
     }
 
     updateCurrentParams(values: any) {
+        if (this.isEmpty) return;
         this._current.paramValues = { ...this._current.paramValues, ...values };
         this.triggerUpdate();
     }
@@ -163,6 +165,7 @@ class PluginAnimationManager extends StatefulPluginComponent<PluginAnimationMana
     }
 
     setSnapshot(snapshot: PluginAnimationManager.Snapshot) {
+        if (this.isEmpty) return;
         this.updateState({ animationState: snapshot.state.animationState });
         this.updateParams(snapshot.state.params);
 

+ 21 - 12
src/mol-plugin-state/manager/structure/measurement.ts

@@ -37,6 +37,12 @@ export interface StructureMeasurementManagerState {
     options: StructureMeasurementOptions
 }
 
+type StructureMeasurementManagerAddOptions = {
+    customText?: string,
+    selectionTags?: string | string[],
+    reprTags?: string | string[]
+}
+
 class StructureMeasurementManager extends StatefulPluginComponent<StructureMeasurementManagerState>  {
     readonly behaviors = {
         state: this.ev.behavior(this.state)
@@ -80,7 +86,7 @@ class StructureMeasurementManager extends StatefulPluginComponent<StructureMeasu
         await PluginCommands.State.Update(this.plugin, { state: this.plugin.state.data, tree: update, options: { doNotLogTiming: true } });
     }
 
-    async addDistance(a: StructureElement.Loci, b: StructureElement.Loci) {
+    async addDistance(a: StructureElement.Loci, b: StructureElement.Loci, options?: StructureMeasurementManagerAddOptions) {
         const cellA = this.plugin.helpers.substructureParent.get(a.structure);
         const cellB = this.plugin.helpers.substructureParent.get(b.structure);
 
@@ -98,17 +104,18 @@ class StructureMeasurementManager extends StatefulPluginComponent<StructureMeasu
                 ],
                 isTransitive: true,
                 label: 'Distance'
-            }, { dependsOn })
+            }, { dependsOn, tags: options?.selectionTags })
             .apply(StateTransforms.Representation.StructureSelectionsDistance3D, {
+                customText: options?.customText || '',
                 unitLabel: this.state.options.distanceUnitLabel,
                 textColor: this.state.options.textColor
-            });
+            }, { tags: options?.reprTags });
 
         const state = this.plugin.state.data;
         await PluginCommands.State.Update(this.plugin, { state, tree: update, options: { doNotLogTiming: true } });
     }
 
-    async addAngle(a: StructureElement.Loci, b: StructureElement.Loci, c: StructureElement.Loci) {
+    async addAngle(a: StructureElement.Loci, b: StructureElement.Loci, c: StructureElement.Loci, options?: StructureMeasurementManagerAddOptions) {
         const cellA = this.plugin.helpers.substructureParent.get(a.structure);
         const cellB = this.plugin.helpers.substructureParent.get(b.structure);
         const cellC = this.plugin.helpers.substructureParent.get(c.structure);
@@ -129,16 +136,17 @@ class StructureMeasurementManager extends StatefulPluginComponent<StructureMeasu
                 ],
                 isTransitive: true,
                 label: 'Angle'
-            }, { dependsOn })
+            }, { dependsOn, tags: options?.selectionTags })
             .apply(StateTransforms.Representation.StructureSelectionsAngle3D, {
+                customText: options?.customText || '',
                 textColor: this.state.options.textColor
-            });
+            }, { tags: options?.reprTags });
 
         const state = this.plugin.state.data;
         await PluginCommands.State.Update(this.plugin, { state, tree: update, options: { doNotLogTiming: true } });
     }
 
-    async addDihedral(a: StructureElement.Loci, b: StructureElement.Loci, c: StructureElement.Loci, d: StructureElement.Loci) {
+    async addDihedral(a: StructureElement.Loci, b: StructureElement.Loci, c: StructureElement.Loci, d: StructureElement.Loci, options?: StructureMeasurementManagerAddOptions) {
         const cellA = this.plugin.helpers.substructureParent.get(a.structure);
         const cellB = this.plugin.helpers.substructureParent.get(b.structure);
         const cellC = this.plugin.helpers.substructureParent.get(c.structure);
@@ -162,16 +170,17 @@ class StructureMeasurementManager extends StatefulPluginComponent<StructureMeasu
                 ],
                 isTransitive: true,
                 label: 'Dihedral'
-            }, { dependsOn })
+            }, { dependsOn, tags: options?.selectionTags })
             .apply(StateTransforms.Representation.StructureSelectionsDihedral3D, {
+                customText: options?.customText || '',
                 textColor: this.state.options.textColor
-            });
+            }, { tags: options?.reprTags });
 
         const state = this.plugin.state.data;
         await PluginCommands.State.Update(this.plugin, { state, tree: update, options: { doNotLogTiming: true } });
     }
 
-    async addLabel(a: StructureElement.Loci) {
+    async addLabel(a: StructureElement.Loci, options?: Omit<StructureMeasurementManagerAddOptions, 'customText'>) {
         const cellA = this.plugin.helpers.substructureParent.get(a.structure);
 
         if (!cellA) return;
@@ -186,10 +195,10 @@ class StructureMeasurementManager extends StatefulPluginComponent<StructureMeasu
                 ],
                 isTransitive: true,
                 label: 'Label'
-            }, { dependsOn })
+            }, { dependsOn, tags: options?.selectionTags })
             .apply(StateTransforms.Representation.StructureSelectionsLabel3D, {
                 textColor: this.state.options.textColor
-            });
+            }, { tags: options?.reprTags });
 
         const state = this.plugin.state.data;
         await PluginCommands.State.Update(this.plugin, { state, tree: update, options: { doNotLogTiming: true } });

+ 2 - 2
src/mol-plugin-ui/controls.tsx

@@ -29,6 +29,7 @@ import { StructureMeasurementsControls } from './structure/measurements';
 import { StructureSelectionActionsControls } from './structure/selection';
 import { StructureSourceControls } from './structure/source';
 import { VolumeStreamingControls, VolumeSourceControls } from './structure/volume';
+import { PluginConfig } from '../mol-plugin/config';
 
 export class TrajectoryViewportControls extends PluginUIComponent<{}, { show: boolean, label: string }> {
     state = { show: false, label: '' }
@@ -219,9 +220,8 @@ export class AnimationViewportControls extends PluginUIComponent<{}, { isEmpty:
     }
 
     render() {
-        // if (!this.state.show) return null;
         const isPlaying = this.plugin.managers.snapshot.state.isPlaying;
-        if (isPlaying || this.state.isEmpty) return null;
+        if (isPlaying || this.state.isEmpty || this.plugin.managers.animation.isEmpty || !this.plugin.config.get(PluginConfig.Viewport.ShowAnimation)) return null;
 
         const isAnimating = this.state.isAnimating;
 

+ 2 - 2
src/mol-plugin-ui/state/tree.tsx

@@ -49,8 +49,8 @@ export class StateTree extends PluginUIComponent<{ state: State }, { showActions
         const ref = this.props.state.tree.root.ref;
         if (this.state.showActions) {
             return <div style={{ margin: '10px', cursor: 'default' }}>
-                <p>Nothing to see here.</p>
-                <p>Structures can be loaded from the <Icon svg={HomeOutlined} /> tab.</p>
+                <p>Nothing to see here yet.</p>
+                <p>Structures and Volumes can be loaded from the <Icon svg={HomeOutlined} /> tab.</p>
             </div>;
         }
         return <StateTreeNode cell={this.props.state.cells.get(ref)!} depth={0} />;

+ 5 - 0
src/mol-plugin/behavior/static/state.ts

@@ -187,4 +187,9 @@ export function Snapshots(ctx: PluginContext) {
     PluginCommands.State.Snapshots.OpenFile.subscribe(ctx, ({ file }) => {
         return ctx.managers.snapshot.open(file);
     });
+
+    PluginCommands.State.Snapshots.OpenUrl.subscribe(ctx, async ({ url, type }) => {
+        const data = await ctx.runTask(ctx.fetch({ url, type: 'binary' }));
+        return ctx.managers.snapshot.open(new File([data], `state.${type}`));
+    });
 }

+ 2 - 1
src/mol-plugin/commands.ts

@@ -37,8 +37,9 @@ export const PluginCommands = {
             Upload: PluginCommand<{ name?: string, description?: string, playOnLoad?: boolean, serverUrl: string, params?: PluginState.SnapshotParams }>(),
             Fetch: PluginCommand<{ url: string }>(),
 
-            DownloadToFile: PluginCommand<{ name?: string, type: 'json' | 'molj' | 'zip' | 'molx', params?: PluginState.SnapshotParams }>(),
+            DownloadToFile: PluginCommand<{ name?: string, type: PluginState.SnapshotType, params?: PluginState.SnapshotParams }>(),
             OpenFile: PluginCommand<{ file: File }>(),
+            OpenUrl: PluginCommand<{ url: string, type: PluginState.SnapshotType }>(),
         }
     },
     Interactivity: {

+ 8 - 1
src/mol-plugin/config.ts

@@ -7,6 +7,8 @@
 
 import { Structure, Model } from '../mol-model/structure';
 import { PluginContext } from './context';
+import { PdbDownloadProvider } from '../mol-plugin-state/actions/structure';
+import { EmdbDownloadProvider } from '../mol-plugin-state/actions/volume';
 
 export class PluginConfigItem<T = any> {
     toString() { return this.key; }
@@ -34,7 +36,12 @@ export const PluginConfig = {
     },
     Viewport: {
         ShowExpand: item('viewer.show-expand-button', true),
-        ShowSelectionMode: item('viewer.show-selection-model-button', true)
+        ShowSelectionMode: item('viewer.show-selection-model-button', true),
+        ShowAnimation: item('viewer.show-animation-button', true),
+    },
+    Download: {
+        DefaultPdbProvider: item<PdbDownloadProvider>('download.default-pdb-provider', 'pdbe'),
+        DefaultEmdbProvider: item<EmdbDownloadProvider>('download.default-emdb-provider', 'pdbe'),
     }
 };
 

+ 2 - 0
src/mol-plugin/state.ts

@@ -166,4 +166,6 @@ namespace PluginState {
         structureFocus?: StructureFocusSnapshot,
         durationInMs?: number
     }
+
+    export type SnapshotType = 'json' | 'molj' | 'zip' | 'molx'
 }

+ 4 - 4
src/mol-repr/shape/loci/angle.ts

@@ -24,6 +24,7 @@ import { transformPrimitive } from '../../../mol-geo/primitive/primitive';
 import { MarkerActions, MarkerAction } from '../../../mol-util/marker-action';
 import { angleLabel } from '../../../mol-theme/label';
 import { Sphere3D } from '../../../mol-math/geometry';
+import { MeasurementRepresentationCommonTextParams } from './common';
 
 export interface AngleData {
     triples: Loci.Bundle<3>[]
@@ -62,9 +63,8 @@ type SectorParams = typeof SectorParams
 
 const TextParams = {
     ...Text.Params,
-    borderWidth: PD.Numeric(0.2, { min: 0, max: 0.5, step: 0.01 }),
-    textColor: PD.Color(ColorNames.black),
-    textSize: PD.Numeric(0.4, { min: 0.1, max: 5, step: 0.1 }),
+    ...MeasurementRepresentationCommonTextParams,
+    borderWidth: PD.Numeric(0.2, { min: 0, max: 0.5, step: 0.01 })
 };
 type TextParams = typeof TextParams
 
@@ -227,7 +227,7 @@ function buildText(data: AngleData, props: AngleProps, text?: Text): Text {
         Vec3.add(tmpVec, tmpState.sphereB.center, tmpVec);
 
         const angle = radToDeg(tmpState.angle).toFixed(2);
-        const label = `${angle}\u00B0`;
+        const label = props.customText || `${angle}\u00B0`;
         const radius = Math.max(2, tmpState.sphereA.radius, tmpState.sphereB.radius, tmpState.sphereC.radius);
         const scale = radius / 2;
         builder.add(label, tmpVec[0], tmpVec[1], tmpVec[2], 0.1, scale, i);

+ 1 - 0
src/mol-repr/shape/loci/common.ts

@@ -9,6 +9,7 @@ import { ParamDefinition as PD } from '../../../mol-util/param-definition';
 import { ColorNames } from '../../../mol-util/color/names';
 
 export const MeasurementRepresentationCommonTextParams = {
+    customText: PD.Text('', { label: 'Text', description: 'Override the label with custom value.' }),
     textColor: PD.Color(ColorNames.black, { isEssential: true }),
     textSize: PD.Numeric(0.5, { min: 0.1, max: 5, step: 0.1 }, { isEssential: true }),
 };

+ 24 - 5
src/mol-repr/shape/loci/dihedral.ts

@@ -76,6 +76,7 @@ type TextParams = typeof TextParams
 const DihedralVisuals = {
     'vectors': (ctx: RepresentationContext, getParams: RepresentationParamsGetter<DihedralData, VectorsParams>) => ShapeRepresentation(getVectorsShape, Lines.Utils, { modifyState: s => ({ ...s, pickable: false }) }),
     'extenders': (ctx: RepresentationContext, getParams: RepresentationParamsGetter<DihedralData, ExtendersParams>) => ShapeRepresentation(getExtendersShape, Lines.Utils, { modifyState: s => ({ ...s, pickable: false }) }),
+    'connector': (ctx: RepresentationContext, getParams: RepresentationParamsGetter<DihedralData, ExtendersParams>) => ShapeRepresentation(getConnectorShape, Lines.Utils, { modifyState: s => ({ ...s, pickable: false }) }),
     'arc': (ctx: RepresentationContext, getParams: RepresentationParamsGetter<DihedralData, ArcParams>) => ShapeRepresentation(getArcShape, Lines.Utils, { modifyState: s => ({ ...s, pickable: false }) }),
     'sector': (ctx: RepresentationContext, getParams: RepresentationParamsGetter<DihedralData, SectorParams>) => ShapeRepresentation(getSectorShape, Mesh.Utils, { modifyProps: p => ({ ...p, alpha: p.sectorOpacity }), modifyState: s => ({ ...s, markerActions: MarkerActions.Highlighting }) }),
     'text': (ctx: RepresentationContext, getParams: RepresentationParamsGetter<DihedralData, TextParams>) => ShapeRepresentation(getTextShape, Text.Utils, { modifyState: s => ({ ...s, markerActions: MarkerAction.None }) }),
@@ -156,7 +157,8 @@ function setDihedralState(quad: Loci.Bundle<4>, state: DihedralState, arcScale:
     Vec3.add(arcPointA, arcCenter, arcDirA);
     Vec3.add(arcPointD, arcCenter, arcDirD);
     state.radius = radius;
-    state.angle = Vec3.angle(arcDirA, arcDirD);
+
+    state.angle = Vec3.dihedralAngle(sphereA.center, sphereB.center, sphereC.center, sphereD.center);
 
     Vec3.matchDirection(tmpVec, arcNormal, Vec3.sub(tmpVec, arcPointA, sphereA.center));
     const angleA = Vec3.angle(dirBA, tmpVec);
@@ -175,11 +177,11 @@ function getCircle(state: DihedralState, segmentLength?: number) {
     const { radius, angle } = state;
     const segments = segmentLength ? arcLength(angle, radius) / segmentLength : 32;
 
-    Mat4.targetTo(tmpMat, state.arcCenter, angle > halfPI ? state.arcPointA : state.arcPointD, state.arcNormal);
+    Mat4.targetTo(tmpMat, state.arcCenter, angle < 0 ? state.arcPointD : state.arcPointA, state.arcNormal);
     Mat4.setTranslation(tmpMat, state.arcCenter);
     Mat4.mul(tmpMat, tmpMat, Mat4.rotY180);
 
-    const circle = Circle({ radius, thetaLength: angle, segments });
+    const circle = Circle({ radius, thetaLength: Math.abs(angle), segments });
     return transformPrimitive(circle, tmpMat);
 }
 
@@ -209,6 +211,23 @@ function getVectorsShape(ctx: RuntimeContext, data: DihedralData, props: Dihedra
 
 //
 
+function buildConnectorLine(data: DihedralData, props: DihedralProps, lines?: Lines): Lines {
+    const builder = LinesBuilder.create(128, 64, lines);
+    for (let i = 0, il = data.quads.length; i < il; ++i) {
+        setDihedralState(data.quads[i], tmpState, props.arcScale);
+        builder.addFixedLengthDashes(tmpState.sphereB.center, tmpState.sphereC.center, props.dashLength, i);
+    }
+    return builder.getLines();
+}
+
+function getConnectorShape(ctx: RuntimeContext, data: DihedralData, props: DihedralProps, shape?: Shape<Lines>) {
+    const lines = buildConnectorLine(data, props, shape && shape.geometry);
+    const name = getDihedralName(data);
+    return Shape.create(name, data, lines, () => props.color, () => props.linesSize, () => '');
+}
+
+//
+
 function buildExtendersLines(data: DihedralData, props: DihedralProps, lines?: Lines): Lines {
     const builder = LinesBuilder.create(128, 64, lines);
     for (let i = 0, il = data.quads.length; i < il; ++i) {
@@ -287,8 +306,8 @@ function buildText(data: DihedralData, props: DihedralProps, text?: Text): Text
         Vec3.setMagnitude(tmpVec, tmpVec, tmpState.radius);
         Vec3.add(tmpVec, tmpState.arcCenter, tmpVec);
 
-        const angle = radToDeg(tmpState.angle).toFixed(2);
-        const label = `${angle}\u00B0`;
+        const angle = Math.abs(radToDeg(tmpState.angle)).toFixed(2);
+        const label =  props.customText || `${angle}\u00B0`;
         const radius = Math.max(2, tmpState.sphereA.radius, tmpState.sphereB.radius, tmpState.sphereC.radius, tmpState.sphereD.radius);
         const scale = radius / 2;
         builder.add(label, tmpVec[0], tmpVec[1], tmpVec[2], 0.1, scale, i);

+ 1 - 1
src/mol-repr/shape/loci/distance.ts

@@ -118,7 +118,7 @@ function buildText(data: DistanceData, props: DistanceProps, text?: Text): Text
     for (let i = 0, il = data.pairs.length; i < il; ++i) {
         setDistanceState(data.pairs[i], tmpState);
         const { center, distance, sphereA, sphereB } = tmpState;
-        const label = `${distance.toFixed(2)} ${props.unitLabel}`;
+        const label = props.customText || `${distance.toFixed(2)} ${props.unitLabel}`;
         const radius = Math.max(2, sphereA.radius, sphereB.radius);
         const scale = radius / 2;
         builder.add(label, center[0], center[1], center[2], 1, scale, i);

+ 2 - 2
src/mol-state/action.ts

@@ -19,7 +19,7 @@ interface StateAction<A extends StateObject = StateObject, T = any, P extends {}
     readonly id: UUID,
     readonly definition: StateAction.Definition<A, T, P>,
     /** create a fresh copy of the params which can be edited in place */
-    createDefaultParams(a: A, globalCtx: unknown): P
+    createDefaultParams(a: A | undefined, globalCtx: unknown): P
 }
 
 namespace StateAction {
@@ -54,7 +54,7 @@ namespace StateAction {
     export interface Definition<A extends StateObject = StateObject, T = any, P extends {} = {}> extends DefinitionBase<A, T, P> {
         readonly from: StateObject.Ctor[],
         readonly display: { readonly name: string, readonly description?: string },
-        params?(a: A, globalCtx: unknown): { [K in keyof P]: PD.Any }
+        params?(a: A | undefined, globalCtx: unknown): { [K in keyof P]: PD.Any }
     }
 
     export function create<A extends StateObject, T, P extends {} = {}>(definition: Definition<A, T, P>): StateAction<A, T, P> {

+ 1 - 1
src/mol-theme/label.ts

@@ -330,7 +330,7 @@ export function angleLabel(triple: Loci.Bundle<3>, options: Partial<LabelOptions
 export function dihedralLabel(quad: Loci.Bundle<4>, options: Partial<LabelOptions & { measureOnly: boolean }> = {}) {
     const o = { ...DefaultLabelOptions, measureOnly: false, ...options };
     const [cA, cB, cC, cD] = quad.loci.map(l => Loci.getCenter(l)!);
-    const dihedral = `${radToDeg(Vec3.dihedralAngle(cA, cB, cC, cD)).toFixed(2)}\u00B0`;
+    const dihedral = `${Math.abs(radToDeg(Vec3.dihedralAngle(cA, cB, cC, cD))).toFixed(2)}\u00B0`;
     if (o.measureOnly) return dihedral;
     const label = bundleLabel(quad, o);
     return o.condensed ? `${dihedral} | ${label}` : `Dihedral ${dihedral}</br>${label}`;

+ 4 - 4
webpack.config.common.js

@@ -43,7 +43,7 @@ const sharedConfig = {
             // __VERSION_TIMESTAMP__: webpack.DefinePlugin.runtimeValue(() => `${new Date().valueOf()}`, true),
             'process.env.DEBUG': JSON.stringify(process.env.DEBUG)
         }),
-        new MiniCssExtractPlugin({ filename: 'app.css' }),
+        new MiniCssExtractPlugin({ filename: 'molstar.css',  }),
         new VersionFile({
             extras: { timestamp: `${new Date().valueOf()}` },
             packageFile: path.resolve(__dirname, 'package.json'),
@@ -73,11 +73,11 @@ function createEntry(src, outFolder, outFilename, isNode) {
     }
 }
 
-function createEntryPoint(name, dir, out) {
+function createEntryPoint(name, dir, out, library) {
     return {
         node: { fs: 'empty' }, // TODO find better solution? Currently used in file-handle.ts
         entry: path.resolve(__dirname, `lib/${dir}/${name}.js`),
-        output: { filename: `${name}.js`, path: path.resolve(__dirname, `build/${out}`) },
+        output: { filename: `${library || name}.js`, path: path.resolve(__dirname, `build/${out}`), library: library || out, libraryTarget: 'umd' },
         ...sharedConfig
     }
 }
@@ -97,7 +97,7 @@ function createNodeEntryPoint(name, dir, out) {
     }
 }
 
-function createApp(name) { return createEntryPoint('index', `apps/${name}`, name) }
+function createApp(name, library) { return createEntryPoint('index', `apps/${name}`, name, library) }
 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) }

+ 1 - 2
webpack.config.production.js

@@ -1,9 +1,8 @@
 const { createApp, createExample } = require('./webpack.config.common.js');
 
-const apps = ['viewer'];
 const examples = ['proteopedia-wrapper', 'basic-wrapper', 'lighting'];
 
 module.exports = [
-    ...apps.map(createApp),
+    createApp('viewer', 'molstar'),
     ...examples.map(createExample)
 ]

+ 1 - 1
webpack.config.viewer.js

@@ -1,5 +1,5 @@
 const common = require('./webpack.config.common.js');
 const createApp = common.createApp;
 module.exports = [
-    createApp('viewer')
+    createApp('viewer', 'molstar')
 ]