Skip to content

Commit

Permalink
State gallery: Refactored dependency of StateGalleryManager vs React …
Browse files Browse the repository at this point in the history
…component
  • Loading branch information
midlik committed Aug 6, 2024
1 parent d5b4d98 commit 24df1e3
Show file tree
Hide file tree
Showing 3 changed files with 60 additions and 50 deletions.
6 changes: 1 addition & 5 deletions src/app/extensions/state-gallery/behavior.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ export const StateGalleryExtensionFunctions = {
};

export type StateGalleryCustomState = {
x: string,
title: BehaviorSubject<string | undefined>,
}
export const StateGalleryCustomState = extensionCustomStateGetter<StateGalleryCustomState>(StateGalleryExtensionName);
Expand All @@ -27,8 +26,6 @@ export const StateGallery = PluginBehavior.create<{ autoAttach: boolean }>({
description: 'Browse pre-computed 3D states for a PDB entry',
},
ctor: class extends PluginBehavior.Handler<{ autoAttach: boolean }> {
getCustomState = extensionCustomStateGetter<StateGalleryCustomState>(StateGalleryExtensionName);

register(): void {
// this.ctx.state.data.actions.add(InitAssemblySymmetry3D);
// this.ctx.customStructureProperties.register(this.provider, this.params.autoAttach);
Expand All @@ -42,8 +39,7 @@ export const StateGallery = PluginBehavior.create<{ autoAttach: boolean }>({
// });
// return [refs, 'Symmetries'];
// });
this.getCustomState(this.ctx).x = 'hello';
this.getCustomState(this.ctx).title = new BehaviorSubject<string | undefined>(undefined);
StateGalleryCustomState(this.ctx).title = new BehaviorSubject<string | undefined>(undefined);
this.ctx.customStructureControls.set(StateGalleryExtensionName, StateGalleryControls as any);
// this.ctx.builders.structure.representation.registerPreset(AssemblySymmetryPreset);
}
Expand Down
58 changes: 37 additions & 21 deletions src/app/extensions/state-gallery/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { PluginContext } from 'molstar/lib/mol-plugin/context';
import { isPlainObject } from 'molstar/lib/mol-util/object';
import { sleep } from 'molstar/lib/mol-util/sleep';
import { BehaviorSubject } from 'rxjs';
import { PreemptiveQueue, PreemptiveQueueResult, combineUrl, createIndex, distinct } from '../../helpers';
import { PreemptiveQueue, PreemptiveQueueResult, combineUrl, createIndex, distinct, nonnegativeModulo } from '../../helpers';
import { StateGalleryConfigValues, getStateGalleryConfig } from './config';


Expand Down Expand Up @@ -64,9 +64,9 @@ type ImageCategory = typeof ImageCategory[number]

export interface Image {
filename: string,
alt: string,
description: string,
clean_description: string,
alt?: string,
description?: string,
clean_description?: string,
category?: ImageCategory,
simple_title?: string,
}
Expand All @@ -76,8 +76,11 @@ export class StateGalleryManager {
public readonly images: Image[]; // TODO Rename to states, add docstring
/** Maps filename to its index within `this.images` */
private readonly filenameIndex: Map<string, number>;
public readonly requestedStateName = new BehaviorSubject<string | undefined>(undefined); // TODO remove if not needed
public readonly loadedStateName = new BehaviorSubject<string | undefined>(undefined); // TODO remove if not needed
public readonly events = {
requestedStateName: new BehaviorSubject<Image | undefined>(undefined), // TODO remove if not needed
loadedStateName: new BehaviorSubject<Image | undefined>(undefined), // TODO remove if not needed
status: new BehaviorSubject<'ready' | 'loading' | 'error'>('ready'), // TODO remove if not needed
};
/** True if at least one state has been loaded (this is to skip animation on the first load) */
private firstLoaded = false;

Expand All @@ -104,7 +107,6 @@ export class StateGalleryManager {
private async _load(filename: string): Promise<void> {
if (!this.plugin.canvas3d) throw new Error('plugin.canvas3d is not defined');

const state = this.getImageByFilename(filename);
let snapshot = await this.getSnapshot(filename);
const oldCamera = getCurrentCamera(this.plugin);
const incomingCamera = getCameraFromSnapshot(snapshot); // Camera position from the MOLJ file, which may be incorrectly zoomed if viewport width < height
Expand All @@ -115,7 +117,6 @@ export class StateGalleryManager {
camera: (this.options.LoadCameraOrientation && !this.firstLoaded) ? newCamera : oldCamera,
transitionDurationInMs: 0,
},
description: state?.simple_title,
});
await this.plugin.managers.snapshot.setStateSnapshot(JSON.parse(snapshot));
await sleep(this.firstLoaded ? this.options.CameraPreTransitionMs : 0); // it is necessary to sleep even for 0 ms here, to get animation
Expand All @@ -127,14 +128,34 @@ export class StateGalleryManager {
this.firstLoaded = true;
}
private readonly loader = new PreemptiveQueue((filename: string) => this._load(filename));
async load(filename: string): Promise<PreemptiveQueueResult<void>> {
this.requestedStateName.next(filename);
this.loadedStateName.next(undefined);
const result = await this.loader.requestRun(filename);
if (result.status === 'completed') {
this.loadedStateName.next(filename);
async load(img: Image | string): Promise<PreemptiveQueueResult<void>> {
if (typeof img === 'string') {
img = this.getImageByFilename(img) ?? { filename: img };
}
this.events.requestedStateName.next(img);
this.events.loadedStateName.next(undefined);
this.events.status.next('loading');
let result;
try {
result = await this.loader.requestRun(img.filename);
return result;
} finally {
if (result?.status === 'completed') {
this.events.loadedStateName.next(img);
this.events.status.next('ready');
}
// if resolves with result.status 'cancelled' or 'skipped', keep current state
if (!result) {
this.events.status.next('error');
}
}
return result;
}
async shift(shift: number) {
const current = this.events.requestedStateName.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]);
}

private readonly cache: { [filename: string]: string } = {};
Expand Down Expand Up @@ -258,7 +279,7 @@ function removeWithSuffixes(images: Image[], suffixes: string[]): Image[] {
return images.filter(img => !suffixes.some(suffix => img.filename.endsWith(suffix)));
}

function modifySnapshot(snapshot: string, options: { removeCanvasProps?: boolean, replaceCamera?: { camera: Camera.Snapshot, transitionDurationInMs: number }, description?: string | null }) {
function modifySnapshot(snapshot: string, options: { removeCanvasProps?: boolean, replaceCamera?: { camera: Camera.Snapshot, transitionDurationInMs: number } }) {
const json = JSON.parse(snapshot) as PluginStateSnapshotManager.StateSnapshot;
for (const entry of json.entries ?? []) {
if (entry.snapshot) {
Expand All @@ -273,11 +294,6 @@ function modifySnapshot(snapshot: string, options: { removeCanvasProps?: boolean
transitionDurationInMs: transitionDurationInMs > 0 ? transitionDurationInMs : undefined,
};
}
if (typeof options.description === 'string') {
entry.description = options.description;
} else if (options.description === null) {
delete entry.description;
}
}
}
return JSON.stringify(json);
Expand Down
46 changes: 22 additions & 24 deletions src/app/extensions/state-gallery/ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { CheckSvg, ErrorSvg } from 'molstar/lib/mol-plugin-ui/controls/icons';
import { ParameterControls } from 'molstar/lib/mol-plugin-ui/controls/parameters';
import { ParamDefinition as PD } from 'molstar/lib/mol-util/param-definition';
import React from 'react';
import { createIndex, groupElements, nonnegativeModulo } from '../../helpers';
import { groupElements } from '../../helpers';
import { ChevronLeftSvg, ChevronRightSvg, CollectionsOutlinedSvg, EmptyIconSvg, HourglassBottomSvg } from '../../ui/icons';
import { StateGalleryCustomState } from './behavior';
import { Image, StateGalleryManager } from './manager';
Expand Down Expand Up @@ -103,6 +103,7 @@ export class StateGalleryControls extends CollapsableControls<{}, StateGalleryCo
}
}


function ManagerControls(props: { manager: StateGalleryManager }) {
const images = props.manager.images;
const nImages = images.length;
Expand All @@ -111,35 +112,30 @@ function ManagerControls(props: { manager: StateGalleryManager }) {
return <div style={{ margin: 8 }}>No data available for {props.manager.entryId}.</div>;
}

const imageIndex = React.useMemo(() => createIndex(images), [images]);
const categories = React.useMemo(() => groupElements(images, img => img.category ?? 'Miscellaneous'), [images]);
const [selected, setSelected] = React.useState<Image>(images[0]);
const [selected, setSelected] = React.useState<Image | undefined>(undefined);
const [status, setStatus] = React.useState<'ready' | 'loading' | 'error'>('ready');

async function loadState(state: Image) {
setStatus('loading');
try {
const result = await props.manager.load(state.filename);
if (result.status === 'completed') {
setStatus('ready');
}
StateGalleryCustomState(props.manager.plugin).title?.next(state.simple_title ?? state.filename);
} catch {
setStatus('error');
}
await props.manager.load(state);
}
React.useEffect(() => { loadState(selected); }, [selected]);

React.useEffect(() => {
if (images.length > 0) {
loadState(images[0]);
}
const subs = [
props.manager.events.status.subscribe(status => setStatus(status)),
props.manager.events.requestedStateName.subscribe(state => setSelected(state)),
];
return () => subs.forEach(sub => sub.unsubscribe());
}, [props.manager]);

const keyDownTargetRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => keyDownTargetRef.current?.focus(), []);

const shift = (x: number) => setSelected(old => {
const oldIndex = imageIndex.get(old) ?? 0;
const newIndex = nonnegativeModulo(oldIndex + x, nImages);
return images[newIndex];
});
const selectPrevious = () => shift(-1);
const selectNext = () => shift(1);
const selectPrevious = () => props.manager.shift(-1);
const selectNext = () => props.manager.shift(1);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.code === 'ArrowLeft') selectPrevious();
if (e.code === 'ArrowRight') selectNext();
Expand All @@ -150,17 +146,17 @@ 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={() => setSelected(img)} />
<StateButton key={img.filename} img={img} isSelected={img === selected} status={status} onClick={() => loadState(img)} />
)}
</ExpandGroup>
)}
</ExpandGroup>
<ExpandGroup header='Description' initiallyExpanded={true} key='description'>
<div className='pdbemolstar-state-gallery-legend' style={{ marginBlock: 6 }}>
<div style={{ fontWeight: 'bold', marginBottom: 8 }}>
{selected.alt}
{selected?.alt}
</div>
<div dangerouslySetInnerHTML={{ __html: selected.description }} />
<div dangerouslySetInnerHTML={{ __html: selected?.description ?? '' }} />
</div>
</ExpandGroup>
<div className='msp-flex-row' >
Expand All @@ -170,6 +166,7 @@ function ManagerControls(props: { manager: StateGalleryManager }) {
</div>;
}


function StateButton(props: { img: Image, isSelected: boolean, status: 'ready' | 'loading' | 'error', onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void }) {
const { img, isSelected, status, onClick } = props;
const icon = !isSelected ? EmptyIconSvg : (status === 'loading') ? HourglassBottomSvg : (status === 'error') ? ErrorSvg : CheckSvg;
Expand All @@ -184,6 +181,7 @@ function StateButton(props: { img: Image, isSelected: boolean, status: 'ready' |
</Button>;
}


export function StateGalleryTitleBox() {
const plugin = React.useContext(PluginReactContext);
const [title, setTitle] = React.useState<string | undefined>(undefined);
Expand Down

0 comments on commit 24df1e3

Please sign in to comment.