Skip to content

Commit

Permalink
Add smiles columns from tdp_core
Browse files Browse the repository at this point in the history
  • Loading branch information
puehringer committed May 17, 2023
1 parent 002bc82 commit 6914039
Show file tree
Hide file tree
Showing 10 changed files with 520 additions and 8 deletions.
24 changes: 19 additions & 5 deletions src/demo/MainApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { Vis, ESupportedPlotlyVis, ENumericalColorScaleType, EScatterSelectSetti
import { fetchIrisData } from '../vis/stories/Iris.stories';
import { iris } from '../vis/stories/irisData';
import { useVisynAppContext, VisynApp, VisynHeader } from '../app';
import { VisynRanking } from '../ranking';
import { IBuiltVisynRanking } from '../ranking/EagerVisynRanking';
import { MyNumberScore, MyStringScore } from './scoresUtils';
import { VisynRanking, autosizeWithSMILESColumn } from '../ranking';
import { IBuiltVisynRanking, defaultBuilder } from '../ranking/EagerVisynRanking';
import { MyNumberScore, MySMILESScore, MyStringScore } from './scoresUtils';

export function MainApp() {
const { user } = useVisynAppContext();
Expand Down Expand Up @@ -63,21 +63,35 @@ export function MainApp() {
// eslint-disable-next-line no-promise-executor-return
await new Promise((resolve) => setTimeout(resolve, 1000));
createScoreColumnFunc.current(({ data }) => {
return value === 'number' ? MyNumberScore(value) : MyStringScore(value);
if (value === 'number') {
return MyNumberScore(value);
}
if (value === 'category') {
return MyStringScore(value);
}
if (value === 'smiles') {
return MySMILESScore(value);
}
throw new Error('Unknown score type');
});
setLoading(false);
}}
rightSection={loading ? <Loader /> : null}
data={[
{ value: 'number', label: 'Number' },
{ value: 'category', label: 'Category' },
{ value: 'smiles', label: 'SMILES' },
]}
/>
<VisynRanking
data={iris}
selection={selection}
setSelection={setSelection}
onBuiltLineUp={({ createScoreColumn }) => (createScoreColumnFunc.current = createScoreColumn)}
getBuilder={({ data }) => defaultBuilder({ data, smilesOptions: { setDynamicHeight: true } })}
onBuiltLineUp={({ createScoreColumn, provider, lineup }) => {
createScoreColumnFunc.current = createScoreColumn;
autosizeWithSMILESColumn({ provider, lineup });
}}
/>
</Stack>
<Vis
Expand Down
10 changes: 10 additions & 0 deletions src/demo/scoresUtils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { buildCategoricalColumn, buildNumberColumn } from 'lineupjs';
import { IScoreResult } from '../ranking/score/interfaces';
import { buildSMILESColumn } from '../ranking/smiles/SMILESColumnBuilder';

export async function MyStringScore(value: string): Promise<IScoreResult> {
const data = new Array(5000).fill(0).map(() => (Math.random() * 10).toFixed(0));
Expand All @@ -18,3 +19,12 @@ export async function MyNumberScore(value: string): Promise<IScoreResult> {
builder: buildNumberColumn('').label(value),
};
}

export async function MySMILESScore(value: string): Promise<IScoreResult> {
const data = new Array(5000).fill(0).map(() => 'C1CCCCC1');

return {
data,
builder: buildSMILESColumn('').label(value),
};
}
15 changes: 12 additions & 3 deletions src/ranking/EagerVisynRanking.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,20 @@ import LineUp, { builder, buildRanking, Taggle, Ranking, DataBuilder, LocalDataP
import isEqual from 'lodash/isEqual';
import { Box, BoxProps } from '@mantine/core';
import { useSyncedRef } from '../hooks/useSyncedRef';
import '../scss/vendors/_lineup.scss';
import { createScoreColumn, IScoreResult } from './score/interfaces';
import { registerSMILESColumn } from './smiles/utils';

import '../scss/vendors/_lineup.scss';

export const defaultBuilder = ({ data }) => {
export const defaultBuilder = ({
data,
smilesOptions = { setDynamicHeight: false },
}: {
data: Record<string, unknown>[];
smilesOptions?: Parameters<typeof registerSMILESColumn>[1];
}) => {
const b = builder(data).deriveColumns().animated(true);
registerSMILESColumn(b, smilesOptions);
const rankingBuilder = buildRanking();
rankingBuilder.supportTypes();
rankingBuilder.allColumns();
Expand Down Expand Up @@ -53,7 +62,7 @@ export function EagerVisynRanking<T extends Record<string, unknown>>({
...innerProps
}: {
data: T[];
getBuilder?: (props: { data: T[] }) => DataBuilder;
getBuilder?: (props: { data: Record<string, unknown>[] }) => DataBuilder;
setSelection: (selection: T[]) => void;
selection: T[];
onBuiltLineUp?: (props: IBuiltVisynRanking) => void;
Expand Down
1 change: 1 addition & 0 deletions src/ranking/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './VisynRanking';
export * from './score';
export * from './smiles';
110 changes: 110 additions & 0 deletions src/ranking/smiles/SMILESColumn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { StringColumn, IDataRow, Column, ValueColumn, IValueColumnDesc, toolbar } from 'lineupjs';
import { isEqual } from 'lodash';

// internal function copied from lineupjs
function integrateDefaults<T>(desc: T, defaults: Partial<T> = {}) {
Object.keys(defaults).forEach((key) => {
const typed = key as keyof T;
if (typeof desc[typed] === 'undefined') {
(desc as any)[typed] = defaults[typed];
}
});
return desc;
}

export interface ISMILESFilter {
/**
* Search string which is used to filter the column data
*/
filter: string;

/**
* Filter out rows with missing values
*/
filterMissing: boolean;

/**
* The set contains matching results that should be visible
*/
matching: Set<string>;
}

@toolbar('filterSMILES', 'rename')
export class SMILESColumn extends ValueColumn<string> {
protected structureFilter: ISMILESFilter | null = null;

protected align: string | null = null;

constructor(id: string, desc: Readonly<IValueColumnDesc<string>>) {
super(
id,
integrateDefaults(desc, {
summaryRenderer: 'default',
}),
);
}

protected createEventList() {
return super.createEventList().concat([StringColumn.EVENT_FILTER_CHANGED]);
}

filter(row: IDataRow): boolean {
if (!this.isFiltered()) {
return true;
}

// filter out row if no valid results found
if (this.structureFilter.matching === null) {
return false;
}

const rowLabel = this.getLabel(row);

// filter missing values
if (rowLabel == null || rowLabel.trim() === '') {
return !this.structureFilter.filterMissing;
}

return this.structureFilter.matching.has(rowLabel) ?? false;
}

isFiltered(): boolean {
return this.structureFilter != null;
}

getFilter() {
return this.structureFilter;
}

setFilter(filter: ISMILESFilter | null) {
if (isEqual(filter, this.structureFilter)) {
return;
}

// ensure that no filter of the string column is used beyond this point
// TODO remove once the string filter is removed from the UI
if (filter && !filter.matching) {
return;
}

this.fire([StringColumn.EVENT_FILTER_CHANGED, Column.EVENT_DIRTY_VALUES, Column.EVENT_DIRTY], this.structureFilter, (this.structureFilter = filter));
}

clearFilter() {
const was = this.isFiltered();
this.setFilter(null);
return was;
}

getAlign(): string | null {
return this.align;
}

setAlign(structure: string | null): void {
if (isEqual(structure, this.align)) {
return;
}

this.fire([Column.EVENT_DIRTY_VALUES, Column.EVENT_DIRTY], (this.align = structure));
}
}
16 changes: 16 additions & 0 deletions src/ranking/smiles/SMILESColumnBuilder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ColumnBuilder, IStringColumnDesc } from 'lineupjs';

export default class SMILESColumnBuilder extends ColumnBuilder<IStringColumnDesc> {
constructor(column: string) {
super('smiles', column);
}
}

/**
* builds a smiles column builder
* @param {string} column column which contains the associated data
* @returns {StringColumnBuilder}
*/
export function buildSMILESColumn(column: string) {
return new SMILESColumnBuilder(column);
}
151 changes: 151 additions & 0 deletions src/ranking/smiles/SMILESFilterDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { ADialog, IDialogContext, IRankingHeaderContext, LocalDataProvider } from 'lineupjs';
import { debounce } from 'lodash';
import { ISMILESFilter, SMILESColumn } from './SMILESColumn';

// copied from lineupjs
function findFilterMissing(node: HTMLElement) {
return (node.getElementsByClassName('lu-filter-missing')[0] as HTMLElement).previousElementSibling as HTMLInputElement;
}

async function fetchSubstructure(structures: string[], substructure: string): Promise<{ count: { [key: string]: number }; valid: { [key: string]: boolean } }> {
const response = await fetch(`/api/rdkit/substructures/?substructure=${encodeURIComponent(substructure)}`, {
headers: {
'Content-Type': 'application/json',
},
// mode: '*cors', // no-cors, *cors, same-origin
method: 'POST',
redirect: 'follow',
...(structures
? {
body: JSON.stringify(structures),
}
: {}),
});
if (!response.ok) {
const json = await response.json().catch(() => null);
throw Error(json.detail[0].msg || response.statusText);
}
return response.json();
}

export class SMILESFilterDialog extends ADialog {
private readonly before: ISMILESFilter | null;

constructor(private readonly column: SMILESColumn, dialog: IDialogContext, private readonly ctx: IRankingHeaderContext) {
super(dialog, {
livePreview: 'filter',
});

this.before = this.column.getFilter();
}

private findLoadingNode(node: HTMLElement) {
return node.querySelector(`#${this.dialog.idPrefix}_loading`);
}

private findErrorNode(node: HTMLElement) {
return node.querySelector(`#${this.dialog.idPrefix}_error`);
}

private updateFilter(filter: string | null, filterMissing: boolean) {
this.findLoadingNode(this.node).setAttribute('hidden', null);
this.findErrorNode(this.node).setAttribute('hidden', null);

// empty input field + missing values checkbox is unchecked
if (filter == null && !filterMissing) {
this.column.setFilter(null);
return;
}

const provider = this.ctx.provider as LocalDataProvider;
const structures = provider.viewRawRows(provider.data.map((_, i) => i)).map((d) => this.column.getValue(d));

// empty input field, but missing values checkbox is checked
if (filter == null && filterMissing) {
this.column.setFilter({ filter, filterMissing, matching: new Set(structures) }); // pass all structures as set and filter missing values in column.filter()
return;
}

this.findLoadingNode(this.node).removeAttribute('hidden');
this.findErrorNode(this.node).setAttribute('hidden', null);

// input field is not empty -> search matching structures on server
fetchSubstructure(structures, filter)
.then(({ count }) => {
const matching = new Set(
Object.entries(count)
.filter(([, cnt]) => cnt > 0)
.map(([structure]) => structure),
);

this.column.setFilter({ filter, filterMissing, matching });
this.findLoadingNode(this.node).setAttribute('hidden', null);
})
.catch((error: Error) => {
this.findLoadingNode(this.node).setAttribute('hidden', null);

const errorNode = this.findErrorNode(this.node);
errorNode.removeAttribute('hidden');
errorNode.textContent = error.message;

this.column.setFilter({ filter, filterMissing, matching: null }); // no matching structures due to server error
});
}

protected reset() {
this.findInput('input[type="text"]').value = '';
this.forEach('input[type=checkbox]', (n: HTMLInputElement) => {
// eslint-disable-next-line no-param-reassign
n.checked = false;
});
}

protected cancel() {
if (this.before) {
this.updateFilter(this.before.filter === '' ? null : this.before.filter, this.before.filterMissing);
} else {
this.updateFilter(null, false);
}
}

protected submit() {
const filterMissing = findFilterMissing(this.node).checked;
const input = this.findInput('input[type="text"]').value.trim();
this.updateFilter(input === '' ? null : input, filterMissing);
return true;
}

protected build(node: HTMLElement) {
const s = this.ctx.sanitize;
const bak = this.column.getFilter() || { filter: '', filterMissing: false };
node.insertAdjacentHTML(
'beforeend',
`<span style="position: relative;">
<input type="text" placeholder="Substructure filter of ${s(this.column.desc.label)} …" autofocus
value="${bak.filter}" style="width: 100%">
<i id="${this.dialog.idPrefix}_loading" hidden class="fas fa-circle-notch fa-spin text-muted" style="position: absolute;right: 6px;top: 6px;"></i>
</span>
<span id="${this.dialog.idPrefix}_error" class="text-danger" hidden></span>
<label class="lu-checkbox">
<input type="checkbox" ${bak.filterMissing ? 'checked="checked"' : ''}>
<span class="lu-filter-missing">Filter rows containing missing values</span>
</label>`,
);

const filterMissing = findFilterMissing(node);
const input = node.querySelector<HTMLInputElement>('input[type="text"]');

this.enableLivePreviews([filterMissing, input]);

if (!this.showLivePreviews()) {
return;
}
input.addEventListener(
'input',
debounce(() => this.submit(), 100),
{
passive: true,
},
);
}
}
Loading

0 comments on commit 6914039

Please sign in to comment.