@datagrok/bio 2.21.11 → 2.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,42 +1,356 @@
1
+ /* eslint-disable rxjs/no-ignored-subscription */
2
+ /* eslint-disable max-lines */
1
3
  /* eslint-disable max-len */
2
4
  /* eslint-disable max-lines-per-function */
3
5
  import * as grok from 'datagrok-api/grok';
4
6
  import * as DG from 'datagrok-api/dg';
5
7
  import * as ui from 'datagrok-api/ui';
6
8
 
7
- import {MSAScrollingHeader} from '@datagrok-libraries/bio/src/utils/sequence-position-scroller';
9
+ import {ConservationTrack, MSAHeaderTrack, MSAScrollingHeader, WebLogoTrack} from '@datagrok-libraries/bio/src/utils/sequence-position-scroller';
8
10
  import {MonomerPlacer} from '@datagrok-libraries/bio/src/utils/cell-renderer-monomer-placer';
9
11
  import {ALPHABET, TAGS as bioTAGS} from '@datagrok-libraries/bio/src/utils/macromolecule';
10
12
  import {_package} from '../package';
13
+ import {getMonomerLibHelper} from '@datagrok-libraries/bio/src/monomer-works/monomer-utils';
14
+ import {ISeqHandler} from '@datagrok-libraries/bio/src/utils/macromolecule/seq-handler';
15
+ import * as RxJs from 'rxjs';
16
+ import {filter} from 'rxjs/operators';
17
+ import {IMonomerLib} from '@datagrok-libraries/bio/src/types';
18
+
19
+ // ============================================================================
20
+ // OPTIMIZED VIEWPORT-AWARE CACHING WITH FORCE UPDATE SUPPORT
21
+ // ============================================================================
22
+ class MSAViewportManager {
23
+ private static conservationCache: DG.LruCache<string, number[]> = new DG.LruCache<string, number[]>(100);
24
+ private static webLogoCache: DG.LruCache<string, Map<number, Map<string, number>>> = new DG.LruCache<string, Map<number, Map<string, number>>>(100);
25
+
26
+ // Track when data was last invalidated for force updates
27
+ private static lastInvalidationTime: number = 0;
28
+
29
+ // Cache chunks of 200 positions at a time
30
+ private static readonly CHUNK_SIZE = 50;
31
+
32
+ /**
33
+ * Clear all caches and update invalidation timestamp for force updates
34
+ */
35
+ static clearAllCaches(): void {
36
+ MSAViewportManager.conservationCache = new DG.LruCache<string, number[]>(100);
37
+ MSAViewportManager.webLogoCache = new DG.LruCache<string, Map<number, Map<string, number>>>(100);
38
+ MSAViewportManager.lastInvalidationTime = Date.now();
39
+ }
40
+
41
+ /**
42
+ * Get the last invalidation time (used by tracks to detect forced updates)
43
+ */
44
+ static getLastInvalidationTime(): number {
45
+ return MSAViewportManager.lastInvalidationTime;
46
+ }
47
+
48
+ private static getChunkCacheKey(column: DG.Column, chunkStart: number, chunkEnd: number): string {
49
+ const dataFrame = column.dataFrame;
50
+ // Use framework's built-in versioning instead of complex hash
51
+ return `${dataFrame.id}_${column.name}_f${dataFrame.filter.version}_${chunkStart}_${chunkEnd}`;
52
+ }
53
+
54
+ private static getConservationChunk(seqHandler: ISeqHandler, start: number, end: number, cacheKey: string): number[] {
55
+ return MSAViewportManager.conservationCache.getOrCreate(cacheKey, () => {
56
+ // conservation track is only shown together with weblogo track, so we can reuse weblogo for this caluclation
57
+ const webLogoChunk = MSAViewportManager.getWebLogoChunk(seqHandler, start, end, cacheKey);
58
+ const chunkSize = end - start;
59
+ const scores = new Array(chunkSize).fill(0);
60
+ for (let i = start; i < end; i++) {
61
+ const entries = webLogoChunk.get(i);
62
+ if (!entries || entries.size === 0)
63
+ continue;
64
+ let totalResidues = 0;
65
+ let mostCommonCount = 0;
66
+ const gapS = seqHandler.defaultGapOriginal;
67
+ // Single pass to find both total and max
68
+ for (const [mon, count] of entries.entries()) {
69
+ if (!mon || mon === gapS) continue; // Skip gaps
70
+ totalResidues += count;
71
+ if (count > mostCommonCount)
72
+ mostCommonCount = count;
73
+ }
74
+ // Calculate conservation score
75
+ scores[i - start] = totalResidues > 0 ? mostCommonCount / totalResidues : 0;
76
+ }
77
+ return scores;
78
+ });
79
+ }
80
+
81
+ private static getWebLogoChunk(seqHandler: ISeqHandler, start: number, end: number, cacheKey: string): Map<number, Map<string, number>> {
82
+ return MSAViewportManager.webLogoCache.getOrCreate(cacheKey, () => {
83
+ const dataFrame = seqHandler.column.dataFrame;
84
+ const webLogoData: Map<number, Map<string, number>> = new Map();
85
+ // OPTIMIZED: Use filter iterator
86
+ const visibleCount = dataFrame.filter.trueCount;
87
+ if (visibleCount <= 1)
88
+ return webLogoData;
89
+
90
+ // pre-alocate residue counts map
91
+ for (let i = start; i < end; i++)
92
+ webLogoData.set(i, new Map());
93
+
94
+ const filter = dataFrame.filter;
95
+
96
+ const oneOverCount = 1 / visibleCount; // Pre-calculate for performance
97
+
98
+ for (let i = -1; (i = filter.findNext(i, true)) !== -1;) {
99
+ // OPTIMIZED: Use seqHandler.getSplitted which has internal caching
100
+ const seqChunk = seqHandler.getSplitted(i).getOriginalRegion(start, end);
101
+ if (seqChunk.length === 0) continue;
102
+ for (let pos = 0; pos < seqChunk.length; pos++) {
103
+ const residue = seqChunk[pos];
104
+ // in weblogo, do not skip gaps, they are important
105
+ const residueMap = webLogoData.get(start + pos)!;
106
+ // add oneOverCount here to avoid division later
107
+ residueMap.set(residue, (residueMap.get(residue) || 0) + oneOverCount);
108
+ }
109
+ }
110
+ return webLogoData;
111
+ });
112
+ }
113
+
114
+ static getConservationForViewport(seqHandler: ISeqHandler, viewportStart: number, viewportEnd: number, maxLength: number): number[] {
115
+ // Create a full-sized array filled with zeros
116
+ const result: number[] = new Array(maxLength).fill(0);
117
+
118
+ // Calculate which chunks we need
119
+ const startChunk = Math.floor(viewportStart / MSAViewportManager.CHUNK_SIZE) * MSAViewportManager.CHUNK_SIZE;
120
+ const endChunk = Math.ceil(viewportEnd / MSAViewportManager.CHUNK_SIZE) * MSAViewportManager.CHUNK_SIZE;
121
+
122
+ // Get all needed chunks and place them in the correct positions
123
+ for (let chunkStart = startChunk; chunkStart < endChunk; chunkStart += MSAViewportManager.CHUNK_SIZE) {
124
+ const chunkEnd = Math.min(chunkStart + MSAViewportManager.CHUNK_SIZE, maxLength);
125
+ const cacheKey = MSAViewportManager.getChunkCacheKey(seqHandler.column, chunkStart, chunkEnd);
126
+ const chunkData = MSAViewportManager.getConservationChunk(seqHandler, chunkStart, chunkEnd, cacheKey);
127
+
128
+ // Copy chunk data to the correct positions in the result array
129
+ for (let i = 0; i < chunkData.length && (chunkStart + i) < maxLength; i++)
130
+ result[chunkStart + i] = chunkData[i];
131
+ }
132
+
133
+ return result;
134
+ }
135
+
136
+ static getWebLogoForViewport(seqHandler: ISeqHandler, viewportStart: number, viewportEnd: number, maxLength: number): Map<number, Map<string, number>> {
137
+ const result: Map<number, Map<string, number>> = new Map();
138
+
139
+ // Calculate which chunks we need
140
+ const startChunk = Math.floor(viewportStart / MSAViewportManager.CHUNK_SIZE) * MSAViewportManager.CHUNK_SIZE;
141
+ const endChunk = Math.ceil(viewportEnd / MSAViewportManager.CHUNK_SIZE) * MSAViewportManager.CHUNK_SIZE;
142
+
143
+ // Get all needed chunks and place them in the correct positions
144
+ for (let chunkStart = startChunk; chunkStart < endChunk; chunkStart += MSAViewportManager.CHUNK_SIZE) {
145
+ const chunkEnd = Math.min(chunkStart + MSAViewportManager.CHUNK_SIZE, maxLength);
146
+ const cacheKey = MSAViewportManager.getChunkCacheKey(seqHandler.column, chunkStart, chunkEnd);
147
+ const chunkData = MSAViewportManager.getWebLogoChunk(seqHandler, chunkStart, chunkEnd, cacheKey);
148
+
149
+ // Copy chunk data to the result map (positions are already correct)
150
+ for (const [pos, data] of chunkData.entries()) {
151
+ if (pos < maxLength)
152
+ result.set(pos, data);
153
+ }
154
+ }
155
+
156
+ return result;
157
+ }
158
+ }
159
+
160
+ // ============================================================================
161
+ // SMART TRACKS
162
+ // ============================================================================
163
+
164
+ class LazyWebLogoTrack extends WebLogoTrack {
165
+ private seqHandler: ISeqHandler;
166
+ private maxLength: number;
167
+ private lastViewportStart: number = -1;
168
+ private lastViewportEnd: number = -1;
169
+ private lastInvalidationTime: number = 0;
170
+ private forceNextUpdate: boolean = false;
171
+
172
+ constructor(
173
+ seqHandler: ISeqHandler,
174
+ maxLength: number,
175
+ height: number = 45,
176
+ title: string = 'WebLogo'
177
+ ) {
178
+ super(new Map(), height, '', title);
179
+ this.seqHandler = seqHandler;
180
+ this.maxLength = maxLength;
181
+
182
+ this.visible = seqHandler.column.dataFrame.filter.trueCount > 1;
183
+ }
184
+
185
+ public forceUpdate(): void {
186
+ this.forceNextUpdate = true;
187
+ }
188
+
189
+ public resetViewportTracking(): void {
190
+ this.lastViewportStart = -1;
191
+ this.lastViewportEnd = -1;
192
+ }
193
+
194
+ private updateForViewport(viewportStart: number, viewportEnd: number): void {
195
+ const currentInvalidationTime = MSAViewportManager.getLastInvalidationTime();
196
+ const cacheWasInvalidated = currentInvalidationTime > this.lastInvalidationTime;
197
+
198
+ const viewportChanged = (
199
+ Math.abs(this.lastViewportStart - viewportStart) >= 10 ||
200
+ Math.abs(this.lastViewportEnd - viewportEnd) >= 10
201
+ );
202
+
203
+ // Only update if viewport changed significantly OR cache was invalidated OR force update is set
204
+ if (!viewportChanged && !cacheWasInvalidated && !this.forceNextUpdate)
205
+ return;
206
+
207
+
208
+ // Reset flags and update tracking
209
+ this.forceNextUpdate = false;
210
+ this.lastViewportStart = viewportStart;
211
+ this.lastViewportEnd = viewportEnd;
212
+ this.lastInvalidationTime = currentInvalidationTime;
213
+
214
+ // Add buffer to reduce cache misses on small scrolls
215
+ const bufferedStart = Math.max(0, viewportStart - 20);
216
+ const bufferedEnd = Math.min(this.maxLength, viewportEnd + 20);
217
+
218
+ const webLogoData = MSAViewportManager.getWebLogoForViewport(
219
+ this.seqHandler,
220
+ bufferedStart,
221
+ bufferedEnd,
222
+ this.maxLength
223
+ );
224
+
225
+ this.updateData(webLogoData);
226
+ }
227
+
228
+ draw(x: number, y: number, width: number, height: number, windowStart: number,
229
+ positionWidth: number, totalPositions: number, currentPosition: number): void {
230
+ // Calculate what positions are actually visible
231
+ const visiblePositions = Math.ceil(width / positionWidth) + 2;
232
+ const viewportEnd = Math.min(windowStart + visiblePositions, totalPositions);
233
+
234
+ // Update data for just the visible range
235
+ this.updateForViewport(windowStart - 1, viewportEnd - 1); // Convert to 0-based
236
+
237
+ // Call parent draw method
238
+ super.draw(x, y, width, height, windowStart, positionWidth, totalPositions, currentPosition);
239
+ }
240
+ }
241
+
242
+ class LazyConservationTrack extends ConservationTrack {
243
+ private seqHandler: ISeqHandler;
244
+ private maxLength: number;
245
+ private lastViewportStart: number = -1;
246
+ private lastViewportEnd: number = -1;
247
+ private lastInvalidationTime: number = 0;
248
+ private forceNextUpdate: boolean = false;
249
+
250
+ constructor(
251
+ seqHandler: ISeqHandler,
252
+ maxLength: number,
253
+ height: number = 45,
254
+ colorScheme: 'default' | 'rainbow' | 'heatmap' = 'default',
255
+ title: string = 'Conservation'
256
+ ) {
257
+ super([], height, colorScheme, title);
258
+ this.seqHandler = seqHandler;
259
+ this.maxLength = maxLength;
260
+
261
+ // OPTIMIZED: Use dataFrame.filter.trueCount directly
262
+ this.visible = seqHandler.column.dataFrame.filter.trueCount > 1;
263
+ }
264
+
265
+ public forceUpdate(): void {
266
+ this.forceNextUpdate = true;
267
+ }
268
+
269
+ public resetViewportTracking(): void {
270
+ this.lastViewportStart = -1;
271
+ this.lastViewportEnd = -1;
272
+ }
273
+
274
+ private updateForViewport(viewportStart: number, viewportEnd: number): void {
275
+ const currentInvalidationTime = MSAViewportManager.getLastInvalidationTime();
276
+ const cacheWasInvalidated = currentInvalidationTime > this.lastInvalidationTime;
277
+
278
+ const viewportChanged = (
279
+ Math.abs(this.lastViewportStart - viewportStart) >= 10 ||
280
+ Math.abs(this.lastViewportEnd - viewportEnd) >= 10
281
+ );
282
+
283
+ // Only update if viewport changed significantly OR cache was invalidated OR force update is set
284
+ if (!viewportChanged && !cacheWasInvalidated && !this.forceNextUpdate)
285
+ return;
286
+
287
+
288
+ // Reset flags and update tracking
289
+ this.forceNextUpdate = false;
290
+ this.lastViewportStart = viewportStart;
291
+ this.lastViewportEnd = viewportEnd;
292
+ this.lastInvalidationTime = currentInvalidationTime;
293
+
294
+ // Add buffer to reduce cache misses on small scrolls
295
+ const bufferedStart = Math.max(0, viewportStart - 20);
296
+ const bufferedEnd = Math.min(this.maxLength, viewportEnd + 20);
297
+
298
+ const conservationScores = MSAViewportManager.getConservationForViewport(
299
+ this.seqHandler,
300
+ bufferedStart,
301
+ bufferedEnd,
302
+ this.maxLength
303
+ );
304
+
305
+ this.updateData(conservationScores);
306
+ }
307
+
308
+ draw(x: number, y: number, width: number, height: number, windowStart: number,
309
+ positionWidth: number, totalPositions: number, currentPosition: number): void {
310
+ // Calculate what positions are actually visible
311
+ const visiblePositions = Math.ceil(width / positionWidth) + 2;
312
+ const viewportEnd = Math.min(windowStart + visiblePositions, totalPositions);
313
+
314
+ this.updateForViewport(windowStart - 1, viewportEnd - 1); // Convert to 0-based
315
+
316
+ // Call parent draw method
317
+ super.draw(x, y, width, height, windowStart, positionWidth, totalPositions, currentPosition);
318
+ }
319
+ }
320
+
321
+ // ============================================================================
322
+ // MAIN HANDLER
323
+ // ============================================================================
11
324
 
