@datagrok/bio 2.21.11 → 2.21.12

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