-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
002bc82
commit e6a62cb
Showing
8 changed files
with
477 additions
and
6 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}, | ||
); | ||
} | ||
} |
Oops, something went wrong.