@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.
- package/CHANGELOG.md +5 -0
- package/package.json +1 -1
- package/src/utils/macromolecule/types.d.ts +5 -0
- package/src/utils/macromolecule/types.d.ts.map +1 -1
- package/src/utils/macromolecule/types.js.map +1 -1
- package/src/utils/macromolecule/utils.d.ts +6 -0
- package/src/utils/macromolecule/utils.d.ts.map +1 -1
- package/src/utils/macromolecule/utils.js +34 -0
- package/src/utils/macromolecule/utils.js.map +1 -1
- package/src/utils/sequence-position-scroller.d.ts +140 -44
- package/src/utils/sequence-position-scroller.d.ts.map +1 -1
- package/src/utils/sequence-position-scroller.js +1046 -170
- package/src/utils/sequence-position-scroller.js.map +1 -1
|
@@ -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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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 ||
|
|
26
|
-
sliderHeight: options.sliderHeight ||
|
|
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 ||
|
|
515
|
+
sliderColor: options.sliderColor || COLORS.SLIDER_DEFAULT,
|
|
30
516
|
onPositionChange: options.onPositionChange || ((_, __) => { }),
|
|
31
|
-
|
|
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
|
-
|
|
37
|
-
this.
|
|
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
|
-
*
|
|
677
|
+
* Draw the column title (shown when above threshold)
|
|
45
678
|
*/
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const sliderTop =
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
const
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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 >
|
|
900
|
+
if (position > totalPositions)
|
|
121
901
|
break;
|
|
122
|
-
const
|
|
123
|
-
const cellWidth =
|
|
124
|
-
const cellCenterX =
|
|
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(
|
|
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(
|
|
134
|
-
this.ctx.lineTo(
|
|
910
|
+
this.ctx.moveTo(posX, y);
|
|
911
|
+
this.ctx.lineTo(posX, sliderTop);
|
|
135
912
|
this.ctx.stroke();
|
|
136
913
|
}
|
|
137
|
-
// Draw position dot
|
|
138
|
-
this.ctx.fillStyle =
|
|
914
|
+
// Draw position dot
|
|
915
|
+
this.ctx.fillStyle = COLORS.POSITION_DOT;
|
|
139
916
|
this.ctx.beginPath();
|
|
140
|
-
this.ctx.arc(cellCenterX, posIndexTop
|
|
917
|
+
this.ctx.arc(cellCenterX, posIndexTop + 5, 1, 0, Math.PI * 2);
|
|
141
918
|
this.ctx.fill();
|
|
142
|
-
// Draw position number
|
|
143
|
-
if (position ===
|
|
144
|
-
|
|
145
|
-
this.ctx.
|
|
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
|
|
926
|
+
this.ctx.fillText(position.toString(), cellCenterX, posIndexTop + 15);
|
|
149
927
|
}
|
|
150
|
-
// Highlight current selected position
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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 -
|
|
186
|
-
return y > sliderTop && y < sliderTop +
|
|
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 -
|
|
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 =
|
|
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 /
|
|
202
|
-
return y > sliderTop && y < sliderTop +
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|