diff --git a/src/demo/MainApp.tsx b/src/demo/MainApp.tsx index 60ffc0416..0995ee299 100644 --- a/src/demo/MainApp.tsx +++ b/src/demo/MainApp.tsx @@ -6,7 +6,7 @@ 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 { MyNumberScore, MySMILESScore, MyStringScore } from './scoresUtils'; export function MainApp() { const { user } = useVisynAppContext(); @@ -63,7 +63,16 @@ 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); }} @@ -71,6 +80,7 @@ export function MainApp() { data={[ { value: 'number', label: 'Number' }, { value: 'category', label: 'Category' }, + { value: 'smiles', label: 'SMILES' }, ]} /> { const data = new Array(5000).fill(0).map(() => (Math.random() * 10).toFixed(0)); @@ -18,3 +19,12 @@ export async function MyNumberScore(value: string): Promise { builder: buildNumberColumn('').label(value), }; } + +export async function MySMILESScore(value: string): Promise { + const data = new Array(5000).fill(0).map(() => 'C1CCCCC1'); + + return { + data, + builder: buildSMILESColumn('').label(value), + }; +} diff --git a/src/ranking/EagerVisynRanking.tsx b/src/ranking/EagerVisynRanking.tsx index e253dc580..b4bdfdd8d 100644 --- a/src/ranking/EagerVisynRanking.tsx +++ b/src/ranking/EagerVisynRanking.tsx @@ -3,11 +3,16 @@ 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 as internalRegisterSMILESColumn } from './smiles/utils'; + +import '../scss/vendors/_lineup.scss'; + +type GetBuilderProps = { data: T[]; registerSMILESColumn: typeof internalRegisterSMILESColumn }; -export const defaultBuilder = ({ data }) => { +const defaultBuilder = >({ data, registerSMILESColumn }: GetBuilderProps) => { const b = builder(data).deriveColumns().animated(true); + registerSMILESColumn(b, { setDynamicHeight: false }); const rankingBuilder = buildRanking(); rankingBuilder.supportTypes(); rankingBuilder.allColumns(); @@ -53,7 +58,7 @@ export function EagerVisynRanking>({ ...innerProps }: { data: T[]; - getBuilder?: (props: { data: T[] }) => DataBuilder; + getBuilder?: (props: GetBuilderProps & { defaultBuilder: (props: GetBuilderProps) => DataBuilder }) => DataBuilder; setSelection: (selection: T[]) => void; selection: T[]; onBuiltLineUp?: (props: IBuiltVisynRanking) => void; @@ -71,7 +76,7 @@ export function EagerVisynRanking>({ React.useEffect(() => { lineupRef.current?.destroy(); - const b = getBuilderRef.current({ data }); + const b = getBuilderRef.current({ data, registerSMILESColumn: internalRegisterSMILESColumn, defaultBuilder }); // Build the ranking lineupRef.current = b.buildTaggle(divRef.current); diff --git a/src/ranking/smiles/SMILESColumn.tsx b/src/ranking/smiles/SMILESColumn.tsx new file mode 100644 index 000000000..3600d0f16 --- /dev/null +++ b/src/ranking/smiles/SMILESColumn.tsx @@ -0,0 +1,110 @@ +import { StringColumn, IDataRow, Column, ValueColumn, IValueColumnDesc, toolbar } from 'lineupjs'; +import { isEqual } from 'lodash'; + +// internal function copied from lineupjs +function integrateDefaults(desc: T, defaults: Partial = {}) { + 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; +} + +@toolbar('filterSMILES', 'rename') +export class SMILESColumn extends ValueColumn { + protected structureFilter: ISMILESFilter | null = null; + + protected align: string | null = null; + + constructor(id: string, desc: Readonly>) { + 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)); + } +} diff --git a/src/ranking/smiles/SMILESColumnBuilder.ts b/src/ranking/smiles/SMILESColumnBuilder.ts new file mode 100644 index 000000000..a8a65eb18 --- /dev/null +++ b/src/ranking/smiles/SMILESColumnBuilder.ts @@ -0,0 +1,16 @@ +import { ColumnBuilder, IStringColumnDesc } from 'lineupjs'; + +export default class SMILESColumnBuilder extends ColumnBuilder { + 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); +} diff --git a/src/ranking/smiles/SMILESFilterDialog.tsx b/src/ranking/smiles/SMILESFilterDialog.tsx new file mode 100644 index 000000000..5932d831b --- /dev/null +++ b/src/ranking/smiles/SMILESFilterDialog.tsx @@ -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', + ` + + + + + `, + ); + + const filterMissing = findFilterMissing(node); + const input = node.querySelector('input[type="text"]'); + + this.enableLivePreviews([filterMissing, input]); + + if (!this.showLivePreviews()) { + return; + } + input.addEventListener( + 'input', + debounce(() => this.submit(), 100), + { + passive: true, + }, + ); + } +} diff --git a/src/ranking/smiles/SMILESRenderer.tsx b/src/ranking/smiles/SMILESRenderer.tsx new file mode 100644 index 000000000..355e39a38 --- /dev/null +++ b/src/ranking/smiles/SMILESRenderer.tsx @@ -0,0 +1,123 @@ +import { + ICellRendererFactory, + ERenderMode, + ICellRenderer, + IDataRow, + IRenderContext, + IGroupCellRenderer, + IOrderedGroup, + renderMissingDOM, + ISummaryRenderer, +} from 'lineupjs'; +import { abortAble } from 'lineupengine'; +import { SMILESColumn } from './SMILESColumn'; + +const template = '
'; + +function getImageURL(structure: string, substructure: string | null = null, align: string | null = null): string { + return `/api/rdkit/?structure=${encodeURIComponent(structure)}${substructure ? `&substructure=${encodeURIComponent(substructure)}` : ''}${ + align ? `&align=${encodeURIComponent(align)}` : '' + }`; +} + +async function fetchImage({ url, data, method }: { url: string; data?: any; method?: string }): Promise { + const response = await fetch(url, { + headers: { + 'Content-Type': 'application/json', + }, + // mode: '*cors', // no-cors, *cors, same-origin + method, + redirect: 'follow', + ...(data + ? { + body: JSON.stringify(data), + } + : {}), + }); + if (!response.ok) { + throw Error((await response.json().catch(() => null))?.message || response.statusText); + } + return response.text(); +} + +async function getReducedImages(structures: string[]): Promise { + // maximum common substructure + if (structures.length > 2) { + return fetchImage({ url: '/api/rdkit/mcs/', data: structures, method: 'POST' }); + } + + // similarity + if (structures.length === 2) { + const reference = structures[0]; + const probe = structures.length > 1 ? structures[1] : structures[0]; + return fetchImage({ url: `/api/rdkit/similarity/?structure=${encodeURIComponent(probe)}&reference=${encodeURIComponent(reference)}`, method: 'GET' }); + } + + // single = first structure + return fetchImage({ url: `/api/rdkit/?structure=${encodeURIComponent(structures[0])}`, method: 'GET' }); +} + +function svgToImageSrc(svg: string): string { + return `data:image/svg+xml;base64,${btoa(svg)}`; +} + +function svgToCSSBackground(svg: string): string { + return `url('${svgToImageSrc(svg)}')`; +} + +export class SMILESRenderer implements ICellRendererFactory { + readonly title: string = 'SMILES'; + + canRender(col: SMILESColumn, mode: ERenderMode): boolean { + return col instanceof SMILESColumn && (mode === ERenderMode.CELL || mode === ERenderMode.GROUP); + } + + create(col: SMILESColumn): ICellRenderer { + return { + template, + update: (n: HTMLLinkElement, d: IDataRow) => { + if (!renderMissingDOM(n, col, d)) { + if (d.v.images?.[0]) { + n.style.backgroundImage = svgToCSSBackground(d.v.images[0]); + return null; + } + const value = col?.getValue(d); + // Load aysnc to avoid triggering + return abortAble( + new Promise((resolve) => { + window.setTimeout(() => resolve(value), 500); + }), + ).then((image) => { + if (typeof image === 'symbol') { + return; + } + n.style.backgroundImage = `url('${getImageURL(value, col.getFilter()?.filter, col.getAlign())}')`; + n.title = value; + }); + } + return null; + }, + }; + } + + createGroup(col: SMILESColumn, context: IRenderContext): IGroupCellRenderer { + return { + template, + update: (n: HTMLImageElement, group: IOrderedGroup) => { + context.tasks.groupRows(col, group, 'SMILESRendererGroup', (rows) => { + return abortAble(getReducedImages(Array.from(rows.map((row) => col.getLabel(row))))).then((res: any) => { + n.style.backgroundImage = res ? svgToCSSBackground(res) : ''; + }); + }); + }, + }; + } + + createSummary(): ISummaryRenderer { + // no renderer + return { + template: `
`, + update: () => {}, + }; + } +} diff --git a/src/ranking/smiles/utils.ts b/src/ranking/smiles/utils.ts new file mode 100644 index 000000000..240475185 --- /dev/null +++ b/src/ranking/smiles/utils.ts @@ -0,0 +1,46 @@ +import { DataBuilder, IRankingHeaderContext, defaultOptions, dialogContext } from 'lineupjs'; +import { SMILESColumn } from './SMILESColumn'; +import { SMILESRenderer } from './SMILESRenderer'; +import { SMILESFilterDialog } from './SMILESFilterDialog'; + +export function registerSMILESColumn(builder: DataBuilder, { setDynamicHeight = false }: { setDynamicHeight?: boolean } = {}) { + builder.registerColumnType('smiles', SMILESColumn); + builder.registerRenderer('smiles', new SMILESRenderer()); + builder.registerToolbarAction( + 'filterSMILES', + // TODO remove default string filter; use `filterString` as key would override the default string filter, but also for string columns + { + title: 'Filter …', // the toolbar icon is derived from this string! (= transformed to `lu-action-filter`) + onClick: (col: SMILESColumn, evt: MouseEvent, ctx: IRankingHeaderContext, level: number, viaShortcut: boolean) => { + const dialog = new SMILESFilterDialog(col, dialogContext(ctx, level, evt), ctx); + dialog.open(); + }, + options: { + mode: 'menu+shortcut', + featureCategory: 'ranking', + featureLevel: 'basic', + }, + }, + ); + + if (setDynamicHeight) { + builder.dynamicHeight((d, ranking) => { + const DEFAULT_ROW_HEIGHT = defaultOptions().rowHeight; // LineUp default rowHeight = 18 + + // get list of smiles columns from the current ranking + const smilesColumns = ranking.children.filter((col) => col instanceof SMILESColumn); + + if (smilesColumns.length === 0) { + return { defaultHeight: DEFAULT_ROW_HEIGHT, height: () => DEFAULT_ROW_HEIGHT, padding: () => 0 }; + } + + const maxColumnHeight = Math.max(DEFAULT_ROW_HEIGHT, ...smilesColumns.map((col) => col.getWidth())); // squared image -> use col width as height + + return { + defaultHeight: maxColumnHeight, + height: () => maxColumnHeight, + padding: () => 0, + }; + }); + } +}