12
325
  export function handleSequenceHeaderRendering() {
13
326
  const handleGrid = (grid: DG.Grid) => {
14
327
  setTimeout(() => {
15
- if (grid.isDetached)
16
- return;
328
+ if (grid.isDetached) return;
329
+
17
330
  const df = grid.dataFrame;
18
- if (!df)
19
- return;
331
+ if (!df) return;
332
+
20
333
  const seqCols = df.columns.bySemTypeAll(DG.SEMTYPE.MACROMOLECULE);
334
+
21
335
  for (const seqCol of seqCols) {
22
- // TODO: Extend this to non-canonical monomers and non msa
23
336
  const sh = _package.seqHelper.getSeqHandler(seqCol);
24
- if (!sh)
25
- continue;
26
- if (sh.isHelm() || sh.alphabet === ALPHABET.UN)
27
- continue;
337
+ if (!sh) continue;
338
+ if (sh.isHelm() || sh.alphabet === ALPHABET.UN) continue;
28
339
 
29
340
  const gCol = grid.col(seqCol.name);
30
- if (!gCol)
31
- continue;
341
+ if (!gCol) continue;
342
+
343
+ let positionStatsViewerAddedOnce = !!grid.tableView &&
344
+ Array.from(grid.tableView.viewers).some((v) => v.type === 'Sequence Position Statistics');
32
345
 
33
- let positionStatsViewerAddedOnce = !!grid.tableView && Array.from(grid.tableView.viewers).some((v) => v.type === 'Sequence Position Statistics');
34
346
  const isMSA = sh.isMsa();
35
347
  const ifNan = (a: number, els: number) => (Number.isNaN(a) ? els : a);
36
348
  const getStart = () => ifNan(Math.max(Number.parseInt(seqCol.getTag(bioTAGS.positionShift) ?? '0'), 0), 0) + 1;
37
349
  const getCurrent = () => ifNan(Number.parseInt(seqCol.getTag(bioTAGS.selectedPosition) ?? '-2'), -2);
38
350
  const getFontSize = () => MonomerPlacer.getFontSettings(seqCol).fontWidth;
39
- // get the maximum length of seqs by getting the primitive length first and then splitting it with correct splitter;
351
+
352
+ // Get maximum sequence length. since this scroller is only applicable to Single character monomeric sequences,
353
+ // we do not need to check every single sequence and split it, instead, max length will coorelate with length of the longest string
40
354
  let pseudoMaxLenIndex = 0;
41
355
  let pseudoMaxLength = 0;
42
356
  const cats = seqCol.categories;
@@ -51,88 +365,178 @@ export function handleSequenceHeaderRendering() {
51
365
  const split = sh.splitter(seq);
52
366
  const maxSeqLen = split ? split.length : 30;
53
367
 
54
- const defaultHeaderHeight = 40;
55
- const scroller = new MSAScrollingHeader({
56
- canvas: grid.overlay,
57
- headerHeight: defaultHeaderHeight,
58
- totalPositions: maxSeqLen + 1,
59
- onPositionChange: (scrollerCur, scrollerRange) => {
60
- setTimeout(() => {
61
- const start = getStart();
62
- const cur = getCurrent();
63
- if (start !== scrollerRange.start)
64
- seqCol.setTag(bioTAGS.positionShift, (scrollerRange.start - 1).toString());
65
- if (cur !== scrollerCur) {
66
- seqCol.setTag(bioTAGS.selectedPosition, (scrollerCur).toString());
67
- if (scrollerCur >= 0 && !positionStatsViewerAddedOnce && grid.tableView) {
68
- positionStatsViewerAddedOnce = true;
69
- const v = grid.tableView.addViewer('Sequence Position Statistics', {sequenceColumnName: seqCol.name});
70
- grid.tableView.dockManager.dock(v, DG.DOCK_TYPE.DOWN, null, 'Sequence Position Statistics', 0.4);
71
- }
72
- }
73
- });
74
- },
368
+ // Do not Skip if sequences are too short, rather, just don't render the tracks by default
369
+
370
+ const STRICT_THRESHOLDS = {
371
+ WITH_TITLE: 58, // BASE + TITLE_HEIGHT(16) + TRACK_GAP(4)
372
+ WITH_WEBLOGO: 107, // WITH_TITLE + DEFAULT_TRACK_HEIGHT(45) + TRACK_GAP(4)
373
+ WITH_BOTH: 156 // WITH_WEBLOGO + DEFAULT_TRACK_HEIGHT(45) + TRACK_GAP(4)
374
+ };
375
+
376
+ let initialHeaderHeight: number;
377
+ if (seqCol.length > 100_000 || maxSeqLen < 50) {
378
+ // Single sequence: just dotted cells
379
+ initialHeaderHeight = STRICT_THRESHOLDS.WITH_TITLE;
380
+ } else {
381
+ if (seqCol.length > 50_000)
382
+ initialHeaderHeight = STRICT_THRESHOLDS.WITH_WEBLOGO;
383
+ else
384
+ initialHeaderHeight = STRICT_THRESHOLDS.WITH_BOTH;
385
+ }
386
+
387
+ let webLogoTrackRef: LazyWebLogoTrack | null = null;
388
+ let conservationTrackRef: LazyConservationTrack | null = null;
389
+ const filterChangeSub = DG.debounce(
390
+ RxJs.merge(df.onFilterChanged, df.onDataChanged.pipe(filter((a) => a?.args?.column === seqCol))), 100
391
+ ).subscribe(() => {
392
+ MSAViewportManager.clearAllCaches();
393
+
394
+ if (webLogoTrackRef) {
395
+ webLogoTrackRef.resetViewportTracking();
396
+ webLogoTrackRef.forceUpdate();
397
+ }
398
+ if (conservationTrackRef) {
399
+ conservationTrackRef.resetViewportTracking();
400
+ conservationTrackRef.forceUpdate();
401
+ }
402
+ setTimeout(() => {
403
+ if (!grid.isDetached)
404
+ grid.invalidate();
405
+ }, 50);
75
406
  });
76
- // adjust header hight automatically only if the sequences are long enough
77
- if (maxSeqLen > 40)
78
- grid.props.colHeaderHeight = 65;
79
-
80
- setTimeout(() => { if (grid.isDetached) return; gCol.width = 400; }, 300); // needed because renderer sets its width
81
- grid.sub(grid.onCellRender.subscribe((e) => {
82
- const cell = e.cell;
83
- if (!cell || !cell.isColHeader || cell?.gridColumn?.name !== gCol?.name)
84
- return;
85
- const cellBounds = e.bounds;
86
- if (!cellBounds || cellBounds.height <= 50) {
87
- scroller.headerHeight = 0;
88
- return;
407
+
408
+ grid.sub(filterChangeSub);
409
+
410
+ const initializeHeaders = (monomerLib: IMonomerLib) => {
411
+ const tracks: { id: string, track: MSAHeaderTrack, priority: number }[] = [];
412
+
413
+ // Create lazy tracks only if we have multiple sequences
414
+
415
+ // OPTIMIZED: Pass seqHandler directly instead of column/splitter
416
+ const conservationTrack = new LazyConservationTrack(
417
+ sh,
418
+ maxSeqLen,
419
+ 45, // DEFAULT_TRACK_HEIGHT
420
+ 'default',
421
+ 'Conservation'
422
+ );
423
+ conservationTrackRef = conservationTrack; // Store reference
424
+ tracks.push({id: 'conservation', track: conservationTrack, priority: 1});
425
+
426
+ // OPTIMIZED: Pass seqHandler directly
427
+ const webLogoTrack = new LazyWebLogoTrack(
428
+ sh,
429
+ maxSeqLen,
430
+ 45, // DEFAULT_TRACK_HEIGHT
431
+ 'WebLogo'
432
+ );
433
+ webLogoTrackRef = webLogoTrack; // Store reference
434
+
435
+ if (monomerLib) {
436
+ webLogoTrack.setMonomerLib(monomerLib);
437
+ webLogoTrack.setBiotype(sh.defaultBiotype || 'HELM_AA');
89
438
  }
90
439
 
91
- const headerTitleSpace = 20;
92
- const availableTitleSpace = cellBounds.height - defaultHeaderHeight;
93
- // Only draw title if we have enough space for it
94
- if (availableTitleSpace > headerTitleSpace) {
95
- // update header height to fit whole header
96
- scroller.headerHeight = cellBounds.height - headerTitleSpace;
440
+ webLogoTrack.setupDefaultTooltip();
441
+ tracks.push({id: 'weblogo', track: webLogoTrack, priority: 2});
442
+
443
+
444
+ // Create the scrolling header
445
+ const scroller = new MSAScrollingHeader({
446
+ canvas: grid.overlay,
447
+ headerHeight: initialHeaderHeight,
448
+ totalPositions: maxSeqLen + 1,
449
+ onPositionChange: (scrollerCur, scrollerRange) => {
450
+ setTimeout(() => {
451
+ const start = getStart();
452
+ const cur = getCurrent();
453
+ if (start !== scrollerRange.start)
454
+ seqCol.setTag(bioTAGS.positionShift, (scrollerRange.start - 1).toString());
455
+
456
+ if (cur !== scrollerCur) {
457
+ seqCol.setTag(bioTAGS.selectedPosition, (scrollerCur).toString());
458
+ if (scrollerCur >= 0 && !positionStatsViewerAddedOnce && grid.tableView) {
459
+ positionStatsViewerAddedOnce = true;
460
+ const v = grid.tableView.addViewer('Sequence Position Statistics', {sequenceColumnName: seqCol.name});
461
+ grid.tableView.dockManager.dock(v, DG.DOCK_TYPE.DOWN, null, 'Sequence Position Statistics', 0.4);
462
+ }
463
+ }
464
+ });
465
+ },
466
+ onHeaderHeightChange: (_newHeight) => {
467
+ // Update grid header height
468
+ if (grid.isDetached || _newHeight < STRICT_THRESHOLDS.WITH_TITLE) return;
469
+ setTimeout(() => grid.props.colHeaderHeight = _newHeight);
470
+ },
471
+ });
97
472
 
98
- const ctx = grid.overlay.getContext('2d');
99
- if (!ctx)
473
+ scroller.setupTooltipHandling();
474
+
475
+ // Add tracks to scroller
476
+ tracks.forEach(({id, track}) => {
477
+ scroller.addTrack(id, track);
478
+ });
479
+
480
+ scroller.setSelectionData(df, seqCol, sh);
481
+
482
+ if (maxSeqLen > 50) {
483
+ grid.props.colHeaderHeight = initialHeaderHeight;
484
+
485
+ // Set column width
486
+ setTimeout(() => {
487
+ if (grid.isDetached) return;
488
+ gCol.width = 400;
489
+ }, 300);
490
+ }
491
+
492
+ // Handle cell rendering
493
+ grid.sub(grid.onCellRender.subscribe((e) => {
494
+ const cell = e.cell;
495
+ if (!cell || !cell.isColHeader || cell?.gridColumn?.name !== gCol?.name)
100
496
  return;
101
- // Save context state
102
- ctx.save();
103
- ctx.rect(cellBounds.x, cellBounds.y, cellBounds.width, headerTitleSpace);
104
- ctx.clip();
105
- // Draw title text
106
- ctx.font = grid.props.colHeaderFont ?? 'bold 13px Roboto, Roboto Local';
107
- ctx.fillStyle = '#4a4a49';
108
- ctx.textAlign = 'center';
109
- ctx.textBaseline = 'middle';
110
-
111
- // Position the title in the middle of the available space above the header
112
- const titleX = cellBounds.x + cellBounds.width / 2;
113
- const titleY = cellBounds.y + headerTitleSpace / 2;
114
-
115
- // Draw the text
116
- ctx.fillText(seqCol.name ?? '', titleX, titleY);
117
-
118
- ctx.restore();
119
- } else
120
- scroller.headerHeight = Math.max(defaultHeaderHeight, cellBounds.height);
121
-
122
- const titleShift = Math.max(0, availableTitleSpace ?? 0) > headerTitleSpace ? headerTitleSpace : 0;
123
- const font = getFontSize();
124
- scroller.positionWidth = font + (isMSA ? 8 : 0);
125
- const start = getStart();
126
- const startPadding = isMSA ? 0 : 4;
127
- scroller.draw(cellBounds.x + startPadding, cellBounds.y + titleShift, cellBounds.width - startPadding, cellBounds.height - titleShift, getCurrent(), start, e);
128
- }));
497
+
498
+
499
+ const cellBounds = e.bounds;
500
+ if (!cellBounds) return;
501
+
502
+ // Set dynamic properties
503
+ scroller.headerHeight = cellBounds.height;
504
+ const font = getFontSize();
505
+ scroller.positionWidth = font + (isMSA ? 8 : 0);
506
+
507
+ const start = getStart();
508
+ const startPadding = isMSA ? 0 : 4;
509
+
510
+ scroller.draw(
511
+ cellBounds.x + startPadding,
512
+ cellBounds.y,
513
+ cellBounds.width - startPadding,
514
+ cellBounds.height,
515
+ getCurrent(),
516
+ start,
517
+ e,
518
+ seqCol.name
519
+ );
520
+ }));
521
+ };
522
+
523
+ // Initialize with monomer library
524
+ getMonomerLibHelper()
525
+ .then((libHelper) => {
526
+ const monomerLib = libHelper.getMonomerLib();
527
+ initializeHeaders(monomerLib);
528
+ })
529
+ .catch((error) => {
530
+ grok.shell.warning(`Failed to initialize monomer library`);
531
+ //initializeHeaders();
532
+ console.error('Failed to initialize monomer library:', error);
533
+ });
129
534
  }
130
535
  }, 1000);
131
536
  };
132
- // handle all new grids
537
+
133
538
  const _ = grok.events.onViewerAdded.subscribe((e) => {
134
- if (!e.args || !(e.args.viewer instanceof DG.Grid))
135
- return;
539
+ if (!e.args || !(e.args.viewer instanceof DG.Grid)) return;
136
540
  const grid = e.args.viewer as DG.Grid;
137
541
  handleGrid(grid);
138
542
  });
@@ -140,7 +544,6 @@ export function handleSequenceHeaderRendering() {
140
544
  const openTables = grok.shell.tableViews;
141
545
  for (const tv of openTables) {
142
546
  const grid = tv?.grid;
143
- if (grid)
144
- handleGrid(grid);
547
+ if (grid) handleGrid(grid);
145
548
  }
146
549
  }