import * as grok from 'datagrok-api/grok'; import * as ui from 'datagrok-api/ui'; import * as DG from 'datagrok-api/dg'; import {TAGS as bioTAGS} from '@datagrok-libraries/bio/src/utils/macromolecule'; import {ISeqHelper} from '@datagrok-libraries/bio/src/utils/seq-helper'; import {_package} from '../package'; export interface GetRegionParams { table: DG.DataFrame, sequence: DG.Column, start: string | null, end: string | null, /** Name for the column with sequence of the region */ name: string | null, } export interface SeqRegion { name: string, description: string, start: string, end: string, } export class GetRegionFuncEditor { inputs = new class { table: DG.InputBase; sequence: DG.InputBase; region: DG.InputBase; start: DG.InputBase; end: DG.InputBase; name: DG.InputBase; }(); constructor( private readonly call: DG.FuncCall, private readonly seqHelper: ISeqHelper, ) { const getDesc = (paramName: string) => this.call.inputParams[paramName].property.description; this.inputs.table = ui.input.table('Table', {value: this.call.inputParams['table'].value ?? grok.shell.tv.dataFrame}); //@formatter:off const seqColValue = this.call.inputParams['sequence'].value ?? this.inputs.table.value!.columns.bySemType(DG.SEMTYPE.MACROMOLECULE); this.inputs.sequence = ui.input.column('Sequence', {table: grok.shell.tv.dataFrame, value: seqColValue, onValueChanged: this.sequenceInputChanged.bind(this), filter: (col: DG.Column) => col.semType === DG.SEMTYPE.MACROMOLECULE}); this.inputs.start = ui.input.choice('Start', {onValueChanged: this.startInputChanged.bind(this)}) as unknown as DG.InputBase; this.inputs.end = ui.input.choice('End', {onValueChanged: this.endInputChanged.bind(this)}) as unknown as DG.InputBase; this.inputs.region = ui.input.choice('Region', {value: null as unknown as SeqRegion, items: [], onValueChanged: this.regionInputChanged.bind(this)}) as DG.InputBase; this.inputs.name = ui.input.string('Column name', {value: this.getDefaultName(), onValueChanged: this.nameInputChanged.bind(this), clearIcon: true}); this.inputs.name.onInput.subscribe(() => this.nameInputInput.bind(this)); // To catch clear event //@formatter:on // tooltips for (const paramName in this.call.inputParams) { // @ts-ignore ui.tooltip.bind(this.inputs[paramName].captionLabel, getDesc(paramName)); } // initial this.sequenceInputChanged(); } private sequenceInputChanged(): void { const seqCol = this.inputs.sequence.value; const sh = seqCol ? this.seqHelper.getSeqHandler(seqCol) : null; this.updateRegionItems(); this.updateStartEndInputItems(); this.updateRegion(true); this.updateNameInput(); } private fixRegion: boolean = false; private regionInputChanged(): void { this.fixRegion = true; try { const regJsonStr = this.inputs.region.stringValue; const reg: SeqRegion | null = regJsonStr ? JSON.parse(regJsonStr) as SeqRegion : null; if (reg !== null) { this.inputs.start.value = reg?.start; this.inputs.end.value = reg?.end; } else { const sh = this.seqHelper.getSeqHandler(this.inputs.sequence.value!); this.inputs.start.value = sh.posList[0]; this.inputs.end.value = sh.posList[sh.posList.length - 1]; } } finally { this.fixRegion = false; } } private startInputChanged(): void { this.updateRegion(false); this.updateNameInput(); } private endInputChanged(): void { this.updateRegion(false); this.updateNameInput(); } private nameInputChanged(): void { if (!this.defaultNameUpdating) this.defaultName = false; } private nameInputInput(): void { if (!this.inputs.name.value) { this.defaultName = true; this.inputs.name.input.focus(); } } private updateStartEndInputItems(): void { const seqCol = this.inputs.sequence.value; const sh = seqCol ? this.seqHelper.getSeqHandler(seqCol) : null; const startSE = (this.inputs.start.input as HTMLSelectElement); const endSE = (this.inputs.end.input as HTMLSelectElement); for (let i = startSE.options.length - 1; i >= 0; --i) startSE.options.remove(i); for (let i = endSE.options.length - 1; i >= 0; --i) endSE.options.remove(i); for (const pos of sh?.posList ?? []) { const startPosOE = document.createElement('option'); const endPosOE = document.createElement('option'); startPosOE.text = endPosOE.text = pos; startPosOE.value = endPosOE.value = pos; startSE.options.add(startPosOE); endSE.options.add(endPosOE); } startSE.value = sh?.posList[0] ?? ''; endSE.value = sh?.posList[sh?.posList.length - 1] ?? ''; } private updateRegionItems(): void { const seqCol = this.inputs.sequence.value; // Read from .annotations first (new system), fall back to .regions (legacy) let regionList: SeqRegion[] | null = null; const annotationsTag: string | null = seqCol ? seqCol.getTag(bioTAGS.annotations) : null; if (annotationsTag) { try { const annotations = JSON.parse(annotationsTag); const structAnnots = annotations.filter((a: any) => a.category === 'structure' && a.start && a.end); if (structAnnots.length > 0) { regionList = structAnnots.map((a: any) => ({ name: a.name, description: a.description ?? '', start: a.start, end: a.end, })); } } catch { /* ignore parse errors */ } } if (!regionList) { const regionsTagTxt: string | null = seqCol ? seqCol.getTag(bioTAGS.regions) : null; regionList = regionsTagTxt ? JSON.parse(regionsTagTxt) : null; } const regionSE = (this.inputs.region.input as HTMLSelectElement); for (let i = regionSE.options.length - 1; i >= 0; --i) regionSE.options.remove(i); const nullOE = document.createElement('option'); nullOE.text = ''; nullOE.value = JSON.stringify(null); regionSE.options.add(nullOE); if (regionList != null) { this.inputs.region.root.style.removeProperty('display'); for (const region of regionList) { const regionOE = document.createElement('option'); regionOE.text = `${region.name}: ${region.start}-${region.end}`; regionOE.value = JSON.stringify(region); regionSE.options.add(regionOE); } } else { this.inputs.region.root.style.display = 'none'; } } private updateRegion(reset: boolean): void { const startPos: string = this.inputs.start.stringValue ?? ''; const endPos: string = this.inputs.end.stringValue ?? ''; if (!this.fixRegion) { const regionSE = (this.inputs.region.input as HTMLSelectElement); regionSE.selectedIndex = -1; for (let i = regionSE.options.length - 1; i >= 0; --i) { const regionOE = regionSE.options[i]; const reg: SeqRegion = JSON.parse(regionOE.value); if (reg && startPos === reg.start && endPos === reg.end) regionSE.selectedIndex = i; } } } private defaultName: boolean = true; private defaultNameUpdating: boolean = false; private updateNameInput(): void { this.defaultNameUpdating = true; try { if (this.defaultName) this.inputs.name.value = this.getDefaultName(); } finally { this.defaultNameUpdating = false; } } private getDefaultName(): string { const regionJsonStr = this.inputs.region.stringValue; const reg: SeqRegion | null = regionJsonStr ? JSON.parse(regionJsonStr) : null; const seqCol: DG.Column = this.inputs.sequence.value!; const startPos: string = this.inputs.start.stringValue ?? ''; const endPos: string = this.inputs.end.stringValue ?? ''; return reg != null ? `${seqCol.name}(${reg.name}): ${reg.start}-${reg.end}` : `${seqCol?.name}: (${startPos}-${endPos})`; } public getParams(): GetRegionParams { return { table: this.inputs.table.value!, sequence: this.inputs.sequence.value!, start: this.getStart(), end: this.getEnd(), name: this.getName(), }; } private getStart(): string | null { return this.inputs.start.stringValue; } private getEnd(): string | null { return this.inputs.end.stringValue; } private getName(): string | null { const str = this.inputs.name.stringValue; return str == '' ? null : str; } // -- History -- /** Serializes the region selection (start/end position codes + column name) for dialog history. */ public getStringInput(): string { return JSON.stringify({ start: this.getStart(), end: this.getEnd(), name: this.inputs.name.stringValue, }); } /** Restores a region selection from history, applying start/end only if the positions exist in the * current sequence column (positions depend on the chosen column). */ public applyStringInput(input: string): void { try { const parsed = JSON.parse(input); const seqCol = this.inputs.sequence.value; const posList: string[] = seqCol ? this.seqHelper.getSeqHandler(seqCol).posList : []; if (parsed.start != null && posList.includes(parsed.start)) this.inputs.start.value = parsed.start; if (parsed.end != null && posList.includes(parsed.end)) this.inputs.end.value = parsed.end; // set the name last: changing start/end resets it to the default via updateNameInput if (parsed.name) this.inputs.name.value = parsed.name; } catch (e: any) { _package.logger.error(e instanceof Error ? e.message : e?.toString()); } } // -- UI -- /** Full inputs form (table + sequence + region/start/end/name), for hosting inside the canonical * FuncCallParamsEditor. */ public getEditorForm(): HTMLElement { return ui.inputs(Object.values(this.inputs), {style: {minWidth: '320px'}}); } public dialog(): void { const inputsForm = ui.inputs(Object.values(this.inputs), {style: {minWidth: '320px'}}); ui.dialog({title: 'Get Region'}) .add(inputsForm) .onOK(() => { (async () => { const callParams = this.getParams(); await this.call.func.prepare(callParams).call(true); })() .catch((err: any) => { _package.handleErrorUI(err); }); }) .show(); } public widget(): DG.Widget { const inputsForm = ui.inputs(Object.entries(this.inputs) .filter(([inputName, _input]) => !['table', 'sequence'].includes(inputName)) .map(([_inputName, input]) => input)); const doBtn = ui.button('Get Region', () => { (async () => { const callParams = this.getParams(); await this.call.func.prepare(callParams).call(true); })() .catch((err: any) => { _package.handleErrorUI(err); }); }); return DG.Widget.fromRoot(ui.divV([inputsForm, ui.div(doBtn)])); } }