@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.
- package/CHANGELOG.md +5 -0
- package/package.json +1 -1
- package/src/utils/composition-table.d.ts +6 -0
- package/src/utils/composition-table.d.ts.map +1 -0
- package/src/utils/composition-table.js +46 -0
- package/src/utils/composition-table.js.map +1 -0
- 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 +144 -44
- package/src/utils/sequence-position-scroller.d.ts.map +1 -1
- package/src/utils/sequence-position-scroller.js +1034 -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,486 @@
|
|
|
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
|
+
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
|
-
*
|
|
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
|
-
|
|
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 ||
|
|
26
|
-
sliderHeight: options.sliderHeight ||
|
|
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 ||
|
|
502
|
+
sliderColor: options.sliderColor || COLORS.SLIDER_DEFAULT,
|
|
30
503
|
onPositionChange: options.onPositionChange || ((_, __) => { }),
|
|
31
|
-
|
|
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
|
-
|
|
37
|
-
this.
|
|
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
|
-
*
|
|
664
|
+
* Draw the column title (shown when above threshold)
|
|
45
665
|
*/
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const sliderTop =
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
const
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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 >
|
|
888
|
+
if (position > totalPositions)
|
|
121
889
|
break;
|
|
122
|
-
const
|
|
123
|
-
const cellWidth =
|
|
124
|
-
const cellCenterX =
|
|
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(
|
|
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(
|
|
134
|
-
this.ctx.lineTo(
|
|
898
|
+
this.ctx.moveTo(posX, y);
|
|
899
|
+
this.ctx.lineTo(posX, sliderTop);
|
|
135
900
|
this.ctx.stroke();
|
|
136
901
|
}
|
|
137
|
-
// Draw position dot
|
|
138
|
-
this.ctx.fillStyle =
|
|
902
|
+
// Draw position dot
|
|
903
|
+
this.ctx.fillStyle = COLORS.POSITION_DOT;
|
|
139
904
|
this.ctx.beginPath();
|
|
140
|
-
this.ctx.arc(cellCenterX, posIndexTop
|
|
905
|
+
this.ctx.arc(cellCenterX, posIndexTop + 5, 1, 0, Math.PI * 2);
|
|
141
906
|
this.ctx.fill();
|
|
142
|
-
// Draw position number
|
|
143
|
-
if (position ===
|
|
144
|
-
|
|
145
|
-
this.ctx.
|
|
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
|
|
914
|
+
this.ctx.fillText(position.toString(), cellCenterX, posIndexTop + 15);
|
|
149
915
|
}
|
|
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);
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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 -
|
|
186
|
-
return y > sliderTop && y < sliderTop +
|
|
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 -
|
|
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 =
|
|
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 /
|
|
202
|
-
return y > sliderTop && y < sliderTop +
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|