@datagrok-libraries/bio 5.53.2 → 5.53.4

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