Skip to content

Commit 24df1e3

Browse files
committed
State gallery: Refactored dependency of StateGalleryManager vs React component
1 parent d5b4d98 commit 24df1e3

File tree

3 files changed

+60
-50
lines changed

3 files changed

+60
-50
lines changed

src/app/extensions/state-gallery/behavior.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ export const StateGalleryExtensionFunctions = {
1313
};
1414

1515
export type StateGalleryCustomState = {
16-
x: string,
1716
title: BehaviorSubject<string | undefined>,
1817
}
1918
export const StateGalleryCustomState = extensionCustomStateGetter<StateGalleryCustomState>(StateGalleryExtensionName);
@@ -27,8 +26,6 @@ export const StateGallery = PluginBehavior.create<{ autoAttach: boolean }>({
2726
description: 'Browse pre-computed 3D states for a PDB entry',
2827
},
2928
ctor: class extends PluginBehavior.Handler<{ autoAttach: boolean }> {
30-
getCustomState = extensionCustomStateGetter<StateGalleryCustomState>(StateGalleryExtensionName);
31-
3229
register(): void {
3330
// this.ctx.state.data.actions.add(InitAssemblySymmetry3D);
3431
// this.ctx.customStructureProperties.register(this.provider, this.params.autoAttach);
@@ -42,8 +39,7 @@ export const StateGallery = PluginBehavior.create<{ autoAttach: boolean }>({
4239
// });
4340
// return [refs, 'Symmetries'];
4441
// });
45-
this.getCustomState(this.ctx).x = 'hello';
46-
this.getCustomState(this.ctx).title = new BehaviorSubject<string | undefined>(undefined);
42+
StateGalleryCustomState(this.ctx).title = new BehaviorSubject<string | undefined>(undefined);
4743
this.ctx.customStructureControls.set(StateGalleryExtensionName, StateGalleryControls as any);
4844
// this.ctx.builders.structure.representation.registerPreset(AssemblySymmetryPreset);
4945
}

src/app/extensions/state-gallery/manager.ts

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { PluginContext } from 'molstar/lib/mol-plugin/context';
66
import { isPlainObject } from 'molstar/lib/mol-util/object';
77
import { sleep } from 'molstar/lib/mol-util/sleep';
88
import { BehaviorSubject } from 'rxjs';
9-
import { PreemptiveQueue, PreemptiveQueueResult, combineUrl, createIndex, distinct } from '../../helpers';
9+
import { PreemptiveQueue, PreemptiveQueueResult, combineUrl, createIndex, distinct, nonnegativeModulo } from '../../helpers';
1010
import { StateGalleryConfigValues, getStateGalleryConfig } from './config';
1111

1212

@@ -64,9 +64,9 @@ type ImageCategory = typeof ImageCategory[number]
6464

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

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

107-
const state = this.getImageByFilename(filename);
108110
let snapshot = await this.getSnapshot(filename);
109111
const oldCamera = getCurrentCamera(this.plugin);
110112
const incomingCamera = getCameraFromSnapshot(snapshot); // Camera position from the MOLJ file, which may be incorrectly zoomed if viewport width < height
@@ -115,7 +117,6 @@ export class StateGalleryManager {
115117
camera: (this.options.LoadCameraOrientation && !this.firstLoaded) ? newCamera : oldCamera,
116118
transitionDurationInMs: 0,
117119
},
118-
description: state?.simple_title,
119120
});
120121
await this.plugin.managers.snapshot.setStateSnapshot(JSON.parse(snapshot));
121122
await sleep(this.firstLoaded ? this.options.CameraPreTransitionMs : 0); // it is necessary to sleep even for 0 ms here, to get animation
@@ -127,14 +128,34 @@ export class StateGalleryManager {
127128
this.firstLoaded = true;
128129
}
129130
private readonly loader = new PreemptiveQueue((filename: string) => this._load(filename));
130-
async load(filename: string): Promise<PreemptiveQueueResult<void>> {
131-
this.requestedStateName.next(filename);
132-
this.loadedStateName.next(undefined);
133-
const result = await this.loader.requestRun(filename);
134-
if (result.status === 'completed') {
135-
this.loadedStateName.next(filename);
131+
async load(img: Image | string): Promise<PreemptiveQueueResult<void>> {
132+
if (typeof img === 'string') {
133+
img = this.getImageByFilename(img) ?? { filename: img };
134+
}
135+
this.events.requestedStateName.next(img);
136+
this.events.loadedStateName.next(undefined);
137+
this.events.status.next('loading');
138+
let result;
139+
try {
140+
result = await this.loader.requestRun(img.filename);
141+
return result;
142+
} finally {
143+
if (result?.status === 'completed') {
144+
this.events.loadedStateName.next(img);
145+
this.events.status.next('ready');
146+
}
147+
// if resolves with result.status 'cancelled' or 'skipped', keep current state
148+
if (!result) {
149+
this.events.status.next('error');
150+
}
136151
}
137-
return result;
152+
}
153+
async shift(shift: number) {
154+
const current = this.events.requestedStateName.value;
155+
const iCurrent = (current !== undefined) ? this.filenameIndex.get(current.filename) : undefined;
156+
let iNew = (iCurrent !== undefined) ? (iCurrent + shift) : (shift > 0) ? (shift - 1) : shift;
157+
iNew = nonnegativeModulo(iNew, this.images.length);
158+
return await this.load(this.images[iNew]);
138159
}
139160

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

261-
function modifySnapshot(snapshot: string, options: { removeCanvasProps?: boolean, replaceCamera?: { camera: Camera.Snapshot, transitionDurationInMs: number }, description?: string | null }) {
282+
function modifySnapshot(snapshot: string, options: { removeCanvasProps?: boolean, replaceCamera?: { camera: Camera.Snapshot, transitionDurationInMs: number } }) {
262283
const json = JSON.parse(snapshot) as PluginStateSnapshotManager.StateSnapshot;
263284
for (const entry of json.entries ?? []) {
264285
if (entry.snapshot) {
@@ -273,11 +294,6 @@ function modifySnapshot(snapshot: string, options: { removeCanvasProps?: boolean
273294
transitionDurationInMs: transitionDurationInMs > 0 ? transitionDurationInMs : undefined,
274295
};
275296
}
276-
if (typeof options.description === 'string') {
277-
entry.description = options.description;
278-
} else if (options.description === null) {
279-
delete entry.description;
280-
}
281297
}
282298
}
283299
return JSON.stringify(json);

src/app/extensions/state-gallery/ui.tsx

Lines changed: 22 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { CheckSvg, ErrorSvg } from 'molstar/lib/mol-plugin-ui/controls/icons';
44
import { ParameterControls } from 'molstar/lib/mol-plugin-ui/controls/parameters';
55
import { ParamDefinition as PD } from 'molstar/lib/mol-util/param-definition';
66
import React from 'react';
7-
import { createIndex, groupElements, nonnegativeModulo } from '../../helpers';
7+
import { groupElements } from '../../helpers';
88
import { ChevronLeftSvg, ChevronRightSvg, CollectionsOutlinedSvg, EmptyIconSvg, HourglassBottomSvg } from '../../ui/icons';
99
import { StateGalleryCustomState } from './behavior';
1010
import { Image, StateGalleryManager } from './manager';
@@ -103,6 +103,7 @@ export class StateGalleryControls extends CollapsableControls<{}, StateGalleryCo
103103
}
104104
}
105105

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

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

119119
async function loadState(state: Image) {
120-
setStatus('loading');
121-
try {
122-
const result = await props.manager.load(state.filename);
123-
if (result.status === 'completed') {
124-
setStatus('ready');
125-
}
126-
StateGalleryCustomState(props.manager.plugin).title?.next(state.simple_title ?? state.filename);
127-
} catch {
128-
setStatus('error');
129-
}
120+
await props.manager.load(state);
130121
}
131-
React.useEffect(() => { loadState(selected); }, [selected]);
122+
123+
React.useEffect(() => {
124+
if (images.length > 0) {
125+
loadState(images[0]);
126+
}
127+
const subs = [
128+
props.manager.events.status.subscribe(status => setStatus(status)),
129+
props.manager.events.requestedStateName.subscribe(state => setSelected(state)),
130+
];
131+
return () => subs.forEach(sub => sub.unsubscribe());
132+
}, [props.manager]);
132133

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

136-
const shift = (x: number) => setSelected(old => {
137-
const oldIndex = imageIndex.get(old) ?? 0;
138-
const newIndex = nonnegativeModulo(oldIndex + x, nImages);
139-
return images[newIndex];
140-
});
141-
const selectPrevious = () => shift(-1);
142-
const selectNext = () => shift(1);
137+
const selectPrevious = () => props.manager.shift(-1);
138+
const selectNext = () => props.manager.shift(1);
143139
const handleKeyDown = (e: React.KeyboardEvent) => {
144140
if (e.code === 'ArrowLeft') selectPrevious();
145141
if (e.code === 'ArrowRight') selectNext();
@@ -150,17 +146,17 @@ function ManagerControls(props: { manager: StateGalleryManager }) {
150146
{categories.groups.map(cat =>
151147
<ExpandGroup header={cat} key={cat} initiallyExpanded={true} >
152148
{categories.members.get(cat)?.map(img =>
153-
<StateButton key={img.filename} img={img} isSelected={img === selected} status={status} onClick={() => setSelected(img)} />
149+
<StateButton key={img.filename} img={img} isSelected={img === selected} status={status} onClick={() => loadState(img)} />
154150
)}
155151
</ExpandGroup>
156152
)}
157153
</ExpandGroup>
158154
<ExpandGroup header='Description' initiallyExpanded={true} key='description'>
159155
<div className='pdbemolstar-state-gallery-legend' style={{ marginBlock: 6 }}>
160156
<div style={{ fontWeight: 'bold', marginBottom: 8 }}>
161-
{selected.alt}
157+
{selected?.alt}
162158
</div>
163-
<div dangerouslySetInnerHTML={{ __html: selected.description }} />
159+
<div dangerouslySetInnerHTML={{ __html: selected?.description ?? '' }} />
164160
</div>
165161
</ExpandGroup>
166162
<div className='msp-flex-row' >
@@ -170,6 +166,7 @@ function ManagerControls(props: { manager: StateGalleryManager }) {
170166
</div>;
171167
}
172168

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

184+
187185
export function StateGalleryTitleBox() {
188186
const plugin = React.useContext(PluginReactContext);
189187
const [title, setTitle] = React.useState<string | undefined>(undefined);

0 commit comments

Comments
 (0)