UNPKG

molstar

Version:

A comprehensive macromolecular library.

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