From 570773fbf057d35262169308aa45797256154dbb Mon Sep 17 00:00:00 2001 From: Adam Midlik Date: Mon, 2 Sep 2024 13:40:18 +0100 Subject: [PATCH] StateGallery: docstrings, minor refactor --- src/app/extensions/state-gallery/behavior.ts | 9 +++-- src/app/extensions/state-gallery/config.ts | 4 ++ src/app/extensions/state-gallery/manager.ts | 42 ++++++++++++++++++-- src/app/extensions/state-gallery/titles.ts | 4 +- src/app/extensions/state-gallery/ui.tsx | 40 +++++++++++-------- src/app/helpers.ts | 7 +++- src/app/plugin-custom-state.ts | 33 +++++++++------ 7 files changed, 101 insertions(+), 38 deletions(-) diff --git a/src/app/extensions/state-gallery/behavior.ts b/src/app/extensions/state-gallery/behavior.ts index 9fce71e..962634d 100644 --- a/src/app/extensions/state-gallery/behavior.ts +++ b/src/app/extensions/state-gallery/behavior.ts @@ -2,20 +2,23 @@ import { PluginBehavior } from 'molstar/lib/mol-plugin/behavior'; import { shallowEqual } from 'molstar/lib/mol-util'; import { ParamDefinition as PD } from 'molstar/lib/mol-util/param-definition'; import { BehaviorSubject } from 'rxjs'; -import { PluginCustomControls, clearExtensionCustomState, extensionCustomStateGetter } from '../../plugin-custom-state'; +import { ExtensionCustomState, PluginCustomControls } from '../../plugin-custom-state'; import { Image, LoadingStatus, StateGalleryManager } from './manager'; import { StateGalleryControls, StateGalleryTitleBox } from './ui'; +/** Name used when registering extension, custom controls, etc. */ export const StateGalleryExtensionName = 'pdbe-state-gallery'; +/** Plugin-bound state for StateGallery extension */ export interface StateGalleryCustomState { requestedImage: BehaviorSubject, manager: BehaviorSubject, status: BehaviorSubject, } -export const StateGalleryCustomState = extensionCustomStateGetter(StateGalleryExtensionName); +export const StateGalleryCustomState = ExtensionCustomState.getter(StateGalleryExtensionName); +/** Parameters for StateGallery extension */ export interface StateGalleryParams { /** Show "3D State Gallery" section in Structure Tools controls */ showControls: boolean, @@ -52,7 +55,7 @@ export const StateGallery = PluginBehavior.create({ unregister() { this.toggleStructureControls(false); this.toggleTitleBox(false); - clearExtensionCustomState(this.ctx, StateGalleryExtensionName); + ExtensionCustomState.clear(this.ctx, StateGalleryExtensionName); } /** Register/unregister custom structure controls */ diff --git a/src/app/extensions/state-gallery/config.ts b/src/app/extensions/state-gallery/config.ts index f87c35b..923bf5b 100644 --- a/src/app/extensions/state-gallery/config.ts +++ b/src/app/extensions/state-gallery/config.ts @@ -3,6 +3,7 @@ import { PluginContext } from 'molstar/lib/mol-plugin/context'; import { PluginConfigUtils } from '../../helpers'; +/** Default values of plugin config items for the `StateGallery` extension */ export const StateGalleryConfigDefaults = { /** Base URL of the state API (list of states will be downloaded from `{ServerUrl}/{entryId}.json`, states from `{ServerUrl}/{stateName}.molj`) */ ServerUrl: 'https://www.ebi.ac.uk/pdbe/static/entry', @@ -15,8 +16,10 @@ export const StateGalleryConfigDefaults = { /** Duration of the camera transition in miliseconds */ CameraTransitionMs: 400, }; +/** Values of plugin config items for the `StateGallery` extension */ export type StateGalleryConfigValues = typeof StateGalleryConfigDefaults; +/** Definition of plugin config items for the `StateGallery` extension */ export const StateGalleryConfig: PluginConfigUtils.ConfigFor = { ServerUrl: new PluginConfigItem('pdbe-state-gallery.server-url', StateGalleryConfigDefaults.ServerUrl), LoadCanvasProps: new PluginConfigItem('pdbe-state-gallery.load-canvas-props', StateGalleryConfigDefaults.LoadCanvasProps), @@ -25,6 +28,7 @@ export const StateGalleryConfig: PluginConfigUtils.ConfigFor('pdbe-state-gallery.camera-transition-ms', StateGalleryConfigDefaults.CameraTransitionMs), }; +/** Retrieve config values the `StateGallery` extension from the current plugin config */ export function getStateGalleryConfig(plugin: PluginContext): StateGalleryConfigValues { return PluginConfigUtils.getConfigValues(plugin, StateGalleryConfig, StateGalleryConfigDefaults); } diff --git a/src/app/extensions/state-gallery/manager.ts b/src/app/extensions/state-gallery/manager.ts index 11c254d..e6aa767 100644 --- a/src/app/extensions/state-gallery/manager.ts +++ b/src/app/extensions/state-gallery/manager.ts @@ -12,6 +12,7 @@ import { StateGalleryConfigValues, getStateGalleryConfig } from './config'; import { ImageTitles } from './titles'; +/** Shape of data coming from `https://www.ebi.ac.uk/pdbe/static/entry/{entryId}.json`[entryId] */ export interface StateGalleryData { entity?: { [entityId: string]: { @@ -61,27 +62,42 @@ export interface StateGalleryData { image_suffix?: string[], last_modification?: string, } -const ImageCategory = ['Entry', 'Assemblies', 'Entities', 'Ligands', 'Modified residues', 'Domains', 'Miscellaneous'] as const; -type ImageCategory = typeof ImageCategory[number]; +/** Categories of images/states */ +export const ImageCategory = ['Entry', 'Assemblies', 'Entities', 'Ligands', 'Modified residues', 'Domains', 'Miscellaneous'] as const; +export type ImageCategory = typeof ImageCategory[number]; + +/** Information about one image (3D state) */ export interface Image { + /** Image filename without extension (.molj, _image-800x800.png, .caption.json...), used to construct URL */ filename: string, + /** Short description of the image */ alt?: string, + /** Long description of the image, with HTML markup */ description?: string, + /** Long description of the image, plaintext */ clean_description?: string, + /** Assignment to a category (does not come from the API) */ category?: ImageCategory, + /** Short title for display in the UI (does not come from the API) */ title?: string, + /** Additional information (e.g. entity name) for display in the UI as subtitle (does not come from the API) */ subtitle?: string, } +/** Current status of a StateGalleryManager ('ready' = last requested image loaded successfully or no image requested yet, 'loading' = last requested image not resolved yet, 'error' = last requested image failed to load) */ export type LoadingStatus = 'ready' | 'loading' | 'error'; +/** Provides functionality to get list of images (3D states) for an entry, load individual images, keeps track of the currently loaded image. + * Use async `StateGalleryManager.create()` to create an instance. */ export class StateGalleryManager { + /** List of images (3D states) for entry `this.entryId` */ public readonly images: Image[]; - /** Maps filename to its index within `this.images` */ + /** Maps image filename to its index within `this.images` */ private readonly filenameIndex: Map; + /** BehaviorSubjects for current state of the manager */ public readonly events = { /** Image that has been requested to load most recently. */ requestedImage: new BehaviorSubject(undefined), @@ -95,8 +111,11 @@ export class StateGalleryManager { private constructor( public readonly plugin: PluginContext, + /** Entry identifier, i.e. '1cbs' */ public readonly entryId: string, + /** Data retrieved from API */ public readonly data: StateGalleryData | undefined, + /** Config values */ public readonly options: StateGalleryConfigValues, ) { const allImages = listImages(data, true); @@ -114,6 +133,8 @@ export class StateGalleryManager { }); } + /** Create an instance of `StateGalleryManager` and retrieve list of images from API. + * Options that are not provided will use values from plugin config. */ static async create(plugin: PluginContext, entryId: string, options?: Partial) { const fullOptions = { ...getStateGalleryConfig(plugin), ...options }; const data = await getData(plugin, fullOptions.ServerUrl, entryId); @@ -123,6 +144,7 @@ export class StateGalleryManager { return new this(plugin, entryId, data, fullOptions); } + /** Load an image (3D state). Do not call directly; use `load` instead, which handles concurrent requests. */ private async _load(filename: string): Promise { if (!this.plugin.canvas3d) throw new Error('plugin.canvas3d is not defined'); @@ -147,6 +169,8 @@ export class StateGalleryManager { this.firstLoaded = true; } private readonly loader = new PreemptiveQueue((filename: string) => this._load(filename)); + + /** Request to load an image (3D state). When there are multiple concurrent requests, some requests may be skipped (will resolve to `{ status: 'cancelled' }` or `{ status: 'skipped' }`) as only the last request is really important. */ async load(img: Image | string): Promise> { if (typeof img === 'string') { img = this.getImageByFilename(img) ?? { filename: img }; @@ -169,6 +193,7 @@ export class StateGalleryManager { } } } + /** Move to next/previous image in the list. */ private async shift(shift: number) { const current = this.events.requestedImage.value; const iCurrent = (current !== undefined) ? this.filenameIndex.get(current.filename) : undefined; @@ -176,22 +201,28 @@ export class StateGalleryManager { iNew = nonnegativeModulo(iNew, this.images.length); return await this.load(this.images[iNew]); } + /** Request to load the previous image in the list */ async loadPrevious() { return await this.shift(-1); } + /** Request to load the next image in the list */ async loadNext() { return await this.shift(1); } + /** Cache for MOLJ states from API */ private readonly cache: { [filename: string]: string } = {}; + /** Fetch a MOLJ state from API */ private async fetchSnapshot(filename: string): Promise { const url = combineUrl(this.options.ServerUrl, `${filename}.molj`); const data = await this.plugin.runTask(this.plugin.fetch({ url, type: 'string' })); return data; } + /** Get MOLJ state for the image (get from cache or fetch from API) */ async getSnapshot(filename: string): Promise { return this.cache[filename] ??= await this.fetchSnapshot(filename); } + /** Get full image information based on filename. Return `undefined` if image with given filename is not in the list. */ private getImageByFilename(filename: string): Image | undefined { const index = this.filenameIndex.get(filename); if (index === undefined) return undefined; @@ -200,6 +231,7 @@ export class StateGalleryManager { } +/** Get the list of images, captions etc. for an entry from API */ async function getData(plugin: PluginContext, serverUrl: string, entryId: string): Promise { const url = combineUrl(serverUrl, entryId + '.json'); try { @@ -290,6 +322,7 @@ function pushImages(out: Image[], data: any): Image[] { return out; } +/** Return a filtered list of images, removing all images with filename ending in one of `suffixes` */ function removeWithSuffixes(images: Image[], suffixes: string[]): Image[] { return images.filter(img => !suffixes.some(suffix => img.filename.endsWith(suffix))); } @@ -319,12 +352,15 @@ function getCameraFromSnapshot(snapshot: string): Camera.Snapshot | undefined { return json?.entries?.[0]?.snapshot?.camera?.current; } +/** Recalculate camera distance from target in `snapshot` based on `snapshot.radius`, + * keeping target, direction, and up from snapshot but using camera mode and FOV from `camera`. */ function refocusCameraSnapshot(camera: Camera, snapshot: Camera.Snapshot | undefined) { if (snapshot === undefined) return undefined; const dir = Vec3.sub(Vec3(), snapshot.target, snapshot.position); return camera.getInvariantFocus(snapshot.target, snapshot.radius, snapshot.up, dir); } +/** Get current camera positioning */ function getCurrentCamera(plugin: PluginContext): Camera.Snapshot { if (!plugin.canvas3d) return Camera.createDefaultSnapshot(); plugin.canvas3d.commit(); diff --git a/src/app/extensions/state-gallery/titles.ts b/src/app/extensions/state-gallery/titles.ts index cd8c893..6865770 100644 --- a/src/app/extensions/state-gallery/titles.ts +++ b/src/app/extensions/state-gallery/titles.ts @@ -4,7 +4,7 @@ import { Image } from './manager'; type Titles = Pick; -/** Functions for creating informative image (state) titles to show in UI */ +/** Functions for creating informative image (3D state) titles for display in UI */ export const ImageTitles = { entry(img: Image): Titles { if (img.filename.includes('_chemically_distinct_molecules')) { @@ -61,11 +61,13 @@ export const ImageTitles = { }; +/** Get contents of `...` tags from an HTML string */ function getSpans(text: string | undefined): string[] { const matches = (text ?? '').matchAll(/]*>([^<]*)<\/span>/g); return Array.from(matches).map(match => match[1]); } +/** Get content of parenthesis (`(...)`) from a string */ function getParenthesis(text: string | undefined): string | undefined { return text?.match(/\((.*)\)/)?.[1]; } diff --git a/src/app/extensions/state-gallery/ui.tsx b/src/app/extensions/state-gallery/ui.tsx index c6c9186..e619681 100644 --- a/src/app/extensions/state-gallery/ui.tsx +++ b/src/app/extensions/state-gallery/ui.tsx @@ -10,16 +10,22 @@ import { StateGalleryCustomState } from './behavior'; import { Image, LoadingStatus, StateGalleryManager } from './manager'; +/** React state for `StateGalleryControls` */ interface StateGalleryControlsState { + /** Content of "Entry ID" text field */ entryId: string, manager: StateGalleryManager | undefined, + /** `true` when initializing manager (fetching list of images) */ isLoading: boolean, + /** Mirrors `this.plugin.behaviors.state.isBusy` (`true` when loading a specific image) */ isBusy: boolean, } +/** Parameter definition for ParameterControls part of "3D State Gallery" section */ const Params = { - entryId: PD.Text(), + entryId: PD.Text(undefined, { label: 'Entry ID' }), }; +/** Parameter values for ParameterControls part of "3D State Gallery" section */ type Values = PD.ValuesFor; @@ -46,7 +52,7 @@ export class StateGalleryControls extends CollapsableControls<{}, StateGalleryCo if (this.state.entryId === '' && sel.structures.length > 0) { const id = sel.structures[0].cell.obj?.data.model.entryId; if (id) { - this.setEntryId(id.toLowerCase()); + this.setState({ entryId: id.toLowerCase() }); } } }); @@ -58,20 +64,17 @@ export class StateGalleryControls extends CollapsableControls<{}, StateGalleryCo entryId: this.state.entryId, }; } - private setEntryId = (entryId: string) => { - this.setState(old => ({ - entryId, - })); + private onChangeValues = (values: Values) => { + this.setState({ entryId: values.entryId }); }; + + /** Load entry given by `this.state.entryId` */ private load = async () => { if (this.loadDisabled()) return; this.setState({ isLoading: true, description: undefined }); const manager = await StateGalleryManager.create(this.plugin, this.state.entryId); this.setState({ manager, isLoading: false, description: this.state.entryId.toUpperCase() }); }; - private onChangeValues = (values: Values) => { - this.setEntryId(values.entryId); - }; private loadDisabled = () => !this.state.entryId || this.state.entryId === this.state.manager?.entryId || this.state.isBusy || this.state.isLoading; protected renderControls(): React.JSX.Element | null { @@ -84,14 +87,15 @@ export class StateGalleryControls extends CollapsableControls<{}, StateGalleryCo } {this.state.manager && - + } ; } } -function ManagerControls(props: { manager: StateGalleryManager }) { +/** Part of "3D State Gallery" section related to a specific entry */ +export function StateGalleryManagerControls(props: { manager: StateGalleryManager }) { const images = props.manager.images; const nImages = images.length; const categories = React.useMemo(() => groupElements(images, img => img.category ?? 'Miscellaneous'), [images]); @@ -126,7 +130,7 @@ function ManagerControls(props: { manager: StateGalleryManager }) { {categories.groups.map(cat => {categories.members.get(cat)?.map(img => - props.manager.load(img)} /> + props.manager.load(img)} /> )} )} @@ -142,10 +146,11 @@ function ManagerControls(props: { manager: StateGalleryManager }) { } -function StateButton(props: { img: Image, isSelected: boolean, status: LoadingStatus, onClick?: (e: React.MouseEvent) => void }) { +/** Button with image title */ +function ImageButton(props: { img: Image, isSelected: boolean, status: LoadingStatus, onClick?: (e: React.MouseEvent) => void }) { const { img, isSelected, status, onClick } = props; const icon = !isSelected ? EmptyIconSvg : (status === 'loading') ? HourglassBottomSvg : (status === 'error') ? ErrorSvg : CheckSvg; - const tooltip = stateTooltip(img, isSelected ? status : 'ready'); + const tooltip = imageTooltip(img, isSelected ? status : 'ready'); return ; } -/** Box in viewport with state title and arrows to move between states */ +/** Box in viewport with image title and arrows to move between images (3D states) */ export function StateGalleryTitleBox() { const plugin = React.useContext(PluginReactContext); const [image, setImage] = React.useState(undefined); @@ -186,7 +191,7 @@ export function StateGalleryTitleBox() {