@datagrok-libraries/bio 5.53.2 → 5.53.3

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,3 +1,5 @@
1
+ /* eslint-disable new-cap */
2
+ /* eslint-disable valid-jsdoc */
1
3
  /* eslint-disable max-len */
2
4
  /**
3
5
  * MSAHeader.ts - Interactive MSA Header Component
@@ -5,15 +7,499 @@
5
7
  * with position markers, slider navigation, and position selection.
6
8
  */
7
9
  import * as ui from 'datagrok-api/ui';
8
- export class MSAScrollingHeader {
10
+ import * as DG from 'datagrok-api/dg';
11
+ // Layout Constants
12
+ // const LAYOUT_CONSTANTS = {
13
+ // TITLE_HEIGHT: 16,
14
+ // TRACK_GAP: 4,
15
+ // DOTTED_CELL_HEIGHT: 30,
16
+ // SLIDER_HEIGHT: 8,
17
+ // TOP_PADDING: 5,
18
+ // DEFAULT_TRACK_HEIGHT: 45,
19
+ // MIN_TRACK_HEIGHT: 35,
20
+ // TRACK_SELECTOR_SIZE: 20,
21
+ // TRACK_SELECTOR_MARGIN: 5
22
+ // } as const;
23
+ // WebLogo Constants
24
+ const WEBLOGO_CONSTANTS = {
25
+ PADDING: 2,
26
+ MIN_LETTER_HEIGHT: 4,
27
+ SEPARATOR_WIDTH: 1,
28
+ MIN_GRID_COLUMNS: 45
29
+ };
30
+ // Typography Constants
31
+ const FONTS = {
32
+ TITLE: 'bold 10px Roboto, Roboto Local',
33
+ COLUMN_TITLE: 'bold 13px Roboto, Roboto Local',
34
+ POSITION_LABELS: '12px monospace',
35
+ CONSERVATION_TEXT: '9px monospace',
36
+ TOOLTIP_MAIN: '13px',
37
+ TOOLTIP_SECONDARY: '12px',
38
+ TOOLTIP_SMALL: '11px',
39
+ };
40
+ // Color Constants
41
+ const COLORS = {
42
+ TITLE_TEXT: '#333333',
43
+ SELECTION_HIGHLIGHT: 'rgba(60, 177, 115, 0.1)',
44
+ SELECTION_STRONG: 'rgba(60, 177, 115, 0.2)',
45
+ SELECTION_CONNECTION: 'rgba(60, 177, 115, 0.4)',
46
+ BACKGROUND_LIGHT: 'rgba(240, 240, 240, 0.5)',
47
+ BORDER_LIGHT: 'rgba(100, 100, 100, 0.3)',
48
+ SEPARATOR_LIGHT: 'rgba(255, 255, 255, 0.4)',
49
+ HOVER_SHADOW: 'rgba(255, 255, 255, 0.8)',
50
+ SLIDER_DEFAULT: 'rgba(220, 220, 220, 0.4)',
51
+ SLIDER_WINDOW: 'rgba(150, 150, 150, 0.5)',
52
+ POSITION_DOT: '#999999',
53
+ SLIDER_MARKER: '#3CB173',
54
+ };
55
+ // Tooltip Style Template
56
+ const TOOLTIP_STYLE = {
57
+ color: '#4A4A49',
58
+ background: '#FFFBCC',
59
+ borderRadius: '3px',
60
+ padding: '8px',
61
+ fontSize: '12px',
62
+ fontFamily: 'Roboto, Roboto Local',
63
+ minWidth: '120px'
64
+ };
65
+ /**
66
+ * Base class for all MSA header tracks
67
+ */
68
+ export class MSAHeaderTrack {
69
+ constructor(height = LAYOUT_CONSTANTS.DEFAULT_TRACK_HEIGHT, minHeight = LAYOUT_CONSTANTS.MIN_TRACK_HEIGHT, title = '') {
70
+ this.ctx = null;
71
+ this.visible = true;
72
+ this.title = '';
73
+ this.tooltipEnabled = false;
74
+ this.tooltipContent = null;
75
+ this.height = height;
76
+ this.defaultHeight = height;
77
+ this.minHeight = minHeight;
78
+ this.title = title;
79
+ }
80
+ /**
81
+ * Initialize the track with a canvas context
82
+ */
83
+ init(ctx) {
84
+ this.ctx = ctx;
85
+ }
86
+ getMonomerAt(x, y, position) {
87
+ return null;
88
+ }
89
+ enableTooltip(enabled) {
90
+ this.tooltipEnabled = enabled;
91
+ }
92
+ setTooltipContentGenerator(contentGenerator) {
93
+ this.tooltipContent = contentGenerator;
94
+ }
95
+ getTooltipContent(position) {
96
+ if (!this.tooltipEnabled || !this.tooltipContent)
97
+ return null;
98
+ const positionData = this.getPositionData(position);
99
+ return this.tooltipContent(position, positionData);
100
+ }
101
+ /**
102
+ * Get data for a specific position (to be implemented by subclasses)
103
+ */
104
+ getPositionData(position) {
105
+ return null;
106
+ }
107
+ setVisible(visible) {
108
+ this.visible = visible;
109
+ }
110
+ getHeight() {
111
+ return this.visible ? this.height : 0;
112
+ }
113
+ getDefaultHeight() {
114
+ return this.defaultHeight;
115
+ }
116
+ getMinHeight() {
117
+ return this.minHeight;
118
+ }
119
+ setHeight(height) {
120
+ this.height = Math.max(this.minHeight, height);
121
+ }
122
+ resetHeight() {
123
+ this.height = this.defaultHeight;
124
+ }
125
+ isVisible() {
126
+ return this.visible;
127
+ }
128
+ setTitle(title) {
129
+ this.title = title;
130
+ }
131
+ getTitle() {
132
+ return this.title;
133
+ }
134
+ }
135
+ export class WebLogoTrack extends MSAHeaderTrack {
136
+ constructor(data = new Map(), height = LAYOUT_CONSTANTS.DEFAULT_TRACK_HEIGHT, _colorScheme = '', title = 'WebLogo') {
137
+ super(height, LAYOUT_CONSTANTS.DEFAULT_TRACK_HEIGHT, title);
138
+ this.data = new Map();
139
+ this.monomerLib = null;
140
+ this.biotype = 'PEPTIDE';
141
+ this.hoveredPosition = -1;
142
+ this.hoveredMonomer = null;
143
+ this.data = data;
144
+ this.visible = data.size > 0;
145
+ }
146
+ setHovered(position, monomer) {
147
+ this.hoveredPosition = position;
148
+ this.hoveredMonomer = monomer;
149
+ }
150
+ getPositionData(position) {
151
+ return this.data.get(position) || null;
152
+ }
153
+ setupDefaultTooltip() {
154
+ this.enableTooltip(true);
155
+ this.setTooltipContentGenerator((position, data) => {
156
+ return this.createTooltipContent(position, data);
157
+ });
158
+ }
159
+ createFrequencyTable(data) {
160
+ const sortedResidues = Array.from(data.entries()).sort((a, b) => b[1] - a[1]);
161
+ const table = document.createElement('table');
162
+ table.style.borderCollapse = 'collapse';
163
+ table.style.marginTop = '4px';
164
+ table.style.fontSize = '11px';
165
+ // Create header
166
+ const headerRow = table.insertRow();
167
+ const headerCell1 = headerRow.insertCell();
168
+ const headerCell2 = headerRow.insertCell();
169
+ headerCell1.textContent = 'Residue';
170
+ headerCell2.textContent = 'Frequency';
171
+ headerCell1.style.fontWeight = 'bold';
172
+ headerCell2.style.fontWeight = 'bold';
173
+ headerCell1.style.padding = '4px 8px';
174
+ headerCell2.style.padding = '4px 8px';
175
+ headerCell1.style.borderBottom = '1px solid #ccc';
176
+ headerCell2.style.borderBottom = '1px solid #ccc';
177
+ for (const [residue, freq] of sortedResidues) {
178
+ const row = table.insertRow();
179
+ const cell1 = row.insertCell();
180
+ const cell2 = row.insertCell();
181
+ const backgroundColor = this.getMonomerBackgroundColor(residue);
182
+ const textColor = this.getMonomerTextColor(residue);
183
+ cell1.textContent = residue;
184
+ cell1.style.backgroundColor = backgroundColor;
185
+ cell1.style.color = textColor;
186
+ cell1.style.textAlign = 'center';
187
+ cell1.style.fontWeight = 'bold';
188
+ cell1.style.padding = '4px 8px';
189
+ cell1.style.border = '1px solid rgba(0,0,0,0.1)';
190
+ cell2.textContent = `${(freq * 100).toFixed(0)}%`;
191
+ cell2.style.textAlign = 'right';
192
+ cell2.style.padding = '4px 8px';
193
+ cell2.style.border = '1px solid rgba(0,0,0,0.1)';
194
+ }
195
+ return table;
196
+ }
197
+ createTooltipContent(position, data) {
198
+ const tooltipRows = [];
199
+ tooltipRows.push(ui.divText(`Position: ${position + 1}`, {
200
+ style: { fontWeight: 'bold', marginBottom: '6px', fontSize: '13px' }
201
+ }));
202
+ if (data && data.size > 0) {
203
+ const freqTable = this.createFrequencyTable(data);
204
+ tooltipRows.push(freqTable);
205
+ }
206
+ else {
207
+ tooltipRows.push(ui.divText('No data available', {
208
+ style: { fontStyle: 'italic', color: '#666' }
209
+ }));
210
+ }
211
+ // Use the standard tooltip container approach
212
+ const tooltipEl = ui.divV(tooltipRows);
213
+ tooltipEl.style.maxHeight = '80vh';
214
+ return tooltipEl;
215
+ }
216
+ setMonomerLib(monomerLib) {
217
+ this.monomerLib = monomerLib;
218
+ }
219
+ setBiotype(biotype) {
220
+ this.biotype = biotype;
221
+ }
222
+ /**
223
+ * Calculate which monomer is at the specified coordinates within a WebLogo column
224
+ */
225
+ getMonomerAt(x, y, position) {
226
+ if (!this.ctx || !this.visible || this.data.size === 0)
227
+ return null;
228
+ const residueFreqs = this.data.get(position);
229
+ if (!residueFreqs || residueFreqs.size === 0)
230
+ return null;
231
+ // No title offset needed since we don't draw titles in tracks anymore
232
+ const relativeY = y;
233
+ const sortedResidues = Array.from(residueFreqs.entries()).sort((a, b) => b[1] - a[1]);
234
+ const totalFreq = sortedResidues.reduce((sum, [_, freq]) => sum + freq, 0);
235
+ const columnHeight = this.height;
236
+ let currentY = 0;
237
+ for (const [residue, freq] of sortedResidues) {
238
+ const letterHeight = freq * columnHeight / totalFreq;
239
+ if (relativeY >= currentY && relativeY < currentY + letterHeight)
240
+ return residue;
241
+ currentY += letterHeight;
242
+ }
243
+ return null;
244
+ }
245
+ updateData(data) {
246
+ this.data = data;
247
+ this.visible = data.size > 0;
248
+ }
249
+ /**
250
+ * Draw the WebLogo track
251
+ */
252
+ draw(x, y, width, height, windowStart, positionWidth, totalPositions, currentPosition) {
253
+ if (!this.ctx || !this.visible || this.data.size === 0)
254
+ return;
255
+ const visiblePositionsN = Math.floor(width / positionWidth);
256
+ const effectiveLogoHeight = height - WEBLOGO_CONSTANTS.PADDING * 2;
257
+ const columnY = y + WEBLOGO_CONSTANTS.PADDING;
258
+ for (let i = 0; i < visiblePositionsN; i++) {
259
+ this.drawWebLogoColumn(i, x, y, width, height, windowStart, positionWidth, totalPositions, currentPosition, columnY, effectiveLogoHeight);
260
+ }
261
+ }
262
+ drawWebLogoColumn(i, x, y, width, height, windowStart, positionWidth, totalPositions, currentPosition, columnY, effectiveLogoHeight) {
263
+ const position = windowStart + i - 1;
264
+ if (position < 0 || position >= totalPositions)
265
+ return;
266
+ const residueFreqs = this.data.get(position);
267
+ if (!residueFreqs || residueFreqs.size === 0)
268
+ return;
269
+ const posX = x + (i * positionWidth);
270
+ const cellWidth = positionWidth - WEBLOGO_CONSTANTS.PADDING;
271
+ // Highlight selected position
272
+ if (windowStart + i === currentPosition) {
273
+ this.ctx.fillStyle = COLORS.SELECTION_HIGHLIGHT;
274
+ this.ctx.fillRect(posX, y, positionWidth, height);
275
+ }
276
+ // Draw column background
277
+ this.ctx.fillStyle = COLORS.BACKGROUND_LIGHT;
278
+ this.ctx.fillRect(posX + WEBLOGO_CONSTANTS.PADDING / 2, columnY, cellWidth, effectiveLogoHeight);
279
+ this.drawLettersInColumn(position, posX, cellWidth, columnY, effectiveLogoHeight, residueFreqs);
280
+ this.drawColumnBorder(posX, columnY, cellWidth, effectiveLogoHeight);
281
+ }
282
+ drawLettersInColumn(position, posX, cellWidth, columnY, effectiveLogoHeight, residueFreqs) {
283
+ const sortedResidues = Array.from(residueFreqs.entries()).sort((a, b) => b[1] - a[1]);
284
+ const totalFreq = sortedResidues.reduce((sum, [_, freq]) => sum + freq, 0);
285
+ const scaleFactor = Math.min(1, effectiveLogoHeight / (totalFreq * effectiveLogoHeight));
286
+ let currentY = columnY;
287
+ const columnBottom = columnY + effectiveLogoHeight;
288
+ for (const [residue, freq] of sortedResidues) {
289
+ const rawLetterHeight = freq * effectiveLogoHeight * scaleFactor;
290
+ const letterHeight = Math.max(WEBLOGO_CONSTANTS.MIN_LETTER_HEIGHT, Math.floor(rawLetterHeight));
291
+ if (letterHeight < WEBLOGO_CONSTANTS.MIN_LETTER_HEIGHT)
292
+ continue;
293
+ const isHovered = position === this.hoveredPosition && residue === this.hoveredMonomer;
294
+ const finalHeight = Math.min(letterHeight, columnBottom - currentY);
295
+ if (finalHeight < WEBLOGO_CONSTANTS.MIN_LETTER_HEIGHT)
296
+ break;
297
+ this.drawLetter(residue, posX + WEBLOGO_CONSTANTS.PADDING / 2, currentY, cellWidth, finalHeight, isHovered);
298
+ currentY += finalHeight;
299
+ }
300
+ }
301
+ drawColumnBorder(posX, columnY, cellWidth, effectiveLogoHeight) {
302
+ this.ctx.strokeStyle = COLORS.BORDER_LIGHT;
303
+ this.ctx.lineWidth = 1;
304
+ this.ctx.strokeRect(posX + WEBLOGO_CONSTANTS.PADDING / 2, columnY, cellWidth, effectiveLogoHeight);
305
+ }
306
+ drawLetter(letter, x, y, width, height, isHovered = false) {
307
+ if (!this.ctx)
308
+ return;
309
+ const backgroundColor = this.getMonomerBackgroundColor(letter);
310
+ const textColor = this.getMonomerTextColor(letter);
311
+ // Fill background
312
+ this.ctx.fillStyle = backgroundColor;
313
+ this.ctx.fillRect(x, y, width, height);
314
+ // Apply hover effect
315
+ if (isHovered) {
316
+ this.ctx.shadowColor = COLORS.HOVER_SHADOW;
317
+ this.ctx.shadowBlur = 8;
318
+ this.ctx.strokeStyle = 'white';
319
+ this.ctx.lineWidth = 2;
320
+ this.ctx.strokeRect(x, y, width, height);
321
+ this.ctx.shadowBlur = 0;
322
+ }
323
+ this.drawLetterSeparators(x, y, width, height);
324
+ this.drawLetterText(letter, x, y, width, height, textColor);
325
+ }
326
+ drawLetterSeparators(x, y, width, height) {
327
+ this.ctx.strokeStyle = COLORS.SEPARATOR_LIGHT;
328
+ this.ctx.lineWidth = WEBLOGO_CONSTANTS.SEPARATOR_WIDTH;
329
+ if (y + height < y + this.ctx.canvas.height) {
330
+ this.ctx.beginPath();
331
+ this.ctx.moveTo(x, y + height);
332
+ this.ctx.lineTo(x + width, y + height);
333
+ this.ctx.stroke();
334
+ }
335
+ }
336
+ drawLetterText(letter, x, y, width, height, textColor) {
337
+ const fontSize = Math.min(height * 0.8, width * 0.8);
338
+ if (fontSize >= 7) {
339
+ this.ctx.fillStyle = textColor;
340
+ this.ctx.font = `bold ${fontSize}px Roboto, Roboto Local`;
341
+ this.ctx.textAlign = 'center';
342
+ this.ctx.textBaseline = 'middle';
343
+ this.ctx.fillText(letter, x + width / 2, y + height / 2);
344
+ }
345
+ }
346
+ getMonomerBackgroundColor(letter) {
347
+ if (this.monomerLib) {
348
+ try {
349
+ const colors = this.monomerLib.getMonomerColors(this.biotype, letter);
350
+ if (colors && colors.backgroundcolor)
351
+ return colors.backgroundcolor;
352
+ }
353
+ catch (e) {
354
+ console.warn('Error getting background color from monomerLib:', e);
355
+ }
356
+ }
357
+ return '#CCCCCC';
358
+ }
359
+ getMonomerTextColor(letter) {
360
+ const backgroundColor = this.getMonomerBackgroundColor(letter);
361
+ try {
362
+ const backgroundColorInt = DG.Color.fromHtml(backgroundColor);
363
+ const contrastColorInt = DG.Color.getContrastColor(backgroundColorInt);
364
+ return DG.Color.toHtml(contrastColorInt);
365
+ }
366
+ catch (e) {
367
+ console.warn('Error calculating contrast color:', e);
368
+ return '#000000';
369
+ }
370
+ }
371
+ }
372
+ /**
373
+ * Track for displaying conservation bars
374
+ */
375
+ export class ConservationTrack extends MSAHeaderTrack {
376
+ constructor(data, height = LAYOUT_CONSTANTS.DEFAULT_TRACK_HEIGHT, colorScheme = 'default', title = 'Conservation') {
377
+ super(height, LAYOUT_CONSTANTS.MIN_TRACK_HEIGHT, title);
378
+ this.data = data;
379
+ this.colorScheme = colorScheme;
380
+ this.visible = data.length > 0;
381
+ }
382
+ updateData(data) {
383
+ this.data = data;
384
+ this.visible = data.length > 0;
385
+ }
9
386
  /**
10
- * Constructor for the MSA Header
11
- * @param {MSAHeaderOptions} options - Configuration options
387
+ * Draw the conservation track
12
388
  */
389
+ draw(x, y, width, height, windowStart, positionWidth, totalPositions, currentPosition) {
390
+ if (!this.ctx || !this.visible || this.data.length === 0)
391
+ return;
392
+ const visiblePositionsN = Math.floor(width / positionWidth);
393
+ for (let i = 0; i < visiblePositionsN; i++) {
394
+ const position = windowStart + i;
395
+ if (position > totalPositions)
396
+ break;
397
+ const posX = x + (i * positionWidth);
398
+ const cellWidth = positionWidth;
399
+ const cellCenterX = posX + cellWidth / 2;
400
+ if (position - 1 < this.data.length) {
401
+ this.drawConservationBar(position - 1, posX, cellWidth, cellCenterX, y, height);
402
+ if (position === currentPosition) {
403
+ this.ctx.fillStyle = COLORS.SELECTION_HIGHLIGHT;
404
+ this.ctx.fillRect(posX, y, cellWidth, height);
405
+ }
406
+ }
407
+ }
408
+ }
409
+ drawConservationBar(posIndex, x, cellWidth, cellCenterX, barplotTop, barplotHeight) {
410
+ if (!this.ctx)
411
+ return;
412
+ const conservation = this.data[posIndex];
413
+ const barplotPadding = WEBLOGO_CONSTANTS.PADDING;
414
+ // Draw bar background
415
+ this.ctx.fillStyle = COLORS.BACKGROUND_LIGHT;
416
+ this.ctx.fillRect(x + barplotPadding, barplotTop, cellWidth - barplotPadding * 2, barplotHeight);
417
+ // Determine bar color based on color scheme
418
+ let barColor = '#3CB173';
419
+ if (this.colorScheme === 'default') {
420
+ if (conservation < 0.5)
421
+ barColor = '#E74C3C';
422
+ else if (conservation < 0.75)
423
+ barColor = '#F39C12';
424
+ }
425
+ else if (this.colorScheme === 'rainbow') {
426
+ if (conservation < 0.2)
427
+ barColor = '#E74C3C';
428
+ else if (conservation < 0.4)
429
+ barColor = '#FF7F00';
430
+ else if (conservation < 0.6)
431
+ barColor = '#FFFF00';
432
+ else if (conservation < 0.8)
433
+ barColor = '#00FF00';
434
+ else
435
+ barColor = '#0000FF';
436
+ }
437
+ else if (this.colorScheme === 'heatmap') {
438
+ const intensity = Math.round(conservation * 255);
439
+ barColor = `rgb(255, ${intensity}, ${intensity})`;
440
+ }
441
+ const barHeight = conservation * barplotHeight;
442
+ this.ctx.fillStyle = barColor;
443
+ this.ctx.fillRect(x + barplotPadding, barplotTop + barplotHeight - barHeight, cellWidth - barplotPadding * 2, barHeight);
444
+ // Add outline
445
+ this.ctx.strokeStyle = COLORS.BORDER_LIGHT;
446
+ this.ctx.lineWidth = 1;
447
+ this.ctx.strokeRect(x + barplotPadding, barplotTop, cellWidth - barplotPadding * 2, barplotHeight);
448
+ // Add conservation percentage text if cell is wide enough
449
+ if (cellWidth > 20) {
450
+ this.ctx.fillStyle = COLORS.TITLE_TEXT;
451
+ this.ctx.font = FONTS.CONSERVATION_TEXT;
452
+ this.ctx.textAlign = 'center';
453
+ this.ctx.textBaseline = 'middle';
454
+ this.ctx.fillText(`${Math.round(conservation * 100)}%`, cellCenterX, barplotTop + barplotHeight / 2);
455
+ }
456
+ }
457
+ }
458
+ const LAYOUT_CONSTANTS = {
459
+ TITLE_HEIGHT: 16,
460
+ TRACK_GAP: 4,
461
+ DOTTED_CELL_HEIGHT: 30,
462
+ SLIDER_HEIGHT: 8,
463
+ TOP_PADDING: 5,
464
+ DEFAULT_TRACK_HEIGHT: 45,
465
+ MIN_TRACK_HEIGHT: 35,
466
+ };
467
+ // STRICT HEIGHT THRESHOLDS - All pixel-perfect and deterministic
468
+ const HEIGHT_THRESHOLDS = {
469
+ // Base: just dotted cells + slider
470
+ BASE: LAYOUT_CONSTANTS.DOTTED_CELL_HEIGHT + LAYOUT_CONSTANTS.SLIDER_HEIGHT, // 38px
471
+ // With title but no tracks
472
+ WITH_TITLE: function () {
473
+ return this.BASE + LAYOUT_CONSTANTS.TITLE_HEIGHT + LAYOUT_CONSTANTS.TRACK_GAP; // 58px
474
+ },
475
+ // With title + WebLogo track
476
+ WITH_WEBLOGO: function () {
477
+ return this.WITH_TITLE() + LAYOUT_CONSTANTS.DEFAULT_TRACK_HEIGHT + LAYOUT_CONSTANTS.TRACK_GAP; // 107px
478
+ },
479
+ // With title + WebLogo + Conservation tracks
480
+ WITH_BOTH: function () {
481
+ return this.WITH_WEBLOGO() + LAYOUT_CONSTANTS.DEFAULT_TRACK_HEIGHT + LAYOUT_CONSTANTS.TRACK_GAP; // 156px
482
+ }
483
+ };
484
+ export class MSAScrollingHeader {
13
485
  constructor(options) {
14
486
  this.canvas = null;
15
487
  this.ctx = null;
16
- // Default configuration with required fields
488
+ this.tracks = new Map();
489
+ // Tooltip and hover state
490
+ this.currentHoverPosition = -1;
491
+ this.currentHoverTrack = null;
492
+ this.previousHoverPosition = -1;
493
+ this.previousHoverTrack = null;
494
+ this.previousHoverMonomer = null;
495
+ // Selection state
496
+ this.dataFrame = null;
497
+ this.seqHandler = null;
498
+ this.seqColumn = null;
499
+ this.onSelectionCallback = null;
500
+ // Track button state
501
+ this.trackButtons = [];
502
+ this.userSelectedTracks = null;
17
503
  this.config = {
18
504
  x: options.x || 0,
19
505
  y: options.y || 0,
@@ -22,142 +508,455 @@ export class MSAScrollingHeader {
22
508
  windowStartPosition: options.windowStartPosition || 1,
23
509
  positionWidth: options.positionWidth || 15,
24
510
  totalPositions: options.totalPositions || 5000,
25
- headerHeight: options.headerHeight || 50,
26
- sliderHeight: options.sliderHeight || 8,
511
+ headerHeight: options.headerHeight || HEIGHT_THRESHOLDS.BASE,
512
+ sliderHeight: options.sliderHeight || LAYOUT_CONSTANTS.SLIDER_HEIGHT,
27
513
  currentPosition: options.currentPosition || 1,
28
514
  cellBackground: options.cellBackground !== undefined ? options.cellBackground : true,
29
- sliderColor: options.sliderColor || 'rgba(220, 220, 220, 0.4)',
515
+ sliderColor: options.sliderColor || COLORS.SLIDER_DEFAULT,
30
516
  onPositionChange: options.onPositionChange || ((_, __) => { }),
31
- ...options // Override defaults with any provided options
517
+ onHeaderHeightChange: options.onHeaderHeightChange || (() => { }),
518
+ ...options
32
519
  };
33
520
  this.eventElement = ui.div();
34
521
  this.eventElement.style.position = 'absolute';
35
522
  this.config.canvas.parentElement?.appendChild(this.eventElement);
36
- // Internal state
37
- this.state = {
38
- isDragging: false,
39
- dragStartX: 0
40
- };
523
+ this.state = { isDragging: false, dragStartX: 0 };
524
+ this.setupEventListeners();
41
525
  this.init();
42
526
  }
527
+ determineVisibleTracks() {
528
+ const currentHeight = this.config.headerHeight;
529
+ const webLogoTrack = this.getTrack('weblogo');
530
+ const conservationTrack = this.getTrack('conservation');
531
+ // Reset all tracks
532
+ this.tracks.forEach((track) => {
533
+ track.setVisible(false);
534
+ track.setHeight(LAYOUT_CONSTANTS.DEFAULT_TRACK_HEIGHT);
535
+ });
536
+ // Apply strict thresholds
537
+ if (currentHeight < HEIGHT_THRESHOLDS.WITH_TITLE()) {
538
+ // Below 58px: No tracks, no title
539
+ return;
540
+ }
541
+ if (currentHeight < HEIGHT_THRESHOLDS.WITH_WEBLOGO()) {
542
+ // 58px - 106px: Title only, no tracks
543
+ return;
544
+ }
545
+ if (currentHeight < HEIGHT_THRESHOLDS.WITH_BOTH()) {
546
+ // 107px - 155px: WebLogo only
547
+ if (webLogoTrack) {
548
+ webLogoTrack.setVisible(true);
549
+ // Scale WebLogo with extra space
550
+ const extraSpace = currentHeight - HEIGHT_THRESHOLDS.WITH_WEBLOGO();
551
+ webLogoTrack.setHeight(LAYOUT_CONSTANTS.DEFAULT_TRACK_HEIGHT + extraSpace);
552
+ }
553
+ return;
554
+ }
555
+ // 156px+: Both tracks
556
+ if (webLogoTrack)
557
+ webLogoTrack.setVisible(true);
558
+ if (conservationTrack)
559
+ conservationTrack.setVisible(true);
560
+ // Distribute extra space to WebLogo
561
+ if (webLogoTrack && currentHeight > HEIGHT_THRESHOLDS.WITH_BOTH()) {
562
+ const extraSpace = currentHeight - HEIGHT_THRESHOLDS.WITH_BOTH();
563
+ webLogoTrack.setHeight(LAYOUT_CONSTANTS.DEFAULT_TRACK_HEIGHT + extraSpace);
564
+ }
565
+ // Override with user selections if they exist (but still respect minimum heights)
566
+ if (this.userSelectedTracks) {
567
+ // Force hide tracks that user disabled
568
+ this.tracks.forEach((track, id) => {
569
+ if (!this.userSelectedTracks[id])
570
+ track.setVisible(false);
571
+ });
572
+ // But auto-hide if height is insufficient regardless of user preference
573
+ if (currentHeight < HEIGHT_THRESHOLDS.WITH_WEBLOGO() && webLogoTrack)
574
+ webLogoTrack.setVisible(false);
575
+ if (currentHeight < HEIGHT_THRESHOLDS.WITH_BOTH() && conservationTrack)
576
+ conservationTrack.setVisible(false);
577
+ }
578
+ }
579
+ drawTrackButtons() {
580
+ const buttonWidth = 70;
581
+ // todo: Make sure buttons are not colliding with the header
582
+ if (!this.ctx || this.config.width < buttonWidth * 4)
583
+ return;
584
+ this.trackButtons = [];
585
+ const conservationTrack = this.getTrack('conservation');
586
+ const webLogoTrack = this.getTrack('weblogo');
587
+ const conservationVisible = conservationTrack?.isVisible() ?? false;
588
+ const webLogoVisible = webLogoTrack?.isVisible() ?? false;
589
+ const buttonHeight = 14;
590
+ const buttonGap = 4;
591
+ const rightMargin = 16;
592
+ let buttonX = this.config.width - rightMargin;
593
+ const buttonY = this.config.headerHeight >= HEIGHT_THRESHOLDS.WITH_TITLE() ?
594
+ (LAYOUT_CONSTANTS.TITLE_HEIGHT - buttonHeight) / 2 : // Center in title area
595
+ 2; // Or just 2px from top if no title
596
+ // Show individual track buttons only if not both are visible
597
+ if (!(conservationVisible && webLogoVisible)) {
598
+ if (!conservationVisible && conservationTrack) {
599
+ buttonX -= buttonWidth;
600
+ this.drawTrackButton('conservation', 'Conservation', buttonX, buttonY, buttonWidth, buttonHeight);
601
+ buttonX -= buttonGap;
602
+ }
603
+ if (!webLogoVisible && webLogoTrack) {
604
+ buttonX -= buttonWidth;
605
+ this.drawTrackButton('weblogo', 'WebLogo', buttonX, buttonY, buttonWidth, buttonHeight);
606
+ }
607
+ }
608
+ }
609
+ drawTrackButton(id, label, x, y, width, height, isAutoButton = false) {
610
+ if (!this.ctx)
611
+ return;
612
+ this.trackButtons.push({ id, label, x, y, width, height });
613
+ this.ctx.fillStyle = isAutoButton ? 'rgba(100, 150, 200, 0.8)' : 'rgba(240, 240, 240, 0.8)';
614
+ this.ctx.fillRect(x, y, width, height);
615
+ this.ctx.strokeStyle = isAutoButton ? 'rgba(70, 120, 170, 0.8)' : 'rgba(180, 180, 180, 0.8)';
616
+ this.ctx.lineWidth = 1;
617
+ this.ctx.strokeRect(x, y, width, height);
618
+ this.ctx.fillStyle = isAutoButton ? '#ffffff' : '#666666';
619
+ this.ctx.font = '9px Roboto, Roboto Local';
620
+ this.ctx.textAlign = 'center';
621
+ this.ctx.textBaseline = 'middle';
622
+ this.ctx.fillText(label, x + width / 2, y + height / 2);
623
+ }
624
+ handleTrackButtonClick(x, y) {
625
+ for (const button of this.trackButtons) {
626
+ if (x >= button.x && x <= button.x + button.width &&
627
+ y >= button.y && y <= button.y + button.height) {
628
+ // if (button.id === 'auto')
629
+ // this.resetToAutoMode();
630
+ // else
631
+ // this.snapToTrackHeight(button.id);
632
+ // return true;
633
+ this.snapToTrackHeight(button.id);
634
+ return true;
635
+ }
636
+ }
637
+ return false;
638
+ }
639
+ snapToTrackHeight(trackId) {
640
+ let targetHeight;
641
+ if (trackId === 'weblogo')
642
+ targetHeight = HEIGHT_THRESHOLDS.WITH_WEBLOGO();
643
+ else if (trackId === 'conservation')
644
+ targetHeight = HEIGHT_THRESHOLDS.WITH_BOTH();
645
+ else
646
+ return;
647
+ // Set user preference
648
+ if (!this.userSelectedTracks) {
649
+ this.userSelectedTracks = {};
650
+ this.tracks.forEach((track, id) => {
651
+ this.userSelectedTracks[id] = false; // Start with all hidden
652
+ });
653
+ }
654
+ // Enable the requested track and any dependencies
655
+ if (trackId === 'conservation') {
656
+ this.userSelectedTracks['weblogo'] = true; // Conservation requires WebLogo
657
+ this.userSelectedTracks['conservation'] = true;
658
+ }
659
+ else if (trackId === 'weblogo')
660
+ this.userSelectedTracks['weblogo'] = true;
661
+ // Don't change conservation setting
662
+ // Snap to exact height
663
+ if (this.config.onHeaderHeightChange)
664
+ this.config.onHeaderHeightChange(targetHeight);
665
+ window.requestAnimationFrame(() => this.redraw());
666
+ }
667
+ resetToAutoMode() {
668
+ this.userSelectedTracks = null;
669
+ // Calculate automatic initial height
670
+ const hasMultipleSequences = this.tracks.size > 0;
671
+ const initialHeight = hasMultipleSequences ? HEIGHT_THRESHOLDS.WITH_BOTH() : HEIGHT_THRESHOLDS.BASE;
672
+ if (this.config.onHeaderHeightChange)
673
+ this.config.onHeaderHeightChange(initialHeight);
674
+ window.requestAnimationFrame(() => this.redraw());
675
+ }
43
676
  /**
44
- * Initialize the component
677
+ * Draw the column title (shown when above threshold)
45
678
  */
46
- init() {
47
- // Get canvas and context
48
- this.canvas = this.config.canvas;
49
- if (!this.canvas) {
50
- console.error(`canvas not found.`);
679
+ drawColumnTitle(x, y, width, columnName) {
680
+ if (!this.ctx || !columnName)
51
681
  return;
682
+ if (this.config.headerHeight >= HEIGHT_THRESHOLDS.WITH_TITLE()) {
683
+ this.ctx.fillStyle = 'rgba(255, 255, 255, 0.95)';
684
+ this.ctx.fillRect(x, y, width, LAYOUT_CONSTANTS.TITLE_HEIGHT);
685
+ this.ctx.fillStyle = COLORS.TITLE_TEXT;
686
+ this.ctx.font = FONTS.COLUMN_TITLE;
687
+ this.ctx.textAlign = 'center';
688
+ this.ctx.textBaseline = 'middle';
689
+ this.ctx.fillText(columnName, x + width / 2, y + LAYOUT_CONSTANTS.TITLE_HEIGHT / 2);
52
690
  }
53
- const context = this.canvas.getContext('2d');
54
- if (!context) {
55
- console.error('Failed to get 2D context from canvas');
691
+ }
692
+ clearHoverStates() {
693
+ const hasHoverState = this.previousHoverPosition !== -1 ||
694
+ this.previousHoverTrack !== null ||
695
+ this.previousHoverMonomer !== null;
696
+ if (hasHoverState) {
697
+ this.previousHoverPosition = -1;
698
+ this.previousHoverTrack = null;
699
+ this.previousHoverMonomer = null;
700
+ this.tracks.forEach((track) => {
701
+ if (track instanceof WebLogoTrack)
702
+ track.setHovered(-1, null);
703
+ });
704
+ window.requestAnimationFrame(() => this.redraw());
705
+ }
706
+ }
707
+ redraw() {
708
+ this.draw(this.config.x, this.config.y, this.config.width, this.config.height, this.config.currentPosition, this.config.windowStartPosition, { preventDefault: () => { } }, this.seqColumn?.name);
709
+ }
710
+ setSelectionData(dataFrame, seqColumn, seqHandler, callback) {
711
+ this.dataFrame = dataFrame;
712
+ this.seqColumn = seqColumn;
713
+ this.seqHandler = seqHandler;
714
+ this.onSelectionCallback = callback || null;
715
+ }
716
+ setupTooltipHandling() {
717
+ this.eventElement.addEventListener('mousemove', this.handleTooltipMouseMove.bind(this));
718
+ this.eventElement.addEventListener('mouseleave', this.handleTooltipMouseLeave.bind(this));
719
+ }
720
+ handleTooltipMouseMove(e) {
721
+ if (!this.isValid)
722
+ return;
723
+ const { x, y } = this.getCoords(e);
724
+ const cellWidth = this.config.positionWidth;
725
+ const hoveredCellIndex = Math.floor(x / cellWidth);
726
+ const windowStart = this.config.windowStartPosition;
727
+ const hoveredPosition = windowStart + hoveredCellIndex - 1;
728
+ if (hoveredPosition < 0 || hoveredPosition >= this.config.totalPositions) {
729
+ this.hideTooltip();
730
+ this.clearHoverStates();
56
731
  return;
57
732
  }
58
- this.ctx = context;
59
- // Add event listeners
60
- this.eventElement.addEventListener('mousedown', this.handleMouseDown.bind(this));
61
- this.eventElement.addEventListener('mousemove', this.handleMouseMove.bind(this));
62
- this.eventElement.addEventListener('mouseup', this.handleMouseUp.bind(this));
63
- this.eventElement.addEventListener('mouseleave', this.handleMouseUp.bind(this));
64
- this.eventElement.addEventListener('click', this.handleClick.bind(this));
65
- this.eventElement.addEventListener('wheel', this.handleMouseWheel.bind(this));
66
- window.addEventListener('keydown', this.handleKeyDown.bind(this));
733
+ const titleHeight = this.config.headerHeight >= HEIGHT_THRESHOLDS.WITH_TITLE() ? LAYOUT_CONSTANTS.TITLE_HEIGHT : 0;
734
+ const sliderTop = this.config.headerHeight - LAYOUT_CONSTANTS.SLIDER_HEIGHT;
735
+ const dottedCellsTop = sliderTop - LAYOUT_CONSTANTS.DOTTED_CELL_HEIGHT;
736
+ const tracksEndY = dottedCellsTop - LAYOUT_CONSTANTS.TRACK_GAP;
737
+ if (y >= dottedCellsTop || y < titleHeight) {
738
+ this.hideTooltip();
739
+ this.clearHoverStates();
740
+ return;
741
+ }
742
+ // Find hovered track working backwards from dotted cells
743
+ let hoveredTrackId = null;
744
+ let trackRelativeY = 0;
745
+ const visibleTracks = [];
746
+ const webLogoTrack = this.getTrack('weblogo');
747
+ if (webLogoTrack && webLogoTrack.isVisible())
748
+ visibleTracks.push({ id: 'weblogo', track: webLogoTrack });
749
+ const conservationTrack = this.getTrack('conservation');
750
+ if (conservationTrack && conservationTrack.isVisible())
751
+ visibleTracks.push({ id: 'conservation', track: conservationTrack });
752
+ let currentY = tracksEndY;
753
+ for (const { id, track } of visibleTracks) {
754
+ const trackHeight = track.getHeight();
755
+ const trackStartY = currentY - trackHeight;
756
+ if (y >= trackStartY && y < currentY) {
757
+ hoveredTrackId = id;
758
+ trackRelativeY = y - trackStartY;
759
+ break;
760
+ }
761
+ currentY = trackStartY - LAYOUT_CONSTANTS.TRACK_GAP;
762
+ }
763
+ // Check for hovered monomer
764
+ let currentHoverMonomer = null;
765
+ if (hoveredTrackId) {
766
+ const track = this.tracks.get(hoveredTrackId);
767
+ if (track)
768
+ currentHoverMonomer = track.getMonomerAt(x, trackRelativeY, hoveredPosition);
769
+ }
770
+ // Update hover state if changed
771
+ const hoverStateChanged = (this.previousHoverPosition !== hoveredPosition ||
772
+ this.previousHoverTrack !== hoveredTrackId ||
773
+ this.previousHoverMonomer !== currentHoverMonomer);
774
+ if (hoverStateChanged) {
775
+ this.previousHoverPosition = hoveredPosition;
776
+ this.previousHoverTrack = hoveredTrackId;
777
+ this.previousHoverMonomer = currentHoverMonomer;
778
+ if (hoveredTrackId) {
779
+ const track = this.tracks.get(hoveredTrackId);
780
+ if (track instanceof WebLogoTrack) {
781
+ track.setHovered(hoveredPosition, currentHoverMonomer);
782
+ window.requestAnimationFrame(() => this.redraw());
783
+ }
784
+ }
785
+ }
786
+ // Handle tooltips
787
+ if (hoveredPosition !== this.currentHoverPosition || hoveredTrackId !== this.currentHoverTrack) {
788
+ this.currentHoverPosition = hoveredPosition;
789
+ this.currentHoverTrack = hoveredTrackId;
790
+ if (hoveredTrackId) {
791
+ const track = this.tracks.get(hoveredTrackId);
792
+ if (track) {
793
+ const tooltipContent = track.getTooltipContent(hoveredPosition);
794
+ if (tooltipContent) {
795
+ ui.tooltip.show(tooltipContent, e.clientX + 16, e.clientY + 16);
796
+ return;
797
+ }
798
+ }
799
+ }
800
+ }
801
+ if (!hoveredTrackId || !currentHoverMonomer) {
802
+ this.hideTooltip();
803
+ this.clearHoverStates();
804
+ }
67
805
  }
68
- get isValid() {
69
- return !!this.canvas && !!this.ctx && this.config.height >= this.config.headerHeight;
806
+ handleTooltipMouseLeave() {
807
+ this.hideTooltip();
808
+ this.clearHoverStates();
809
+ window.requestAnimationFrame(() => this.draw(this.config.x, this.config.y, this.config.width, this.config.height, this.config.currentPosition, this.config.windowStartPosition, { preventDefault: () => { } }, this.seqColumn?.name));
810
+ }
811
+ hideTooltip() {
812
+ this.currentHoverPosition = -1;
813
+ this.currentHoverTrack = null;
814
+ ui.tooltip.hide();
70
815
  }
71
- draw(x, y, w, h, currentPos, scrollerStart, preventable) {
72
- // soft internal update
73
- if (this.config.x != x || this.config.y != y || this.config.width != w || this.config.height != h || this.config.currentPosition != currentPos || this.config.windowStartPosition != scrollerStart)
74
- Object.assign(this.config, { x, y, width: w, height: h, currentPosition: currentPos, windowStartPosition: scrollerStart });
816
+ draw(x, y, w, h, currentPos, scrollerStart, preventable, columnName) {
817
+ Object.assign(this.config, {
818
+ x, y, width: w, height: h,
819
+ currentPosition: currentPos,
820
+ windowStartPosition: scrollerStart
821
+ });
75
822
  if (!this.isValid) {
76
823
  this.eventElement.style.display = 'none';
77
824
  return;
78
825
  }
79
826
  this.ctx.save();
80
- // Clear canvas
81
827
  this.ctx.clearRect(x, y, w, h);
82
828
  this.ctx.translate(x, y);
83
829
  this.ctx.rect(0, 0, w, h);
84
830
  this.ctx.clip();
85
- // Calculate dimensions
86
- const canvasWidth = w;
87
- const canvasHeight = h;
88
- const topPadding = 5;
89
- const posIndexTop = topPadding;
90
- const sliderTop = this.config.headerHeight - this.config.sliderHeight;
91
- // Draw the full sequence slider bar (very subtle gray bar)
92
- this.ctx.fillStyle = this.config.sliderColor;
93
- this.ctx.fillRect(0, sliderTop, canvasWidth, this.config.sliderHeight);
94
- const visiblePositionsN = Math.floor(this.config.width / this.config.positionWidth);
95
- const windowStart = Math.max(1, this.config.windowStartPosition);
96
- // Calculate slider position on the bar
97
- const totalSliderRange = this.config.totalPositions - visiblePositionsN;
98
- const sliderStartPX = totalSliderRange <= 0 ? 0 :
99
- windowStart / (totalSliderRange) * (canvasWidth - this.sliderWidth);
100
- const sliderLengthPX = totalSliderRange <= 0 ? canvasWidth :
101
- this.sliderWidth;
102
- // Draw slider window (darker rectangle)
103
- this.ctx.fillStyle = 'rgba(150, 150, 150, 0.5)';
104
- this.ctx.fillRect(sliderStartPX, sliderTop, sliderLengthPX, this.config.sliderHeight);
831
+ this.determineVisibleTracks();
832
+ const showTitle = this.config.headerHeight >= HEIGHT_THRESHOLDS.WITH_TITLE();
833
+ const titleHeight = showTitle ? LAYOUT_CONSTANTS.TITLE_HEIGHT : 0;
834
+ if (columnName && showTitle)
835
+ this.drawColumnTitle(0, 0, w, columnName);
836
+ const sliderTop = h - LAYOUT_CONSTANTS.SLIDER_HEIGHT;
837
+ const dottedCellsTop = sliderTop - LAYOUT_CONSTANTS.DOTTED_CELL_HEIGHT;
838
+ const tracksEndY = dottedCellsTop - LAYOUT_CONSTANTS.TRACK_GAP;
839
+ const visibleTrackPositions = [];
840
+ // Draw tracks working backwards from dotted cells
841
+ const visibleTracks = [];
842
+ const webLogoTrack = this.getTrack('weblogo');
843
+ if (webLogoTrack && webLogoTrack.isVisible())
844
+ visibleTracks.push({ id: 'weblogo', track: webLogoTrack });
845
+ const conservationTrack = this.getTrack('conservation');
846
+ if (conservationTrack && conservationTrack.isVisible())
847
+ visibleTracks.push({ id: 'conservation', track: conservationTrack });
848
+ let currentY = tracksEndY;
849
+ for (const { track } of visibleTracks) {
850
+ const trackHeight = track.getHeight();
851
+ const trackStartY = currentY - trackHeight;
852
+ track.draw(0, trackStartY, w, trackHeight, this.config.windowStartPosition, this.config.positionWidth, this.config.totalPositions, this.config.currentPosition);
853
+ visibleTrackPositions.unshift({ y: trackStartY, height: trackHeight });
854
+ currentY = trackStartY - LAYOUT_CONSTANTS.TRACK_GAP;
855
+ }
856
+ // Draw dotted cells
857
+ this.drawDottedCells(0, dottedCellsTop, w, LAYOUT_CONSTANTS.DOTTED_CELL_HEIGHT, sliderTop);
858
+ visibleTrackPositions.push({ y: dottedCellsTop, height: LAYOUT_CONSTANTS.DOTTED_CELL_HEIGHT });
859
+ // Draw connection lines for selected position
105
860
  if (this.config.currentPosition >= 1 && this.config.currentPosition <= this.config.totalPositions) {
106
- // Calculate the position of the current selection as a proportion of total sequence
107
- const currentPositionRatio = (this.config.currentPosition - 1) / (this.config.totalPositions - 1);
108
- const notchX = Math.round(currentPositionRatio * canvasWidth);
109
- // Draw a 3px thick vertical green marker at this position
110
- this.ctx.fillStyle = '#3CB173'; // Same green as the cell highlight
111
- this.ctx.fillRect(notchX - 1, // Center the 3px marker on the calculated position
112
- sliderTop - 2, // Extend the marker 2px above the scrollbar
113
- 3, // 3px width as requested
114
- this.config.sliderHeight + 4 // Make the marker taller than the scrollbar
115
- );
116
- }
117
- // Draw position marks and indices
861
+ const cellWidth = this.config.positionWidth;
862
+ const position = this.config.currentPosition;
863
+ const windowStart = this.config.windowStartPosition;
864
+ const visibleIndex = position - windowStart;
865
+ if (visibleIndex >= 0 && visibleIndex < Math.floor(w / cellWidth)) {
866
+ const posX = visibleIndex * cellWidth;
867
+ const cellCenterX = posX + cellWidth / 2;
868
+ for (let i = 0; i < visibleTrackPositions.length - 1; i++) {
869
+ const upperTrack = visibleTrackPositions[i];
870
+ const lowerTrack = visibleTrackPositions[i + 1];
871
+ this.ctx.strokeStyle = COLORS.SELECTION_CONNECTION;
872
+ this.ctx.lineWidth = 1;
873
+ this.ctx.beginPath();
874
+ this.ctx.moveTo(cellCenterX, upperTrack.y + upperTrack.height);
875
+ this.ctx.lineTo(cellCenterX, lowerTrack.y);
876
+ this.ctx.stroke();
877
+ }
878
+ }
879
+ }
880
+ this.drawTrackButtons();
881
+ this.ctx.restore();
882
+ preventable.preventDefault();
883
+ this.setupEventElement();
884
+ }
885
+ /**
886
+ * Draw the dotted cells area with position markers
887
+ */
888
+ drawDottedCells(x, y, width, height, sliderTop) {
889
+ if (!this.ctx)
890
+ return;
891
+ const totalPositions = this.config.totalPositions;
892
+ const positionWidth = this.config.positionWidth;
893
+ const currentPosition = this.config.currentPosition;
894
+ const windowStart = this.config.windowStartPosition;
895
+ const visiblePositionsN = Math.floor(width / positionWidth);
896
+ const posIndexTop = y + LAYOUT_CONSTANTS.TOP_PADDING;
897
+ this.drawSlider(x, sliderTop, width);
118
898
  for (let i = 0; i < visiblePositionsN; i++) {
119
899
  const position = windowStart + i;
120
- if (position > this.config.totalPositions)
900
+ if (position > totalPositions)
121
901
  break;
122
- const x = i * this.config.positionWidth;
123
- const cellWidth = this.config.positionWidth;
124
- const cellCenterX = x + cellWidth / 2;
125
- // Draw cell background for monospace appearance
902
+ const posX = x + (i * positionWidth);
903
+ const cellWidth = positionWidth;
904
+ const cellCenterX = posX + cellWidth / 2;
126
905
  if (this.config.cellBackground) {
127
- // Very light alternating cell background
128
906
  this.ctx.fillStyle = i % 2 === 0 ? 'rgba(248, 248, 248, 0.3)' : 'rgba(242, 242, 242, 0.2)';
129
- this.ctx.fillRect(x, posIndexTop, cellWidth, Math.min(this.config.headerHeight - topPadding, canvasHeight - posIndexTop) - this.config.sliderHeight);
130
- // Cell borders - very light vertical lines
907
+ this.ctx.fillRect(posX, y, cellWidth, height);
131
908
  this.ctx.strokeStyle = 'rgba(220, 220, 220, 0.7)';
132
909
  this.ctx.beginPath();
133
- this.ctx.moveTo(x, posIndexTop);
134
- this.ctx.lineTo(x, Math.min(this.config.headerHeight, canvasHeight) - this.config.sliderHeight);
910
+ this.ctx.moveTo(posX, y);
911
+ this.ctx.lineTo(posX, sliderTop);
135
912
  this.ctx.stroke();
136
913
  }
137
- // Draw position dot for every position - centered in cell
138
- this.ctx.fillStyle = '#999999';
914
+ // Draw position dot
915
+ this.ctx.fillStyle = COLORS.POSITION_DOT;
139
916
  this.ctx.beginPath();
140
- this.ctx.arc(cellCenterX, posIndexTop * 2, 1, 0, Math.PI * 2);
917
+ this.ctx.arc(cellCenterX, posIndexTop + 5, 1, 0, Math.PI * 2);
141
918
  this.ctx.fill();
142
- // Draw position number for every 10th position, on the current position and also make sure that the number is not obstructed by some other numbers
143
- if (position === this.config.currentPosition || ((position === 1 || position % 10 === 0) && Math.abs(position - this.config.currentPosition) > 1)) {
144
- this.ctx.fillStyle = '#333333';
145
- this.ctx.font = '12px monospace';
919
+ // Draw position number
920
+ if (position === currentPosition || ((position === 1 || position % 10 === 0) &&
921
+ Math.abs(position - currentPosition) > 1)) {
922
+ this.ctx.fillStyle = COLORS.TITLE_TEXT;
923
+ this.ctx.font = FONTS.POSITION_LABELS;
146
924
  this.ctx.textAlign = 'center';
147
925
  this.ctx.textBaseline = 'middle';
148
- this.ctx.fillText(position.toString(), cellCenterX, posIndexTop * 4);
926
+ this.ctx.fillText(position.toString(), cellCenterX, posIndexTop + 15);
149
927
  }
150
- // Highlight current selected position with square marker
151
- // if current is outside the range, draw it at the end
152
- if (position === this.config.currentPosition) {
153
- // Draw filled background with 50% opacity
154
- this.ctx.fillStyle = 'rgba(60, 177, 115, 0.2)';
155
- this.ctx.fillRect(x, 0, cellWidth, sliderTop);
928
+ // Highlight current selected position
929
+ if (position === currentPosition) {
930
+ this.ctx.fillStyle = COLORS.SELECTION_STRONG;
931
+ this.ctx.fillRect(posX, y, cellWidth, height);
156
932
  }
157
933
  }
158
- this.ctx.restore();
159
- // Prevent default behavior if in header area
160
- preventable.preventDefault();
934
+ }
935
+ /**
936
+ * Draw the position slider
937
+ */
938
+ drawSlider(x, sliderTop, width) {
939
+ if (!this.ctx)
940
+ return;
941
+ this.ctx.fillStyle = this.config.sliderColor;
942
+ this.ctx.fillRect(x, sliderTop, width, LAYOUT_CONSTANTS.SLIDER_HEIGHT);
943
+ const visiblePositionsN = Math.floor(width / this.config.positionWidth);
944
+ const windowStart = this.config.windowStartPosition;
945
+ const totalSliderRange = this.config.totalPositions - visiblePositionsN;
946
+ const sliderWidth = this.sliderWidth;
947
+ const sliderStartPX = totalSliderRange <= 0 ? 0 :
948
+ (windowStart - 1) / totalSliderRange * (width - sliderWidth);
949
+ const sliderLengthPX = totalSliderRange <= 0 ? width : sliderWidth;
950
+ this.ctx.fillStyle = COLORS.SLIDER_WINDOW;
951
+ this.ctx.fillRect(x + sliderStartPX, sliderTop, sliderLengthPX, LAYOUT_CONSTANTS.SLIDER_HEIGHT);
952
+ if (this.config.currentPosition >= 1 && this.config.currentPosition <= this.config.totalPositions) {
953
+ const currentPositionRatio = (this.config.currentPosition - 1) / (this.config.totalPositions - 1);
954
+ const notchX = Math.round(currentPositionRatio * width);
955
+ this.ctx.fillStyle = COLORS.SLIDER_MARKER;
956
+ this.ctx.fillRect(x + notchX - 1, sliderTop - 2, 3, LAYOUT_CONSTANTS.SLIDER_HEIGHT + 4);
957
+ }
958
+ }
959
+ setupEventElement() {
161
960
  this.eventElement.style.display = 'block';
162
961
  this.eventElement.style.left = `${this.config.x}px`;
163
962
  this.eventElement.style.top = `${this.config.y}px`;
@@ -182,8 +981,8 @@ export class MSAScrollingHeader {
182
981
  }
183
982
  isInSliderArea(e) {
184
983
  const { y } = this.getCoords(e);
185
- const sliderTop = this.config.headerHeight - this.config.sliderHeight;
186
- return y > sliderTop && y < sliderTop + this.config.sliderHeight;
984
+ const sliderTop = this.config.headerHeight - LAYOUT_CONSTANTS.SLIDER_HEIGHT;
985
+ return y > sliderTop && y < sliderTop + LAYOUT_CONSTANTS.SLIDER_HEIGHT;
187
986
  }
188
987
  get sliderWidth() {
189
988
  const pseudoPositionWidth = this.config.width / this.config.totalPositions;
@@ -192,19 +991,138 @@ export class MSAScrollingHeader {
192
991
  }
193
992
  isInSliderDraggableArea(e) {
194
993
  const { x, y } = this.getCoords(e);
195
- const sliderTop = this.config.headerHeight - this.config.sliderHeight;
994
+ const sliderTop = this.config.headerHeight - LAYOUT_CONSTANTS.SLIDER_HEIGHT;
196
995
  const visiblePositionsN = Math.floor(this.config.width / this.config.positionWidth);
197
- const windowStart = Math.max(1, this.config.windowStartPosition);
198
- // Calculate slider position on the bar
996
+ const windowStart = this.config.windowStartPosition;
199
997
  const totalSliderRange = this.config.totalPositions - visiblePositionsN;
200
998
  const sliderStartPX = totalSliderRange <= 0 ? 0 :
201
- windowStart / (totalSliderRange) * (this.config.width - this.sliderWidth);
202
- return y > sliderTop && y < sliderTop + this.config.sliderHeight && x >= sliderStartPX && x < sliderStartPX + this.sliderWidth;
999
+ (windowStart - 1) / totalSliderRange * (this.config.width - this.sliderWidth);
1000
+ return y > sliderTop && y < sliderTop + LAYOUT_CONSTANTS.SLIDER_HEIGHT &&
1001
+ x >= sliderStartPX && x < sliderStartPX + this.sliderWidth;
1002
+ }
1003
+ setupEventListeners() {
1004
+ this.eventElement.addEventListener('mousemove', (e) => {
1005
+ if (!this.isValid)
1006
+ return;
1007
+ if (this.isInSliderDraggableArea(e))
1008
+ this.eventElement.style.cursor = 'grab';
1009
+ else if (this.isInSliderArea(e))
1010
+ this.eventElement.style.cursor = 'pointer';
1011
+ else if (this.isInHeaderArea(e))
1012
+ this.eventElement.style.cursor = 'pointer';
1013
+ else
1014
+ this.eventElement.style.cursor = 'default';
1015
+ });
1016
+ this.eventElement.addEventListener('mousedown', this.handleMouseDown.bind(this));
1017
+ this.eventElement.addEventListener('mousemove', this.handleMouseMove.bind(this));
1018
+ this.eventElement.addEventListener('mouseup', this.handleMouseUp.bind(this));
1019
+ this.eventElement.addEventListener('mouseleave', this.handleMouseUp.bind(this));
1020
+ this.eventElement.addEventListener('click', this.handleSelectionClick.bind(this));
1021
+ this.eventElement.addEventListener('click', this.handleClick.bind(this));
1022
+ this.eventElement.addEventListener('wheel', this.handleMouseWheel.bind(this));
1023
+ window.addEventListener('keydown', this.handleKeyDown.bind(this));
1024
+ }
1025
+ handleSelectionClick(e) {
1026
+ if (!this.isValid || !this.dataFrame || !this.seqColumn || !this.seqHandler)
1027
+ return;
1028
+ const { x, y } = this.getCoords(e);
1029
+ if (this.handleTrackButtonClick(x, y))
1030
+ return;
1031
+ const cellWidth = this.config.positionWidth;
1032
+ const clickedCellIndex = Math.floor(x / cellWidth);
1033
+ const windowStart = this.config.windowStartPosition;
1034
+ const clickedPosition = windowStart + clickedCellIndex - 1;
1035
+ if (clickedPosition < 0 || clickedPosition >= this.config.totalPositions)
1036
+ return;
1037
+ const titleHeight = this.config.headerHeight >= HEIGHT_THRESHOLDS.WITH_TITLE() ? LAYOUT_CONSTANTS.TITLE_HEIGHT : 0;
1038
+ const sliderTop = this.config.headerHeight - LAYOUT_CONSTANTS.SLIDER_HEIGHT;
1039
+ const dottedCellsTop = sliderTop - LAYOUT_CONSTANTS.DOTTED_CELL_HEIGHT;
1040
+ const tracksEndY = dottedCellsTop - LAYOUT_CONSTANTS.TRACK_GAP;
1041
+ if (y >= dottedCellsTop || y < titleHeight)
1042
+ return;
1043
+ // Find clicked track
1044
+ let clickedTrackId = null;
1045
+ let trackRelativeY = 0;
1046
+ const visibleTracks = [];
1047
+ const webLogoTrack = this.getTrack('weblogo');
1048
+ if (webLogoTrack && webLogoTrack.isVisible())
1049
+ visibleTracks.push({ id: 'weblogo', track: webLogoTrack });
1050
+ const conservationTrack = this.getTrack('conservation');
1051
+ if (conservationTrack && conservationTrack.isVisible())
1052
+ visibleTracks.push({ id: 'conservation', track: conservationTrack });
1053
+ let currentY = tracksEndY;
1054
+ for (const { id, track } of visibleTracks) {
1055
+ const trackHeight = track.getHeight();
1056
+ const trackStartY = currentY - trackHeight;
1057
+ if (y >= trackStartY && y < currentY) {
1058
+ clickedTrackId = id;
1059
+ trackRelativeY = y - trackStartY;
1060
+ break;
1061
+ }
1062
+ currentY = trackStartY - LAYOUT_CONSTANTS.TRACK_GAP;
1063
+ }
1064
+ if (clickedTrackId) {
1065
+ const track = this.tracks.get(clickedTrackId);
1066
+ if (track) {
1067
+ const monomer = track.getMonomerAt(x, trackRelativeY, clickedPosition);
1068
+ if (monomer) {
1069
+ if (this.onSelectionCallback) {
1070
+ this.onSelectionCallback(clickedPosition, monomer);
1071
+ return;
1072
+ }
1073
+ this.selectRowsWithMonomerAtPosition(clickedPosition, monomer);
1074
+ e.stopPropagation();
1075
+ e.stopImmediatePropagation();
1076
+ }
1077
+ }
1078
+ }
1079
+ }
1080
+ selectRowsWithMonomerAtPosition(position, monomer) {
1081
+ if (!this.dataFrame || !this.seqHandler)
1082
+ return;
1083
+ try {
1084
+ const selection = this.dataFrame.selection;
1085
+ const monomersAtPositon = this.seqHandler.getMonomersAtPosition(position, true);
1086
+ selection.init((i) => monomersAtPositon[i] === monomer);
1087
+ }
1088
+ catch (error) {
1089
+ console.error('Error selecting rows:', error);
1090
+ }
1091
+ }
1092
+ init() {
1093
+ this.canvas = this.config.canvas;
1094
+ if (!this.canvas) {
1095
+ console.error('Canvas not found');
1096
+ return;
1097
+ }
1098
+ const context = this.canvas.getContext('2d');
1099
+ if (!context) {
1100
+ console.error('Failed to get 2D context from canvas');
1101
+ return;
1102
+ }
1103
+ this.ctx = context;
1104
+ this.tracks.forEach((track) => track.init(context));
1105
+ }
1106
+ addTrack(id, track) {
1107
+ if (this.ctx)
1108
+ track.init(this.ctx);
1109
+ this.tracks.set(id, track);
1110
+ }
1111
+ removeTrack(id) {
1112
+ this.tracks.delete(id);
1113
+ }
1114
+ getTrack(id) {
1115
+ return this.tracks.get(id);
1116
+ }
1117
+ updateTrack(id, updater) {
1118
+ const track = this.getTrack(id);
1119
+ if (track)
1120
+ updater(track);
1121
+ }
1122
+ get isValid() {
1123
+ return !!this.canvas && !!this.ctx &&
1124
+ this.config.height >= HEIGHT_THRESHOLDS.WITH_TITLE();
203
1125
  }
204
- /**
205
- * Handle mouse down (start dragging)
206
- * @param {MouseEvent} e - Mouse event
207
- */
208
1126
  handleMouseDown(e) {
209
1127
  if (!this.isValid)
210
1128
  return;
@@ -225,14 +1143,9 @@ export class MSAScrollingHeader {
225
1143
  e.preventDefault();
226
1144
  e.stopPropagation();
227
1145
  e.stopImmediatePropagation();
228
- // Determine scroll direction and amount
229
- // Use deltaX if available (for horizontal scrolling devices)
230
- // otherwise use deltaY with shift key for horizontal scroll
231
1146
  const delta = e.shiftKey ? Math.sign(e.deltaY) : Math.sign(e.deltaX || e.deltaY);
232
- // Move by 1 position or more for faster scrolling (optional)
233
- const scrollSpeed = e.shiftKey ? 3 : 1; // Faster scrolling with shift
1147
+ const scrollSpeed = e.shiftKey ? 3 : 1;
234
1148
  const newStartPosition = this.config.windowStartPosition + (delta * scrollSpeed);
235
- // Clamp to valid range
236
1149
  const visiblePositions = Math.floor(this.config.width / this.config.positionWidth);
237
1150
  const maxStart = this.config.totalPositions - visiblePositions + 1;
238
1151
  this.config.windowStartPosition = Math.max(1, Math.min(maxStart, newStartPosition));
@@ -240,10 +1153,6 @@ export class MSAScrollingHeader {
240
1153
  this.config.onPositionChange(this.config.currentPosition, this.getWindowRange());
241
1154
  }
242
1155
  }
243
- /**
244
- * Handle mouse move (dragging)
245
- * @param {MouseEvent} e - Mouse event
246
- */
247
1156
  handleMouseMove(e) {
248
1157
  if (!this.state.isDragging || !this.isValid)
249
1158
  return;
@@ -263,13 +1172,11 @@ export class MSAScrollingHeader {
263
1172
  e.preventDefault();
264
1173
  e.stopPropagation();
265
1174
  e.stopImmediatePropagation();
266
- // Determine scroll direction
267
1175
  const delta = e.key === 'ArrowLeft' ? -1 : 1;
268
1176
  const newPosition = Math.min(Math.max(this.config.currentPosition + delta, 1), this.config.totalPositions);
269
1177
  if (newPosition === this.config.currentPosition)
270
1178
  return;
271
1179
  this.config.currentPosition = newPosition;
272
- // make sure that the cureent position is visible
273
1180
  const visiblePositions = Math.floor(this.config.width / this.config.positionWidth);
274
1181
  const start = this.config.windowStartPosition;
275
1182
  const end = start + visiblePositions - 1;
@@ -281,7 +1188,6 @@ export class MSAScrollingHeader {
281
1188
  }
282
1189
  }
283
1190
  else if (e.key === 'Escape') {
284
- // reset the current position
285
1191
  this.config.currentPosition = -2;
286
1192
  e.preventDefault();
287
1193
  e.stopPropagation();
@@ -292,114 +1198,84 @@ export class MSAScrollingHeader {
292
1198
  if (typeof this.config.onPositionChange === 'function')
293
1199
  this.config.onPositionChange(this.config.currentPosition, this.getWindowRange());
294
1200
  }
295
- /**
296
- * Handle mouse up (end dragging)
297
- */
298
1201
  handleMouseUp() {
299
1202
  this.state.isDragging = false;
300
1203
  }
301
- /**
302
- * Handle slider drag
303
- * @param {number} x - X position of mouse
304
- */
305
1204
  handleSliderDrag(x) {
306
1205
  if (!this.isValid)
307
1206
  return;
308
1207
  const sliderWidth = this.sliderWidth;
309
- // Calculate the position based on the drag position
310
1208
  const canvasWidth = this.config.width - sliderWidth;
311
1209
  const normalizedX = Math.max(0, Math.min(this.config.width, x));
312
1210
  const fittedPositions = Math.floor(this.config.width / this.config.positionWidth);
313
1211
  const visiblePositionsN = Math.floor(this.config.width / this.config.positionWidth);
314
- // Calculate slider position on the bar
315
1212
  const totalSliderRange = this.config.totalPositions - visiblePositionsN;
316
- // const sliderStartPX =
317
- // windowStart / (totalSliderRange) * (this.config.width - this.sliderWidth);
318
- // after we normalize the x position of the mouse, this is where the center of the slider should be.
319
1213
  const sliderStartPx = Math.max(0, normalizedX - sliderWidth / 2);
320
- // then we reverse the formula and calculate the new start position
321
1214
  const windowStart = sliderStartPx / (canvasWidth) * (totalSliderRange);
322
- // we add these positions so that it feels like we are grabbing the slider by its center
323
1215
  this.config.windowStartPosition = Math.max(1, Math.min(windowStart, this.config.totalPositions - fittedPositions + 1));
324
- // Call callback if defined
325
1216
  if (typeof this.config.onPositionChange === 'function')
326
1217
  this.config.onPositionChange(this.config.currentPosition, this.getWindowRange());
327
1218
  }
328
1219
  get headerHeight() {
329
1220
  return this.config.headerHeight;
330
1221
  }
331
- /** Soft setting of header hight, without redrawing or update event
332
- * @param {number} value - New header height
333
- */
334
1222
  set headerHeight(value) {
335
1223
  this.config.headerHeight = value;
336
1224
  }
337
- /**
338
- * Handle click on positions
339
- * @param {MouseEvent} e - Mouse event
340
- */
341
1225
  handleClick(e) {
342
1226
  if (!this.isValid)
343
1227
  return;
344
- // Get calculated coordinates
1228
+ const absoluteX = e.clientX - this.canvas.getBoundingClientRect().left;
1229
+ const absoluteY = e.clientY - this.canvas.getBoundingClientRect().top;
345
1230
  const { x, y } = this.getCoords(e);
346
- const sliderTop = this.config.headerHeight - this.config.sliderHeight;
1231
+ if (this.handleTrackButtonClick(x, y)) {
1232
+ e.preventDefault();
1233
+ e.stopPropagation();
1234
+ return;
1235
+ }
1236
+ const sliderTop = this.config.headerHeight - LAYOUT_CONSTANTS.SLIDER_HEIGHT;
347
1237
  if (y < sliderTop && y >= 0) {
348
- // Calculate which position was clicked
349
1238
  const cellWidth = this.config.positionWidth;
350
1239
  const clickedCellIndex = Math.round(x / cellWidth - 0.5);
351
- // Calculate actual position in sequence
352
1240
  const windowStart = this.config.windowStartPosition;
353
1241
  const clickedPosition = windowStart + clickedCellIndex;
354
- // Update current position if valid
355
1242
  if (clickedPosition >= 1 && clickedPosition <= this.config.totalPositions) {
356
1243
  this.config.currentPosition = clickedPosition;
357
- // Call callback if defined
358
1244
  if (typeof this.config.onPositionChange === 'function')
359
1245
  this.config.onPositionChange(this.config.currentPosition, this.getWindowRange());
360
1246
  }
361
1247
  }
362
1248
  }
363
- /**
364
- * Get the current window range
365
- * @return {WindowRange} Object with start and end properties
366
- */
367
1249
  getWindowRange() {
368
1250
  return {
369
1251
  start: this.config.windowStartPosition,
370
1252
  end: Math.min(this.config.totalPositions, this.config.windowStartPosition + Math.floor(this.config.width / this.config.positionWidth))
371
1253
  };
372
1254
  }
373
- /**
374
- * Update configuration
375
- * @param {MSAHeaderOptions} newConfig - New configuration options
376
- */
377
1255
  updateConfig(newConfig) {
378
- // Update config with new values
379
1256
  Object.assign(this.config, newConfig);
380
- // Ensure current position is still valid
381
1257
  this.config.currentPosition = Math.min(this.config.currentPosition, this.config.totalPositions);
382
- // Call callback if defined
383
1258
  if (typeof this.config.onPositionChange === 'function')
384
1259
  this.config.onPositionChange(this.config.currentPosition, this.getWindowRange());
385
1260
  }
386
- /**
387
- * Get current position
388
- * @return {number} Current position
389
- */
390
1261
  getCurrentPosition() {
391
1262
  return this.config.currentPosition;
392
1263
  }
393
- /**
394
- * Set current position
395
- * @param {number} position - New position
396
- */
397
1264
  setCurrentPosition(position) {
398
- // Clamp to valid range
399
1265
  this.config.currentPosition = Math.max(1, Math.min(this.config.totalPositions, position));
400
- // Call callback if defined
401
1266
  if (typeof this.config.onPositionChange === 'function')
402
1267
  this.config.onPositionChange(this.config.currentPosition, this.getWindowRange());
403
1268
  }
1269
+ /**
1270
+ * ADDED: Public methods for external control
1271
+ */
1272
+ getHeightThresholds() {
1273
+ return {
1274
+ BASE: HEIGHT_THRESHOLDS.BASE,
1275
+ WITH_TITLE: HEIGHT_THRESHOLDS.WITH_TITLE(),
1276
+ WITH_WEBLOGO: HEIGHT_THRESHOLDS.WITH_WEBLOGO(),
1277
+ WITH_BOTH: HEIGHT_THRESHOLDS.WITH_BOTH()
1278
+ };
1279
+ }
404
1280
  }
405
1281
  //# sourceMappingURL=sequence-position-scroller.js.map