Skip to content

Commit

Permalink
StateGallery: docstrings, minor refactor
Browse files Browse the repository at this point in the history
  • Loading branch information
midlik committed Sep 2, 2024
1 parent 00189f2 commit 570773f
Show file tree
Hide file tree
Showing 7 changed files with 101 additions and 38 deletions.
9 changes: 6 additions & 3 deletions src/app/extensions/state-gallery/behavior.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Image | undefined>,
manager: BehaviorSubject<StateGalleryManager | undefined>,
status: BehaviorSubject<LoadingStatus>,
}
export const StateGalleryCustomState = extensionCustomStateGetter<StateGalleryCustomState>(StateGalleryExtensionName);
export const StateGalleryCustomState = ExtensionCustomState.getter<StateGalleryCustomState>(StateGalleryExtensionName);

/** Parameters for StateGallery extension */
export interface StateGalleryParams {
/** Show "3D State Gallery" section in Structure Tools controls */
showControls: boolean,
Expand Down Expand Up @@ -52,7 +55,7 @@ export const StateGallery = PluginBehavior.create<StateGalleryParams>({
unregister() {
this.toggleStructureControls(false);
this.toggleTitleBox(false);
clearExtensionCustomState(this.ctx, StateGalleryExtensionName);
ExtensionCustomState.clear(this.ctx, StateGalleryExtensionName);
}

/** Register/unregister custom structure controls */
Expand Down
4 changes: 4 additions & 0 deletions src/app/extensions/state-gallery/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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<StateGalleryConfigValues> = {
ServerUrl: new PluginConfigItem<string>('pdbe-state-gallery.server-url', StateGalleryConfigDefaults.ServerUrl),
LoadCanvasProps: new PluginConfigItem<boolean>('pdbe-state-gallery.load-canvas-props', StateGalleryConfigDefaults.LoadCanvasProps),
Expand All @@ -25,6 +28,7 @@ export const StateGalleryConfig: PluginConfigUtils.ConfigFor<StateGalleryConfigV
CameraTransitionMs: new PluginConfigItem<number>('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);
}
42 changes: 39 additions & 3 deletions src/app/extensions/state-gallery/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]: {
Expand Down Expand Up @@ -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<string, number>;
/** BehaviorSubjects for current state of the manager */
public readonly events = {
/** Image that has been requested to load most recently. */
requestedImage: new BehaviorSubject<Image | undefined>(undefined),
Expand All @@ -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);
Expand All @@ -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<StateGalleryConfigValues>) {
const fullOptions = { ...getStateGalleryConfig(plugin), ...options };
const data = await getData(plugin, fullOptions.ServerUrl, entryId);
Expand All @@ -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<void> {
if (!this.plugin.canvas3d) throw new Error('plugin.canvas3d is not defined');

Expand All @@ -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<PreemptiveQueueResult<void>> {
if (typeof img === 'string') {
img = this.getImageByFilename(img) ?? { filename: img };
Expand All @@ -169,29 +193,36 @@ 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;
let iNew = (iCurrent !== undefined) ? (iCurrent + shift) : (shift > 0) ? (shift - 1) : shift;
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<string> {
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<string> {
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;
Expand All @@ -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<StateGalleryData | undefined> {
const url = combineUrl(serverUrl, entryId + '.json');
try {
Expand Down Expand Up @@ -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)));
}
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 3 additions & 1 deletion src/app/extensions/state-gallery/titles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Image } from './manager';
type Titles = Pick<Image, 'title' | 'subtitle'>;


/** 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')) {
Expand Down Expand Up @@ -61,11 +61,13 @@ export const ImageTitles = {
};


/** Get contents of `<span ...>...</span>` tags from an HTML string */
function getSpans(text: string | undefined): string[] {
const matches = (text ?? '').matchAll(/<span [^>]*>([^<]*)<\/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];
}
40 changes: 23 additions & 17 deletions src/app/extensions/state-gallery/ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Params>;


Expand All @@ -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() });
}
}
});
Expand All @@ -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 {
Expand All @@ -84,14 +87,15 @@ export class StateGalleryControls extends CollapsableControls<{}, StateGalleryCo
</Button>
}
{this.state.manager &&
<ManagerControls manager={this.state.manager} />
<StateGalleryManagerControls manager={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]);
Expand Down Expand Up @@ -126,7 +130,7 @@ function ManagerControls(props: { manager: StateGalleryManager }) {
{categories.groups.map(cat =>
<ExpandGroup header={cat} key={cat} initiallyExpanded={true} >
{categories.members.get(cat)?.map(img =>
<StateButton key={img.filename} img={img} isSelected={img === selected} status={status} onClick={() => props.manager.load(img)} />
<ImageButton key={img.filename} img={img} isSelected={img === selected} status={status} onClick={() => props.manager.load(img)} />
)}
</ExpandGroup>
)}
Expand All @@ -142,10 +146,11 @@ function ManagerControls(props: { manager: StateGalleryManager }) {
}


function StateButton(props: { img: Image, isSelected: boolean, status: LoadingStatus, onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void }) {
/** Button with image title */
function ImageButton(props: { img: Image, isSelected: boolean, status: LoadingStatus, onClick?: (e: React.MouseEvent<HTMLButtonElement>) => 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 <Button className='msp-action-menu-button pdbemolstar-state-gallery-state-button'
icon={icon} onClick={onClick} title={tooltip}
style={{ fontWeight: isSelected ? 'bold' : undefined }}>
Expand All @@ -154,7 +159,7 @@ function StateButton(props: { img: Image, isSelected: boolean, status: LoadingSt
</Button>;
}

/** 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<Image | undefined>(undefined);
Expand Down Expand Up @@ -186,7 +191,7 @@ export function StateGalleryTitleBox() {
<Button className='msp-btn-icon' title='Previous state' icon={ChevronLeftSvg} onClick={() => manager.loadPrevious()} />
</div>
}
<div className='pdbemolstar-state-gallery-title' title={stateTooltip(image, status)} >
<div className='pdbemolstar-state-gallery-title' title={imageTooltip(image, status)} >
<div className='pdbemolstar-state-gallery-title-icon'>
<Icon svg={status === 'error' ? ErrorSvg : status === 'loading' ? HourglassBottomSvg : EmptyIconSvg} />
</div>
Expand All @@ -204,7 +209,8 @@ export function StateGalleryTitleBox() {
</div >;
}

function stateTooltip(img: Image, status: LoadingStatus): string {
/** Return tooltip text for an image */
function imageTooltip(img: Image, status: LoadingStatus): string {
const tooltip =
(status === 'error' ? '[Failed to load] \n' : status === 'loading' ? '[Loading] \n' : '')
+ (img.title ?? img.filename)
Expand Down
7 changes: 5 additions & 2 deletions src/app/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -610,10 +610,13 @@ export class PreemptiveQueue<X, Y> {
}


/** Functions for working with plugin config items */
export namespace PluginConfigUtils {
export type ConfigFor<T> = { [key in keyof T]: PluginConfigItem<T[key]> };
/** Type of config definition for given type of config values T */
export type ConfigFor<T extends object> = { [key in keyof T]: PluginConfigItem<T[key]> };

export function getConfigValues<T>(plugin: PluginContext | undefined, configItems: { [name in keyof T]: PluginConfigItem<T[name]> }, defaults: T): T {
/** Retrieve config values for items in `configItems` from the current plugin config */
export function getConfigValues<T extends object>(plugin: PluginContext | undefined, configItems: ConfigFor<T>, defaults: T): T {
const values = {} as T;
for (const name in configItems) {
values[name] = plugin?.config.get(configItems[name]) ?? defaults[name];
Expand Down
Loading

0 comments on commit 570773f

Please sign in to comment.