varri-js 1.0.0 → 1.0.2
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/CITATION.bib +13 -0
- package/CITATION.cff +33 -0
- package/README.html +4 -4
- package/README.md +113 -41
- package/citation.html +316 -0
- package/doc/vaRRI-UI-example.png +0 -0
- package/docu.free-trailing-ends.md +247 -0
- package/example-data.js +226 -0
- package/index.html +616 -0
- package/index.js +2844 -0
- package/logo/vaRRI.logo.200x200.png +0 -0
- package/logo/vaRRI.logo.40x40.png +0 -0
- package/logo/vaRRI.logo.svg +152 -0
- package/logo/vaRRI.media.png +0 -0
- package/package.json +23 -5
- package/src/README.md +1 -1
- package/style.css +1183 -0
package/index.js
ADDED
|
@@ -0,0 +1,2844 @@
|
|
|
1
|
+
|
|
2
|
+
// -------------------------------------------------------------------------
|
|
3
|
+
// Default data
|
|
4
|
+
// -------------------------------------------------------------------------
|
|
5
|
+
const INITIAL_COLORS = vaRRI.getColors();
|
|
6
|
+
|
|
7
|
+
const DEFAULT_VALUES = {
|
|
8
|
+
// input + rendering
|
|
9
|
+
sequence: '',
|
|
10
|
+
structure: '',
|
|
11
|
+
startIndex1: '1',
|
|
12
|
+
startIndex2: '1',
|
|
13
|
+
rotationSlider: '0',
|
|
14
|
+
cropping: '-1',
|
|
15
|
+
colorSeq1: cssColorToHex(INITIAL_COLORS.sequence1).toString(),
|
|
16
|
+
colorSeq2: cssColorToHex(INITIAL_COLORS.sequence2),
|
|
17
|
+
coloring: 'strand',
|
|
18
|
+
highlighting: 'region',
|
|
19
|
+
backgroundhighlighting: 'basepairs',
|
|
20
|
+
distinctBpTypes: true,
|
|
21
|
+
// profile data
|
|
22
|
+
profileData1: '',
|
|
23
|
+
profileIdxRef1: '1',
|
|
24
|
+
profileColor1: cssColorToHex(INITIAL_COLORS.seq1profileColor),
|
|
25
|
+
profileColorRepresentsOne1: true,
|
|
26
|
+
profileData2: '',
|
|
27
|
+
profileIdxRef2: '1',
|
|
28
|
+
profileColor2: cssColorToHex(INITIAL_COLORS.seq2profileColor),
|
|
29
|
+
profileColorRepresentsOne2: true,
|
|
30
|
+
// visualization
|
|
31
|
+
forceLayout: false,
|
|
32
|
+
forceLayoutFreeTails: false,
|
|
33
|
+
forceLayoutPullCrossing: false,
|
|
34
|
+
forceLayoutLinearStructure: false,
|
|
35
|
+
forceLayoutLinearRRI: false,
|
|
36
|
+
hideFooterAndHeader: false,
|
|
37
|
+
colorRriNodes: cssColorToHex(INITIAL_COLORS.intermolecularHighlight),
|
|
38
|
+
colorRriRegion: cssColorToHex(INITIAL_COLORS.backgroundHighlight),
|
|
39
|
+
colorBasepair: cssColorToHex(INITIAL_COLORS.basepair),
|
|
40
|
+
// subsequence highlighting
|
|
41
|
+
subseqEditId: '',
|
|
42
|
+
subseqSequence: '1',
|
|
43
|
+
subseqRange: '',
|
|
44
|
+
get subseqColor() { return getDefaultSubsequenceHighlightColor(); },
|
|
45
|
+
subseqAlpha: '0.3',
|
|
46
|
+
// region highlighting
|
|
47
|
+
regionEditId: '',
|
|
48
|
+
region1: '',
|
|
49
|
+
region2: '',
|
|
50
|
+
get regionColor() { return getDefaultRegionHighlightColor(); },
|
|
51
|
+
regionAlpha: '0.2',
|
|
52
|
+
// point mutations
|
|
53
|
+
mutationEditId: '',
|
|
54
|
+
mutationSequence: '1',
|
|
55
|
+
mutationPosition: '',
|
|
56
|
+
mutationBase: '',
|
|
57
|
+
get mutationColor() { return getDefaultMutationColor(); },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// -----------------------------------------------------------------------
|
|
61
|
+
// UI-only fields (not to be exported via URL parameters)
|
|
62
|
+
// -----------------------------------------------------------------------
|
|
63
|
+
const UI_ONLY_FIELDS = [
|
|
64
|
+
// fasta input form fields
|
|
65
|
+
'fastaInput',
|
|
66
|
+
'fastaSequence',
|
|
67
|
+
'fastaStructure',
|
|
68
|
+
// region highlight input/update form fields
|
|
69
|
+
'regionEditId',
|
|
70
|
+
'region1',
|
|
71
|
+
'region2',
|
|
72
|
+
'regionColor',
|
|
73
|
+
'regionAlpha',
|
|
74
|
+
// sequence highlight input/update form fields
|
|
75
|
+
'subseqEditId',
|
|
76
|
+
'subseqSequence',
|
|
77
|
+
'subseqRange',
|
|
78
|
+
'subseqColor',
|
|
79
|
+
'subseqAlpha',
|
|
80
|
+
// mutation input/update form fields
|
|
81
|
+
'mutationEditId',
|
|
82
|
+
'mutationSequence',
|
|
83
|
+
'mutationPosition',
|
|
84
|
+
'mutationBase',
|
|
85
|
+
'mutationColor',
|
|
86
|
+
// rotation control & slider
|
|
87
|
+
'rotationSlider'
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
// -------------------------------------------------------------------------
|
|
91
|
+
// Example data
|
|
92
|
+
// -------------------------------------------------------------------------
|
|
93
|
+
const EXAMPLES = window.VARRI_EXAMPLES || {};
|
|
94
|
+
const EXAMPLE_PLACEHOLDER = 'Select an example';
|
|
95
|
+
let selectedExampleKey = null;
|
|
96
|
+
|
|
97
|
+
function renderExampleDropdown() {
|
|
98
|
+
const options = document.getElementById('exampleDropdownOptions');
|
|
99
|
+
if (!options) return;
|
|
100
|
+
|
|
101
|
+
const fragment = document.createDocumentFragment();
|
|
102
|
+
Object.entries(EXAMPLES).forEach(([key, example]) => {
|
|
103
|
+
const button = document.createElement('button');
|
|
104
|
+
button.type = 'button';
|
|
105
|
+
button.className = 'example-option';
|
|
106
|
+
button.dataset.example = key;
|
|
107
|
+
|
|
108
|
+
const name = document.createElement('span');
|
|
109
|
+
name.className = 'example-option-name';
|
|
110
|
+
name.textContent = example.nameShort || example.name || key;
|
|
111
|
+
|
|
112
|
+
const description = document.createElement('span');
|
|
113
|
+
description.className = 'example-option-description';
|
|
114
|
+
description.textContent = example.descriptionShort || example.description;
|
|
115
|
+
|
|
116
|
+
button.append(name, description);
|
|
117
|
+
if (key === selectedExampleKey) {
|
|
118
|
+
button.setAttribute('aria-current', 'true');
|
|
119
|
+
}
|
|
120
|
+
fragment.appendChild(button);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
options.replaceChildren(fragment);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function updateExampleDropdownSelection(key) {
|
|
127
|
+
const example = EXAMPLES[key];
|
|
128
|
+
selectedExampleKey = example ? key : null;
|
|
129
|
+
|
|
130
|
+
const label = document.getElementById('selectedExampleName');
|
|
131
|
+
if (label) label.textContent = example ? (example.nameShort || example.name) : EXAMPLE_PLACEHOLDER;
|
|
132
|
+
|
|
133
|
+
const dropdown = document.getElementById('exampleDropdown');
|
|
134
|
+
if (dropdown) dropdown.open = false;
|
|
135
|
+
|
|
136
|
+
document.querySelectorAll('#exampleDropdownOptions [data-example]').forEach(button => {
|
|
137
|
+
if (button.dataset.example === selectedExampleKey) {
|
|
138
|
+
button.setAttribute('aria-current', 'true');
|
|
139
|
+
} else {
|
|
140
|
+
button.removeAttribute('aria-current');
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const caption = document.getElementById('rendering-caption');
|
|
145
|
+
if (caption) {
|
|
146
|
+
if (selectedExampleKey && example && example.description && example.description.trim() !== '') {
|
|
147
|
+
let captionText = "**Example** *"+example.nameShort+"*: "
|
|
148
|
+
+ example.description;
|
|
149
|
+
caption.innerHTML = marked.parse(captionText);
|
|
150
|
+
caption.style.display = '';
|
|
151
|
+
} else {
|
|
152
|
+
caption.innerText = '';
|
|
153
|
+
caption.style.display = 'none';
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
console.warn('Rendering caption element not found.');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function resetExampleDropdownSelection() {
|
|
161
|
+
updateExampleDropdownSelection(null);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function getExampleUrlParameters(example) {
|
|
165
|
+
const params = new URLSearchParams();
|
|
166
|
+
Object.entries(example.vaRRIParams || {}).forEach(([key, value]) => {
|
|
167
|
+
if (key !== 'showRenderingOnly' && value !== undefined && value !== null) {
|
|
168
|
+
params.set(key, value);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
return params;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function loadExample(key) {
|
|
175
|
+
const example = EXAMPLES[key];
|
|
176
|
+
if (!example || !example.vaRRIParams) return false;
|
|
177
|
+
|
|
178
|
+
// Example links assume a fresh page. Reset all state before applying the
|
|
179
|
+
// same URL parameters here so switching examples cannot leak prior values.
|
|
180
|
+
vaRRI.setColors(INITIAL_COLORS);
|
|
181
|
+
clearAll();
|
|
182
|
+
loadAllUrlParameters(getExampleUrlParameters(example), { revealProfilePanel: false });
|
|
183
|
+
enableForceLayoutForSelectedLinearOptions();
|
|
184
|
+
syncAnimationDependentControls();
|
|
185
|
+
|
|
186
|
+
renderSubseqHighlightList();
|
|
187
|
+
renderRegionList();
|
|
188
|
+
renderMutationList();
|
|
189
|
+
resetFastaForm();
|
|
190
|
+
resetSubseqForm();
|
|
191
|
+
resetRegionForm();
|
|
192
|
+
resetMutationForm();
|
|
193
|
+
|
|
194
|
+
updateExampleDropdownSelection(key);
|
|
195
|
+
runVisualization();
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// -------------------------------------------------------------------------
|
|
200
|
+
// Colour helpers
|
|
201
|
+
// -------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
function getCssColorChannels(css) {
|
|
204
|
+
const canvas = document.createElement('canvas');
|
|
205
|
+
canvas.width = canvas.height = 1;
|
|
206
|
+
const ctx = canvas.getContext('2d');
|
|
207
|
+
ctx.fillStyle = css;
|
|
208
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
209
|
+
return ctx.getImageData(0, 0, 1, 1).data;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Convert a CSS color to a lowercase six-digit hexadecimal value. */
|
|
213
|
+
function cssColorToHex(css) {
|
|
214
|
+
const [r, g, b] = getCssColorChannels(css);
|
|
215
|
+
return '#' + [r, g, b].map(value => value.toString(16).padStart(2, '0')).join('');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Convert a CSS color and opacity to an rgba-compatible CSS value. */
|
|
219
|
+
function cssColorToRGB(css, alpha = 1) {
|
|
220
|
+
const [r, g, b] = getCssColorChannels(css);
|
|
221
|
+
return 'rgb(' + [r, g, b, alpha].join(',') + ')';
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function getProfileAccessibilityColors() {
|
|
225
|
+
const defaults = vaRRI.getColors();
|
|
226
|
+
return {
|
|
227
|
+
sequence1: document.getElementById('profileColor1')?.value || cssColorToHex(defaults.seq1profileColor),
|
|
228
|
+
sequence2: document.getElementById('profileColor2')?.value || cssColorToHex(defaults.seq2profileColor),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function getProfileAccessibilityMode() {
|
|
233
|
+
return {
|
|
234
|
+
sequence1RepresentsOne: !!document.getElementById('profileColorRepresentsOne1')?.checked,
|
|
235
|
+
sequence2RepresentsOne: !!document.getElementById('profileColorRepresentsOne2')?.checked,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function applyColors() {
|
|
240
|
+
vaRRI.setColors({
|
|
241
|
+
sequence1: document.getElementById('colorSeq1').value,
|
|
242
|
+
sequence2: document.getElementById('colorSeq2').value,
|
|
243
|
+
intermolecularHighlight: document.getElementById('colorRriNodes').value,
|
|
244
|
+
backgroundHighlight: document.getElementById('colorRriRegion').value,
|
|
245
|
+
subsequenceHighlight: document.getElementById('subseqColor').value,
|
|
246
|
+
basepair: document.getElementById('colorBasepair').value,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// -------------------------------------------------------------------------
|
|
251
|
+
// Main visualisation
|
|
252
|
+
// -------------------------------------------------------------------------
|
|
253
|
+
let currentContainerId = 'rendering-canvas';
|
|
254
|
+
let committedRotationDeg = 0;
|
|
255
|
+
let autoVisualizationTimeoutId = null;
|
|
256
|
+
let latestVisualizationRunId = 0;
|
|
257
|
+
const skipNextCommittedFieldChange = new WeakMap();
|
|
258
|
+
let defaultSubsequenceHighlightColor = null;
|
|
259
|
+
let defaultRegionHighlightColor = null;
|
|
260
|
+
let defaultMutationColor = null;
|
|
261
|
+
|
|
262
|
+
function getDefaultSubsequenceHighlightColor() {
|
|
263
|
+
if (defaultSubsequenceHighlightColor !== null) return defaultSubsequenceHighlightColor;
|
|
264
|
+
defaultSubsequenceHighlightColor = cssColorToHex(vaRRI.getColors().subsequenceHighlight);
|
|
265
|
+
return defaultSubsequenceHighlightColor;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function getDefaultRegionHighlightColor() {
|
|
269
|
+
if (defaultRegionHighlightColor !== null) return defaultRegionHighlightColor;
|
|
270
|
+
defaultRegionHighlightColor = cssColorToHex(vaRRI.getColors().backgroundHighlight);
|
|
271
|
+
return defaultRegionHighlightColor;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function getDefaultMutationColor() {
|
|
275
|
+
if (defaultMutationColor !== null) return defaultMutationColor;
|
|
276
|
+
defaultMutationColor = cssColorToHex(vaRRI.getColors().mutationColor);
|
|
277
|
+
return defaultMutationColor;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function showMsg(text, type) {
|
|
281
|
+
const el = document.getElementById('msg');
|
|
282
|
+
el.className = type;
|
|
283
|
+
el.textContent = text;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function clearMsg() {
|
|
287
|
+
const el = document.getElementById('msg');
|
|
288
|
+
el.className = '';
|
|
289
|
+
el.style.display = 'none';
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function clearAll() {
|
|
293
|
+
resetExampleDropdownSelection();
|
|
294
|
+
resetFormDefaultValue(Object.keys(DEFAULT_VALUES));
|
|
295
|
+
vaRRI.clearSubsequenceHighlights();
|
|
296
|
+
vaRRI.clearRegionHighlights();
|
|
297
|
+
vaRRI.clearPointMutations();
|
|
298
|
+
document.getElementById('sequence').dispatchEvent(new Event('input', { bubbles: true }));
|
|
299
|
+
document.getElementById('structure').dispatchEvent(new Event('input', { bubbles: true }));
|
|
300
|
+
resetSubseqForm();
|
|
301
|
+
resetRegionForm();
|
|
302
|
+
resetProfileForm();
|
|
303
|
+
resetMutationForm();
|
|
304
|
+
renderSubseqHighlightList();
|
|
305
|
+
renderRegionList();
|
|
306
|
+
renderMutationList();
|
|
307
|
+
const container = document.getElementById(currentContainerId);
|
|
308
|
+
container.innerHTML = '';
|
|
309
|
+
container.style.visibility = '';
|
|
310
|
+
resetCroppingControl();
|
|
311
|
+
resetRotationControl();
|
|
312
|
+
resetForceLayoutControl();
|
|
313
|
+
clearMsg();
|
|
314
|
+
clearAllFieldErrors();
|
|
315
|
+
// hide "Full Page" button if present
|
|
316
|
+
const openVarriBtn = document.getElementById('openVarriBtn');
|
|
317
|
+
if (openVarriBtn) openVarriBtn.style.display = 'none';
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function resetForceLayoutControl() {
|
|
321
|
+
resetFormDefaultValue([
|
|
322
|
+
'forceLayout',
|
|
323
|
+
'forceLayoutFreeTails',
|
|
324
|
+
'forceLayoutPullCrossing',
|
|
325
|
+
'forceLayoutLinearStructure',
|
|
326
|
+
'forceLayoutLinearRRI'
|
|
327
|
+
]);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function resetCroppingControl() {
|
|
331
|
+
resetFormDefaultValue(['cropping']);
|
|
332
|
+
const croppingValueEl = document.getElementById('cropping-value');
|
|
333
|
+
if (croppingValueEl) croppingValueEl.textContent = '-1';
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function updateRotationLabel(degValue) {
|
|
337
|
+
const deg = Number.parseInt(degValue, 10) || 0;
|
|
338
|
+
const labelEl = document.getElementById('rotation');
|
|
339
|
+
if (labelEl) {
|
|
340
|
+
labelEl.textContent = `${deg}°`;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function resetRotationControl() {
|
|
345
|
+
committedRotationDeg = 0;
|
|
346
|
+
resetFormDefaultValue(['rotationSlider']);
|
|
347
|
+
updateRotationLabel(committedRotationDeg);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function applySliderRotation() {
|
|
351
|
+
const slider = document.getElementById('rotationSlider');
|
|
352
|
+
if (!slider) return;
|
|
353
|
+
|
|
354
|
+
const container = document.getElementById(currentContainerId);
|
|
355
|
+
if (!container || !container.querySelector('svg')) return;
|
|
356
|
+
|
|
357
|
+
const sliderDelta = Number(slider.value);
|
|
358
|
+
const rotValue = vaRRI.rotateVisualization(
|
|
359
|
+
currentContainerId,
|
|
360
|
+
committedRotationDeg + sliderDelta,
|
|
361
|
+
{ mode: 'absolute' }
|
|
362
|
+
);
|
|
363
|
+
updateRotationLabel(rotValue);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function commitSliderRotation() {
|
|
367
|
+
const slider = document.getElementById('rotationSlider');
|
|
368
|
+
if (!slider) return;
|
|
369
|
+
|
|
370
|
+
const sliderDelta = Number(slider.value);
|
|
371
|
+
committedRotationDeg = vaRRI.normaliseRotationDegrees(committedRotationDeg + sliderDelta);
|
|
372
|
+
slider.value = '0';
|
|
373
|
+
|
|
374
|
+
const container = document.getElementById(currentContainerId);
|
|
375
|
+
if (!container || !container.querySelector('svg')) return;
|
|
376
|
+
|
|
377
|
+
const rotValue = vaRRI.rotateVisualization(currentContainerId, committedRotationDeg, { mode: 'absolute' });
|
|
378
|
+
updateRotationLabel(rotValue);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// -------------------------------------------------------------------------
|
|
382
|
+
// Per-field validation helpers
|
|
383
|
+
// -------------------------------------------------------------------------
|
|
384
|
+
function setFieldError(fieldId, msg) {
|
|
385
|
+
const el = document.getElementById(fieldId);
|
|
386
|
+
if (!el) return;
|
|
387
|
+
const wrap = el.closest('.input-wrap');
|
|
388
|
+
if (wrap) {
|
|
389
|
+
wrap.classList.add('has-error');
|
|
390
|
+
const tooltip = wrap.querySelector('.field-tooltip');
|
|
391
|
+
if (tooltip) tooltip.textContent = msg;
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const inlineRow = el.closest('.start-index-label-row');
|
|
396
|
+
if (!inlineRow) return;
|
|
397
|
+
inlineRow.classList.add('has-error');
|
|
398
|
+
el.style.borderColor = '#e74c3c';
|
|
399
|
+
el.style.background = '#fff5f5';
|
|
400
|
+
const tooltip = inlineRow.querySelector('.field-tooltip');
|
|
401
|
+
if (tooltip) {
|
|
402
|
+
tooltip.textContent = msg;
|
|
403
|
+
tooltip.style.display = 'block';
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function clearFieldError(fieldId) {
|
|
408
|
+
const el = document.getElementById(fieldId);
|
|
409
|
+
if (!el) return;
|
|
410
|
+
const wrap = el.closest('.input-wrap');
|
|
411
|
+
if (wrap) {
|
|
412
|
+
wrap.classList.remove('has-error');
|
|
413
|
+
const tooltip = wrap.querySelector('.field-tooltip');
|
|
414
|
+
if (tooltip) tooltip.textContent = '';
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const inlineRow = el.closest('.start-index-label-row');
|
|
419
|
+
if (!inlineRow) return;
|
|
420
|
+
inlineRow.classList.remove('has-error');
|
|
421
|
+
el.style.borderColor = '';
|
|
422
|
+
el.style.background = '';
|
|
423
|
+
const tooltip = inlineRow.querySelector('.field-tooltip');
|
|
424
|
+
if (tooltip) {
|
|
425
|
+
tooltip.textContent = '';
|
|
426
|
+
tooltip.style.display = '';
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function clearFieldErrors(fieldIds) {
|
|
431
|
+
fieldIds.forEach(clearFieldError);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function resetFormDefaultValue(fieldIds) {
|
|
435
|
+
if (!fieldIds) return;
|
|
436
|
+
// list of UI-control fields to ignore
|
|
437
|
+
const ignoreFields = new Set(['hideFooterAndHeader']);
|
|
438
|
+
|
|
439
|
+
const ids = Array.isArray(fieldIds) ? fieldIds : [fieldIds];
|
|
440
|
+
for (const fieldId of ids) {
|
|
441
|
+
// skip any fields that are in the ignore list
|
|
442
|
+
if (ignoreFields.has(fieldId)) continue;
|
|
443
|
+
let defaultValue = DEFAULT_VALUES[fieldId];
|
|
444
|
+
if (typeof defaultValue === 'function') {
|
|
445
|
+
defaultValue = defaultValue();
|
|
446
|
+
}
|
|
447
|
+
const el = document.getElementById(fieldId);
|
|
448
|
+
if (el) {
|
|
449
|
+
if (el.type === 'checkbox') {
|
|
450
|
+
el.checked = Boolean(defaultValue);
|
|
451
|
+
} else {
|
|
452
|
+
el.value = defaultValue !== undefined ? defaultValue : '';
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
clearFieldError(fieldId);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function clearAllFieldErrors() {
|
|
460
|
+
document.querySelectorAll('.input-wrap.has-error').forEach(wrap => {
|
|
461
|
+
wrap.classList.remove('has-error');
|
|
462
|
+
const t = wrap.querySelector('.field-tooltip');
|
|
463
|
+
if (t) t.textContent = '';
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
document.querySelectorAll('.start-index-label-row.has-error').forEach(row => {
|
|
467
|
+
row.classList.remove('has-error');
|
|
468
|
+
const input = row.querySelector('input[type="number"]');
|
|
469
|
+
if (input) {
|
|
470
|
+
input.style.borderColor = '';
|
|
471
|
+
input.style.background = '';
|
|
472
|
+
}
|
|
473
|
+
const tooltip = row.querySelector('.field-tooltip');
|
|
474
|
+
if (tooltip) {
|
|
475
|
+
tooltip.textContent = '';
|
|
476
|
+
tooltip.style.display = '';
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function getBaseVisualizationArgs() {
|
|
482
|
+
return {
|
|
483
|
+
structure: document.getElementById('structure').value.trim(),
|
|
484
|
+
sequence: document.getElementById('sequence').value.trim(),
|
|
485
|
+
startIndex1: document.getElementById('startIndex1').value.trim() || '1',
|
|
486
|
+
startIndex2: document.getElementById('startIndex2').value.trim() || '1',
|
|
487
|
+
cropping: document.getElementById('cropping').value.trim() || '-1',
|
|
488
|
+
labelInterval: '10',
|
|
489
|
+
coloring: document.getElementById('coloring').value,
|
|
490
|
+
highlighting: document.getElementById('highlighting').value,
|
|
491
|
+
backgroundhighlighting: document.getElementById('backgroundhighlighting').value,
|
|
492
|
+
distinctBpTypes: document.getElementById('distinctBpTypes').checked,
|
|
493
|
+
// clone lists
|
|
494
|
+
subsequenceHighlights: vaRRI.getSubsequenceHighlights(),
|
|
495
|
+
regionHighlights: vaRRI.getRegionHighlights(),
|
|
496
|
+
pointMutations: vaRRI.getPointMutations(),
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function getSequenceContext() {
|
|
501
|
+
const baseArgs = getBaseVisualizationArgs();
|
|
502
|
+
const v = vaRRI.validate(baseArgs);
|
|
503
|
+
return {
|
|
504
|
+
'1': { id: 'Sequence 1', offset: v.offset1, length: (v.sequence1 == null ? 0 : v.sequence1.length), sequence: v.sequence1 },
|
|
505
|
+
'2': { id: 'Sequence 2', offset: v.offset2, length: (v.sequence2 == null ? 0 : v.sequence2.length), sequence: v.sequence2 },
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function getProfileIndexReference(seq) {
|
|
510
|
+
const selected = document.getElementById(`profileIdxRef${seq}`);
|
|
511
|
+
return selected ? selected.value : '1';
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function convertCsvProfileData(profileDataId) {
|
|
515
|
+
var elem = document.getElementById(profileDataId);
|
|
516
|
+
if (!elem) return;
|
|
517
|
+
|
|
518
|
+
var lines = elem.value.split(/\r?\n/);
|
|
519
|
+
|
|
520
|
+
// Prüfen, ob mindestens 2 Zeilen vorhanden sind
|
|
521
|
+
if (lines.length < 2) return;
|
|
522
|
+
|
|
523
|
+
// Regex mit Capturing-Gruppe (2) für das Trennzeichen ([,;])
|
|
524
|
+
var dataRegex = /^\s*(-?\d+)([,;])(\d+(?:\.\d+)?)/;
|
|
525
|
+
|
|
526
|
+
// Prüfung der 2. Zeile (Index 1)
|
|
527
|
+
var match = lines[1].match(dataRegex);
|
|
528
|
+
if (!match) return;
|
|
529
|
+
|
|
530
|
+
// Gefundenes Trennzeichen ("," oder ";")
|
|
531
|
+
var delimiter = match[2];
|
|
532
|
+
|
|
533
|
+
// Prüfen, ob die 1. Zeile (Index 0) eine Datenzeile ist
|
|
534
|
+
var isFirstLineData = dataRegex.test(lines[0]);
|
|
535
|
+
|
|
536
|
+
for (var i = 0; i < lines.length; i++) {
|
|
537
|
+
// 1. Trennzeichen in allen Zeilen durch Leerzeichen ersetzen
|
|
538
|
+
lines[i] = lines[i].split(delimiter).join(' ');
|
|
539
|
+
|
|
540
|
+
// 2. Erste Zeile mit "#" versehen, falls sie keine Datenzeile ist
|
|
541
|
+
if (i === 0 && !isFirstLineData && !lines[0].match(/^\s*#/)) {
|
|
542
|
+
lines[0] = '#' + lines[0];
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Zurück in die Textarea schreiben
|
|
547
|
+
elem.value = lines.join('\n');
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function parseProfileLines(fieldId) {
|
|
551
|
+
|
|
552
|
+
const profileElem = document.getElementById(fieldId);
|
|
553
|
+
if (!profileElem) { throw { fieldId, message: `Profile data field "${fieldId}" not found.` }; }
|
|
554
|
+
const profileText = profileElem.value;
|
|
555
|
+
|
|
556
|
+
const lines = String(profileText || '').split(/\r?\n/);
|
|
557
|
+
const data = [];
|
|
558
|
+
|
|
559
|
+
for (let lineNo = 0; lineNo < lines.length; lineNo++) {
|
|
560
|
+
const line = lines[lineNo];
|
|
561
|
+
if (/^\s*$/.test(line)) continue;
|
|
562
|
+
if (/^\s*#/.test(line)) continue;
|
|
563
|
+
|
|
564
|
+
const match = line.match(/^\s*(-?\d+)\s+(\d+(?:\.\d+)?)/);
|
|
565
|
+
if (!match) {
|
|
566
|
+
throw {
|
|
567
|
+
fieldId,
|
|
568
|
+
message: `Invalid profile line ${lineNo + 1}. Expected empty/comment or "index value" data line.`,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
data.push({
|
|
573
|
+
index: Number.parseInt(match[1], 10),
|
|
574
|
+
value: Number.parseFloat(match[2]),
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
const current = data[data.length - 1];
|
|
578
|
+
if (current.value < 0 || current.value > 1) {
|
|
579
|
+
throw {
|
|
580
|
+
fieldId,
|
|
581
|
+
message: `Invalid profile line ${lineNo + 1}. Probability value must be within [0, 1].`,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return data;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function getOriginalProfileSequencePositions(vUncropped, seqId) {
|
|
590
|
+
const seqLength = seqId === '1' ? vUncropped.sequence1.length : vUncropped.sequence2.length;
|
|
591
|
+
const offset = seqId === '1' ? vUncropped.offset1 : vUncropped.offset2;
|
|
592
|
+
const seqKey = seqId === '1' ? 's1' : 's2';
|
|
593
|
+
return vaRRI.getSequenceIndices(seqKey, offset, seqLength).map(([, pos]) => pos);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function mapProfileDataToAccessData(seqData, seqId, refMode, v, vUncropped) {
|
|
597
|
+
const seqKey = seqId === '1' ? 's1' : 's2';
|
|
598
|
+
const seqLength = seqId === '1' ? v.sequence1.length : v.sequence2.length;
|
|
599
|
+
const renderedSeqPositions = vaRRI
|
|
600
|
+
.getSequenceIndices(seqKey, seqId === '1' ? v.offset1 : v.offset2, seqLength)
|
|
601
|
+
.map(([, pos]) => pos);
|
|
602
|
+
const renderedSeqPositionSet = new Set(renderedSeqPositions);
|
|
603
|
+
|
|
604
|
+
const sourceSeqPositions = getOriginalProfileSequencePositions(vUncropped, seqId);
|
|
605
|
+
const sourceSeqPositionSet = new Set(sourceSeqPositions);
|
|
606
|
+
|
|
607
|
+
const seqNodeMap = {};
|
|
608
|
+
for (const [nodeIdStr, [seqName, pos]] of Object.entries(vaRRI.getIndexDictionary(v))) {
|
|
609
|
+
if (seqName === seqKey) seqNodeMap[pos] = Number(nodeIdStr);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const accessData = {};
|
|
613
|
+
seqData.forEach(({ index, value }) => {
|
|
614
|
+
let seqPosition;
|
|
615
|
+
if (refMode === '1') {
|
|
616
|
+
const ordinal = index;
|
|
617
|
+
if (!Number.isInteger(ordinal) || ordinal < 1 || ordinal > sourceSeqPositions.length) {
|
|
618
|
+
throw {
|
|
619
|
+
fieldId: `profileData${seqId}`,
|
|
620
|
+
message: `Profile index ${index} is out of 1-based sequence bounds [1, ${sourceSeqPositions.length}] for sequence ${seqId}.`,
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
seqPosition = sourceSeqPositions[ordinal - 1];
|
|
624
|
+
} else {
|
|
625
|
+
// Start-index mode is resolved against the original (uncropped)
|
|
626
|
+
// sequence coordinate system.
|
|
627
|
+
if (sourceSeqPositionSet.has(index)) {
|
|
628
|
+
seqPosition = index;
|
|
629
|
+
} else {
|
|
630
|
+
seqPosition = index;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const nodeId = seqNodeMap[seqPosition];
|
|
635
|
+
if (!nodeId) {
|
|
636
|
+
// Cropping can remove nucleotides referenced by profile input.
|
|
637
|
+
// In that case, skip the entry instead of remapping it or failing.
|
|
638
|
+
if (sourceSeqPositionSet.has(seqPosition) && !renderedSeqPositionSet.has(seqPosition)) {
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
throw {
|
|
642
|
+
fieldId: `profileData${seqId}`,
|
|
643
|
+
message: `Profile index ${index} does not map to a valid nucleotide of sequence ${seqId}.`,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
accessData[nodeId] = value;
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
return accessData;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function parseProfileAccessData(v, args) {
|
|
654
|
+
const vUncropped = vaRRI.validate({ ...args, cropping: '-1' });
|
|
655
|
+
let accessData = {};
|
|
656
|
+
for (const seqId of ['1', '2']) {
|
|
657
|
+
const seqData = parseProfileLines(`profileData${seqId}`);
|
|
658
|
+
const refMode = getProfileIndexReference(seqId);
|
|
659
|
+
// append the access data for this sequence to the overall access data object
|
|
660
|
+
accessData = { ...accessData, ...mapProfileDataToAccessData(seqData, seqId, refMode, v, vUncropped) };
|
|
661
|
+
}
|
|
662
|
+
// update UI counters for profile data fields
|
|
663
|
+
updateInputCounter(['profileData1', 'profileData2'], 'profileCounterUI');
|
|
664
|
+
// return the combined access data for both sequences
|
|
665
|
+
return accessData;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function resetProfileForm() {
|
|
669
|
+
resetFormDefaultValue([
|
|
670
|
+
'profileData1',
|
|
671
|
+
'profileIdxRef1',
|
|
672
|
+
'profileColor1',
|
|
673
|
+
'profileColorRepresentsOne1',
|
|
674
|
+
'profileData2',
|
|
675
|
+
'profileIdxRef2',
|
|
676
|
+
'profileColor2',
|
|
677
|
+
'profileColorRepresentsOne2',
|
|
678
|
+
]);
|
|
679
|
+
// update UI counter for profile data fields
|
|
680
|
+
updateInputCounter(['profileData1', 'profileData2'], 'profileCounterUI');
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function getCompatibilityValidationContext(v) {
|
|
684
|
+
return {
|
|
685
|
+
'1': { offset: v.offset1, sequence: v.sequence1 },
|
|
686
|
+
'2': { offset: v.offset2, sequence: v.sequence2 },
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function getVisibleSequencePositionSets(v) {
|
|
691
|
+
const visible = { '1': new Set(), '2': new Set() };
|
|
692
|
+
const indexDictionary = vaRRI.getIndexDictionary(v);
|
|
693
|
+
Object.values(indexDictionary).forEach(([seqName, pos]) => {
|
|
694
|
+
if (seqName === 's1') visible['1'].add(pos);
|
|
695
|
+
if (seqName === 's2') visible['2'].add(pos);
|
|
696
|
+
});
|
|
697
|
+
return visible;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function validateStartIndexCompatibility(args) {
|
|
701
|
+
const argsWithoutAnnotations = {
|
|
702
|
+
...args,
|
|
703
|
+
subsequenceHighlights: [],
|
|
704
|
+
regionHighlights: [],
|
|
705
|
+
pointMutations: [],
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
let validatedBase;
|
|
709
|
+
try {
|
|
710
|
+
validatedBase = vaRRI.validate(argsWithoutAnnotations);
|
|
711
|
+
} catch (err) {
|
|
712
|
+
return {
|
|
713
|
+
ok: false,
|
|
714
|
+
message: err && err.message ? err.message : String(err),
|
|
715
|
+
focusField: null,
|
|
716
|
+
startField: null,
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const sequenceContext = getCompatibilityValidationContext(validatedBase);
|
|
721
|
+
const visiblePositions = getVisibleSequencePositionSets(validatedBase);
|
|
722
|
+
|
|
723
|
+
const highlights = vaRRI.getSubsequenceHighlights();
|
|
724
|
+
for (const highlight of highlights) {
|
|
725
|
+
try {
|
|
726
|
+
vaRRI.createSubsequenceHighlight({
|
|
727
|
+
sequence: highlight.sequence,
|
|
728
|
+
range: highlight.range,
|
|
729
|
+
color: highlight.color,
|
|
730
|
+
}, sequenceContext);
|
|
731
|
+
} catch (err) {
|
|
732
|
+
const message = err && err.message ? err.message : String(err);
|
|
733
|
+
return {
|
|
734
|
+
ok: false,
|
|
735
|
+
message: `Start index ${highlight.sequence} is incompatible with subsequence highlight "${highlight.rangeText}": ${message}`,
|
|
736
|
+
focusField: 'subseqRange',
|
|
737
|
+
startField: highlight.sequence === '1' ? 'startIndex1' : 'startIndex2',
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const regions = vaRRI.getRegionHighlights();
|
|
743
|
+
for (const region of regions) {
|
|
744
|
+
try {
|
|
745
|
+
vaRRI.createRegionHighlight({
|
|
746
|
+
sequence1Range: region.sequence1Range,
|
|
747
|
+
sequence2Range: region.sequence2Range,
|
|
748
|
+
color: region.color,
|
|
749
|
+
generated: region.generated,
|
|
750
|
+
}, sequenceContext);
|
|
751
|
+
} catch (err) {
|
|
752
|
+
const message = err && err.message ? err.message : String(err);
|
|
753
|
+
return {
|
|
754
|
+
ok: false,
|
|
755
|
+
message: `Start index ${region.sequence1Range[0]} is incompatible with region highlight "${region.rangeText}": ${message}`,
|
|
756
|
+
focusField: 'regionSequence1Start',
|
|
757
|
+
startField: 'startIndex1',
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const mutations = vaRRI.getPointMutations();
|
|
763
|
+
for (const mutation of mutations) {
|
|
764
|
+
try {
|
|
765
|
+
const normalized = vaRRI.createPointMutation({
|
|
766
|
+
sequence: mutation.sequence,
|
|
767
|
+
position: mutation.position,
|
|
768
|
+
replacement: mutation.replacement,
|
|
769
|
+
color: mutation.color,
|
|
770
|
+
}, sequenceContext);
|
|
771
|
+
|
|
772
|
+
if (!visiblePositions[normalized.sequence].has(normalized.position)) {
|
|
773
|
+
return {
|
|
774
|
+
ok: false,
|
|
775
|
+
message: `Start index ${normalized.sequence} is incompatible with mutation ${normalized.labelText}.`,
|
|
776
|
+
focusField: 'mutationPosition',
|
|
777
|
+
startField: normalized.sequence === '1' ? 'startIndex1' : 'startIndex2',
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
} catch (err) {
|
|
781
|
+
const message = err && err.message ? err.message : String(err);
|
|
782
|
+
const mutationSeq = String(mutation.sequence);
|
|
783
|
+
return {
|
|
784
|
+
ok: false,
|
|
785
|
+
message: `Start index ${mutationSeq} is incompatible with mutation at ${mutation.position}: ${message}`,
|
|
786
|
+
focusField: 'mutationPosition',
|
|
787
|
+
startField: mutationSeq === '1' ? 'startIndex1' : 'startIndex2',
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
try {
|
|
793
|
+
parseProfileAccessData(validatedBase, argsWithoutAnnotations);
|
|
794
|
+
} catch (err) {
|
|
795
|
+
const message = err && err.message ? err.message : String(err);
|
|
796
|
+
const fieldId = err && err.fieldId ? err.fieldId : null;
|
|
797
|
+
const seq = fieldId === 'profileData2' ? '2' : '1';
|
|
798
|
+
return {
|
|
799
|
+
ok: false,
|
|
800
|
+
message: `Start index ${seq} is incompatible with profile data: ${message}`,
|
|
801
|
+
focusField: fieldId,
|
|
802
|
+
startField: seq === '1' ? 'startIndex1' : 'startIndex2',
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
return { ok: true };
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Updates a UI counter element based on the number of non-empty input/textarea fields.
|
|
811
|
+
*
|
|
812
|
+
* @param {string[]} inputIds - Array of element IDs to inspect (e.g. ['profileData1', 'profileData2']).
|
|
813
|
+
* @param {string} counterId - ID of the <span> element displaying the counter.
|
|
814
|
+
*/
|
|
815
|
+
function updateInputCounter(inputIds, counterId) {
|
|
816
|
+
const counter = document.getElementById(counterId);
|
|
817
|
+
if (!counter || !Array.isArray(inputIds)) return;
|
|
818
|
+
|
|
819
|
+
// Count how many listed input fields contain non-empty values after trim()
|
|
820
|
+
const activeCount = inputIds.reduce((count, id) => {
|
|
821
|
+
const el = document.getElementById(id);
|
|
822
|
+
if (el && typeof el.value === 'string' && el.value.trim() !== '') {
|
|
823
|
+
return count + 1;
|
|
824
|
+
}
|
|
825
|
+
return count;
|
|
826
|
+
}, 0);
|
|
827
|
+
|
|
828
|
+
// Set counter text, e.g. "(2)". Hide if 0.
|
|
829
|
+
counter.textContent = activeCount > 0 ? `(${activeCount})` : '';
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Display an annotation count, hiding zero counts.
|
|
834
|
+
*
|
|
835
|
+
* @param {string} counterId - ID of the counter element.
|
|
836
|
+
* @param {number} count - Number of registered annotations.
|
|
837
|
+
*/
|
|
838
|
+
function updateListCounter(counterId, count) {
|
|
839
|
+
const counter = document.getElementById(counterId);
|
|
840
|
+
if (!counter) return;
|
|
841
|
+
counter.textContent = count > 0 ? '(' + count + ')' : '';
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
// Dynamically sync size & compensate for vertical scrollbar width
|
|
848
|
+
function syncDimensions(textareaId) {
|
|
849
|
+
const textarea = document.getElementById(textareaId);
|
|
850
|
+
const backdrop = document.getElementById(`backdrop-${textareaId}`);
|
|
851
|
+
backdrop.style.width = `${textarea.offsetWidth}px`;
|
|
852
|
+
backdrop.style.height = `${textarea.offsetHeight}px`;
|
|
853
|
+
|
|
854
|
+
// Calculate textarea's scrollbar width (offsetWidth - clientWidth - 2px borders)
|
|
855
|
+
const scrollbarWidth = textarea.offsetWidth - textarea.clientWidth - 2;
|
|
856
|
+
|
|
857
|
+
// Add scrollbar width to backdrop's right padding so text area matches character for character
|
|
858
|
+
backdrop.style.paddingRight = `${8 + Math.max(0, scrollbarWidth)}px`;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Sync scroll positions
|
|
862
|
+
function syncScroll(textareaId) {
|
|
863
|
+
const textarea = document.getElementById(textareaId);
|
|
864
|
+
const backdrop = document.getElementById(`backdrop-${textareaId}`);
|
|
865
|
+
backdrop.scrollTop = textarea.scrollTop;
|
|
866
|
+
backdrop.scrollLeft = textarea.scrollLeft;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
function resetFastaForm() {
|
|
871
|
+
resetFormDefaultValue( ['fastaInput', 'fastaSequence', 'fastaStructure'] );
|
|
872
|
+
updateHighlights('fastaSequence');
|
|
873
|
+
updateHighlights('fastaStructure');
|
|
874
|
+
clearFieldErrors(['fastaInput', 'fastaSequence', 'fastaStructure']);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function validateFastaForm() {
|
|
878
|
+
clearFieldErrors(['fastaInput', 'fastaSequence', 'fastaStructure']);
|
|
879
|
+
|
|
880
|
+
const fastaInput = document.getElementById('fastaInput');
|
|
881
|
+
if (!fastaInput) return false;
|
|
882
|
+
|
|
883
|
+
const fastaText = fastaInput.value.trim();
|
|
884
|
+
if (fastaText === '') {
|
|
885
|
+
setFieldError('fastaInput', 'FASTA input cannot be empty.');
|
|
886
|
+
return false;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// Basic validation: check for FASTA header and sequence lines
|
|
890
|
+
const lines = fastaText.split(/\r?\n/);
|
|
891
|
+
if (!lines[0].startsWith('>')) {
|
|
892
|
+
setFieldError('fastaInput', 'FASTA input must start with a header line beginning with ">".');
|
|
893
|
+
return false;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
if (lines.length < 2 || lines.slice(1).every(line => line.trim() === '')) {
|
|
897
|
+
setFieldError('fastaInput', 'FASTA input must contain at least one sequence line after the header.');
|
|
898
|
+
return false;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// copy data to input fields
|
|
902
|
+
try {
|
|
903
|
+
if (!processFastaTextarea('fastaInput', 'fastaSequence', 'fastaStructure')) {
|
|
904
|
+
return false;
|
|
905
|
+
}
|
|
906
|
+
// update highlights and sync dimensions/scroll for both sequence and structure textareas
|
|
907
|
+
for ( const fieldId of ['fastaSequence', 'fastaStructure']) {
|
|
908
|
+
updateHighlights(fieldId);
|
|
909
|
+
syncDimensions(fieldId);
|
|
910
|
+
syncScroll(fieldId);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
catch (e) { setFieldError('fastaInput', e.message); return false;}
|
|
914
|
+
|
|
915
|
+
const sequenceInput = document.getElementById('fastaSequence').value;
|
|
916
|
+
if(sequenceInput && sequenceInput.trim() !== '') {
|
|
917
|
+
try {
|
|
918
|
+
vaRRI.validateSequenceInput(sequenceInput.trim());
|
|
919
|
+
} catch (e) {
|
|
920
|
+
setFieldError('fastaSequence', e.message);
|
|
921
|
+
return false;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const structureInput = document.getElementById('fastaStructure').value;
|
|
926
|
+
if(structureInput && structureInput.trim() !== '') {
|
|
927
|
+
try {
|
|
928
|
+
vaRRI.validateStructureInput(structureInput.trim(), sequenceInput.trim());
|
|
929
|
+
} catch (e) {
|
|
930
|
+
setFieldError('fastaStructure', e.message);
|
|
931
|
+
return false;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
// all fine
|
|
937
|
+
return true;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function submitFastaForm() {
|
|
941
|
+
if (!validateFastaForm()) {
|
|
942
|
+
return false;
|
|
943
|
+
}
|
|
944
|
+
// update input field values from the parsed textareas
|
|
945
|
+
const sequence = document.getElementById('sequence');
|
|
946
|
+
sequence.value = document.getElementById('fastaSequence').value;
|
|
947
|
+
// copy structure input if present
|
|
948
|
+
const structureInput = document.getElementById('fastaStructure').value;
|
|
949
|
+
if (structureInput.trim() !== '') {
|
|
950
|
+
const structure = document.getElementById('structure');
|
|
951
|
+
structure.value = structureInput;
|
|
952
|
+
structure.dispatchEvent(new Event('input', {bubbles:true}))
|
|
953
|
+
}
|
|
954
|
+
sequence.dispatchEvent(new Event('input', {bubbles:true}))
|
|
955
|
+
resetFastaForm();
|
|
956
|
+
runVisualization();
|
|
957
|
+
return true;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
// Funktion zum Verarbeiten und Hervorheben des Textes
|
|
962
|
+
function updateHighlights(textareaId) {
|
|
963
|
+
const textarea = document.getElementById(textareaId);
|
|
964
|
+
const backdrop = document.getElementById(`backdrop-${textareaId}`);
|
|
965
|
+
const highlights = document.getElementById(`highlights-${textareaId}`);
|
|
966
|
+
|
|
967
|
+
const colorSeq1Input = document.getElementById('colorSeq1');
|
|
968
|
+
const colorSeq2Input = document.getElementById('colorSeq2');
|
|
969
|
+
const colorBasepairInput = document.getElementById('colorBasepair');
|
|
970
|
+
const text = textarea.value;
|
|
971
|
+
const seq1Color = cssColorToRGB(colorSeq1Input.value, 0.35);
|
|
972
|
+
const seq2Color = cssColorToRGB(colorSeq2Input.value, 0.35);
|
|
973
|
+
const basepairColor = cssColorToRGB(colorBasepairInput.value, 0.6); // '&' etwas auffälliger
|
|
974
|
+
|
|
975
|
+
const regex = /^([^&]+)(&)([^&]+)$/;
|
|
976
|
+
const match = text.match(regex);
|
|
977
|
+
|
|
978
|
+
let html = '';
|
|
979
|
+
if (match) {
|
|
980
|
+
html = `<span class="hl-seq1" style="background-color: ${seq1Color}">${match[1]}</span>` +
|
|
981
|
+
`<span class="hl-basepair" style="background-color: ${basepairColor}">${match[2]}</span>` +
|
|
982
|
+
`<span class="hl-seq2" style="background-color: ${seq2Color}">${match[3]}</span>`;
|
|
983
|
+
} else if (text.includes('&')) {
|
|
984
|
+
const parts = text.split('&');
|
|
985
|
+
const seq1 = parts[0];
|
|
986
|
+
const rest = parts.slice(1).join('&');
|
|
987
|
+
html = `<span class="hl-seq1" style="background-color: ${seq1Color}">${seq1}</span>` +
|
|
988
|
+
`<span class="hl-basepair" style="background-color: ${basepairColor}">&</span>` +
|
|
989
|
+
`<span class="hl-seq2" style="background-color: ${seq2Color}">${rest}</span>`;
|
|
990
|
+
} else {
|
|
991
|
+
html = text;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// If text ends with a newline, add a space so backdrop height matches textarea scroll
|
|
995
|
+
if (text.endsWith('\n')) {
|
|
996
|
+
html += ' ';
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
highlights.innerHTML = html;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
function normalizeRegionInput(value) {
|
|
1004
|
+
if (value === null || value === undefined) return '';
|
|
1005
|
+
const normalized = String(value).replace(/\s+/g, '');
|
|
1006
|
+
if (normalized === '') return '';
|
|
1007
|
+
if (!/^(-?\d+)-(-?\d+)$/.test(normalized)) {
|
|
1008
|
+
throw new Error('Region must use the format START-END.');
|
|
1009
|
+
}
|
|
1010
|
+
return normalized;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function resetAnnotationForm(fieldIds, submitButtonId, activeItemSelector) {
|
|
1014
|
+
resetFormDefaultValue(fieldIds);
|
|
1015
|
+
const submitButton = document.getElementById(submitButtonId);
|
|
1016
|
+
if (submitButton) submitButton.textContent = 'Add';
|
|
1017
|
+
document.querySelectorAll(activeItemSelector).forEach(item => item.classList.remove('active'));
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
function resetRegionForm() {
|
|
1022
|
+
resetAnnotationForm(
|
|
1023
|
+
['regionEditId', 'region1', 'region2', 'regionColor', 'regionAlpha'],
|
|
1024
|
+
null,
|
|
1025
|
+
'.region-item.active'
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
function resetSubseqForm() {
|
|
1030
|
+
resetAnnotationForm(
|
|
1031
|
+
['subseqEditId', 'subseqSequence', 'subseqRange', 'subseqColor', 'subseqAlpha'],
|
|
1032
|
+
null,
|
|
1033
|
+
'.highlight-item.active'
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function renderSubseqHighlightList() {
|
|
1038
|
+
const listEl = document.getElementById('highlight-list');
|
|
1039
|
+
const highlights = vaRRI.getSubsequenceHighlights();
|
|
1040
|
+
|
|
1041
|
+
if (highlights.length === 0) {
|
|
1042
|
+
listEl.innerHTML = '<li class="highlight-empty">No highlights defined.</li>';
|
|
1043
|
+
updateListCounter('subseqCounterUI', 0);
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
listEl.innerHTML = '';
|
|
1048
|
+
highlights.forEach(highlight => {
|
|
1049
|
+
const item = document.createElement('li');
|
|
1050
|
+
item.className = 'highlight-item';
|
|
1051
|
+
item.dataset.highlightId = String(highlight.id);
|
|
1052
|
+
|
|
1053
|
+
const info = document.createElement('button');
|
|
1054
|
+
info.type = 'button';
|
|
1055
|
+
info.className = 'highlight-item-main';
|
|
1056
|
+
info.textContent = `Seq ${highlight.sequence} : ${highlight.rangeText}`;
|
|
1057
|
+
info.style.borderLeft = `10px solid ${cssColorToRGB(highlight.color, highlight.alpha)}`;
|
|
1058
|
+
info.title = 'Click to edit subsequence highlighting.';
|
|
1059
|
+
|
|
1060
|
+
const removeBtn = document.createElement('button');
|
|
1061
|
+
removeBtn.type = 'button';
|
|
1062
|
+
removeBtn.className = 'highlight-delete';
|
|
1063
|
+
removeBtn.setAttribute('aria-label', `Remove highlight ${highlight.id}`);
|
|
1064
|
+
removeBtn.textContent = '🗑️'; // trash can icon
|
|
1065
|
+
removeBtn.title = 'Remove highlight';
|
|
1066
|
+
|
|
1067
|
+
info.addEventListener('click', (e) => {
|
|
1068
|
+
document.querySelectorAll('.highlight-item.active').forEach(el => el.classList.remove('active'));
|
|
1069
|
+
item.classList.add('active');
|
|
1070
|
+
document.getElementById('subseqEditId').value = String(highlight.id);
|
|
1071
|
+
document.getElementById('subseqSequence').value = highlight.sequence;
|
|
1072
|
+
document.getElementById('subseqRange').value = highlight.rangeText;
|
|
1073
|
+
document.getElementById('subseqColor').value = cssColorToHex(highlight.color);
|
|
1074
|
+
document.getElementById('subseqAlpha').value = highlight.alpha.toFixed(1);
|
|
1075
|
+
clearFieldErrors(['subseqSequence', 'subseqRange', 'subseqColor']);
|
|
1076
|
+
// open dialog and store if needed
|
|
1077
|
+
openDialog('subseqHighlightDialog', 'Changing Subsequence Highlighting ...', highlight.rangeText, e, function(label, value) {
|
|
1078
|
+
return submitSubseqForm();
|
|
1079
|
+
}, resetSubseqForm);
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
removeBtn.addEventListener('click', () => {
|
|
1083
|
+
const removed = vaRRI.removeSubsequenceHighlight(highlight.id);
|
|
1084
|
+
if (!removed) return;
|
|
1085
|
+
if (document.getElementById('subseqEditId').value === String(highlight.id)) {
|
|
1086
|
+
resetSubseqForm();
|
|
1087
|
+
}
|
|
1088
|
+
renderSubseqHighlightList();
|
|
1089
|
+
runVisualization();
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
item.appendChild(info);
|
|
1093
|
+
item.appendChild(removeBtn);
|
|
1094
|
+
listEl.appendChild(item);
|
|
1095
|
+
});
|
|
1096
|
+
updateListCounter('subseqCounterUI', highlights.length);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function validateSubseqForm() {
|
|
1100
|
+
|
|
1101
|
+
clearFieldErrors(['subseqSequence', 'subseqRange', 'subseqColor', 'subseqAlpha']);
|
|
1102
|
+
|
|
1103
|
+
const sequence = document.getElementById('subseqSequence').value;
|
|
1104
|
+
const range = document.getElementById('subseqRange').value.trim();
|
|
1105
|
+
const color = document.getElementById('subseqColor').value;
|
|
1106
|
+
const alpha = document.getElementById('subseqAlpha').value.trim();
|
|
1107
|
+
const editIdRaw = document.getElementById('subseqEditId').value;
|
|
1108
|
+
|
|
1109
|
+
if (!range) {
|
|
1110
|
+
setFieldError('subseqRange', 'Highlight range must not be empty.');
|
|
1111
|
+
return false;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
let sequenceContext;
|
|
1115
|
+
try {
|
|
1116
|
+
sequenceContext = getSequenceContext();
|
|
1117
|
+
} catch (err) {
|
|
1118
|
+
showMsg('Please fix sequence/structure inputs first: ' + err.message, 'error');
|
|
1119
|
+
return false;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// store or update data
|
|
1123
|
+
try {
|
|
1124
|
+
vaRRI.createSubsequenceHighlight({ sequence, range, color, alpha }, sequenceContext);
|
|
1125
|
+
} catch (err) {
|
|
1126
|
+
const message = err && err.message ? err.message : String(err);
|
|
1127
|
+
|
|
1128
|
+
// Route parsing/validation failures for the range text box to the
|
|
1129
|
+
// range field, even when the message mentions "sequence indices".
|
|
1130
|
+
const isSequenceSelectorError = /must be "1" or "2"/i.test(message);
|
|
1131
|
+
const isColorError = /color/i.test(message);
|
|
1132
|
+
|
|
1133
|
+
if (isSequenceSelectorError) setFieldError('subseqSequence', message);
|
|
1134
|
+
else if (isColorError) setFieldError('subseqColor', message);
|
|
1135
|
+
else setFieldError('subseqRange', message);
|
|
1136
|
+
return false;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
return true;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function submitSubseqForm() {
|
|
1143
|
+
if (validateSubseqForm()) {
|
|
1144
|
+
// store or update data
|
|
1145
|
+
try {
|
|
1146
|
+
const sequence = document.getElementById('subseqSequence').value;
|
|
1147
|
+
const range = document.getElementById('subseqRange').value.trim();
|
|
1148
|
+
const color = document.getElementById('subseqColor').value;
|
|
1149
|
+
const alpha = document.getElementById('subseqAlpha').value.trim();
|
|
1150
|
+
const editIdRaw = document.getElementById('subseqEditId').value;
|
|
1151
|
+
let sequenceContext;
|
|
1152
|
+
try {
|
|
1153
|
+
sequenceContext = getSequenceContext();
|
|
1154
|
+
} catch (err) {
|
|
1155
|
+
showMsg('Please fix sequence/structure inputs first: ' + err.message, 'error');
|
|
1156
|
+
return false;
|
|
1157
|
+
}
|
|
1158
|
+
if (editIdRaw) {
|
|
1159
|
+
vaRRI.updateSubsequenceHighlight(Number(editIdRaw), { sequence, range, color, alpha }, sequenceContext);
|
|
1160
|
+
} else {
|
|
1161
|
+
vaRRI.registerSubsequenceHighlight({ sequence, range, color, alpha }, sequenceContext);
|
|
1162
|
+
}
|
|
1163
|
+
} catch (err) {
|
|
1164
|
+
const message = err && err.message ? err.message : String(err);
|
|
1165
|
+
|
|
1166
|
+
// Route parsing/validation failures for the range text box to the
|
|
1167
|
+
// range field, even when the message mentions "sequence indices".
|
|
1168
|
+
const isSequenceSelectorError = /must be "1" or "2"/i.test(message);
|
|
1169
|
+
const isColorError = /color/i.test(message);
|
|
1170
|
+
|
|
1171
|
+
if (isSequenceSelectorError) setFieldError('subseqSequence', message);
|
|
1172
|
+
else if (isColorError) setFieldError('subseqColor', message);
|
|
1173
|
+
else setFieldError('subseqRange', message);
|
|
1174
|
+
return false;
|
|
1175
|
+
}
|
|
1176
|
+
resetSubseqForm();
|
|
1177
|
+
renderSubseqHighlightList();
|
|
1178
|
+
runVisualization();
|
|
1179
|
+
return true; // Erfolgreich -> Dialog darf schließen
|
|
1180
|
+
}
|
|
1181
|
+
return false; // Fehlerhaft -> Dialog bleibt offen
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function renderRegionList() {
|
|
1185
|
+
const listEl = document.getElementById('region-list');
|
|
1186
|
+
const regions = vaRRI.getRegionHighlights();
|
|
1187
|
+
|
|
1188
|
+
if (regions.length === 0) {
|
|
1189
|
+
listEl.innerHTML = '<li class="highlight-empty">No region highlights defined.</li>';
|
|
1190
|
+
updateListCounter('regionCounterUI', 0);
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
listEl.innerHTML = '';
|
|
1195
|
+
regions.forEach(region => {
|
|
1196
|
+
const item = document.createElement('li');
|
|
1197
|
+
item.className = 'highlight-item region-item';
|
|
1198
|
+
item.dataset.regionId = String(region.id);
|
|
1199
|
+
if (region.generated) item.classList.add('generated');
|
|
1200
|
+
|
|
1201
|
+
const info = document.createElement('button');
|
|
1202
|
+
info.type = 'button';
|
|
1203
|
+
info.className = 'highlight-item-main';
|
|
1204
|
+
info.textContent = region.rangeText;
|
|
1205
|
+
info.style.borderLeft = `10px solid ${cssColorToRGB(region.color, region.alpha)}`;
|
|
1206
|
+
if (region.generated) {
|
|
1207
|
+
info.disabled = true;
|
|
1208
|
+
info.title = 'Generated region highlight';
|
|
1209
|
+
} else {
|
|
1210
|
+
info.title = 'Click to edit region highlight';
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
if (!region.generated) {
|
|
1214
|
+
const removeBtn = document.createElement('button');
|
|
1215
|
+
removeBtn.type = 'button';
|
|
1216
|
+
removeBtn.className = 'highlight-delete';
|
|
1217
|
+
removeBtn.setAttribute('aria-label', `Remove region highlight ${region.id}`);
|
|
1218
|
+
removeBtn.textContent = '🗑️';
|
|
1219
|
+
removeBtn.title = 'Remove region highlight';
|
|
1220
|
+
|
|
1221
|
+
info.addEventListener('click', (e) => {
|
|
1222
|
+
document.querySelectorAll('.region-item.active').forEach(el => el.classList.remove('active'));
|
|
1223
|
+
item.classList.add('active');
|
|
1224
|
+
document.getElementById('regionEditId').value = String(region.id);
|
|
1225
|
+
document.getElementById('region1').value = `${region.sequence1Range[0]}-${region.sequence1Range[1]}`;
|
|
1226
|
+
document.getElementById('region2').value = `${region.sequence2Range[0]}-${region.sequence2Range[1]}`;
|
|
1227
|
+
document.getElementById('regionColor').value = cssColorToHex(region.color);
|
|
1228
|
+
document.getElementById('regionAlpha').value = region.alpha.toFixed(1);
|
|
1229
|
+
clearFieldErrors(['region1', 'region2', 'regionColor']);
|
|
1230
|
+
// open dialog and store if needed
|
|
1231
|
+
openDialog('regionHighlightDialog', 'Changing RRI Region Highlighting ...', `${region.sequence1Range[0]}-${region.sequence1Range[1]}`, e, function(label, value) {
|
|
1232
|
+
return submitRegionForm();
|
|
1233
|
+
}, resetRegionForm);
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
removeBtn.addEventListener('click', () => {
|
|
1237
|
+
const removed = vaRRI.removeRegionHighlight(region.id);
|
|
1238
|
+
if (!removed) return;
|
|
1239
|
+
if (document.getElementById('regionEditId').value === String(region.id)) {
|
|
1240
|
+
resetRegionForm();
|
|
1241
|
+
}
|
|
1242
|
+
renderRegionList();
|
|
1243
|
+
runVisualization();
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
item.appendChild(info);
|
|
1247
|
+
item.appendChild(removeBtn);
|
|
1248
|
+
} else {
|
|
1249
|
+
item.appendChild(info);
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
listEl.appendChild(item);
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
updateListCounter('regionCounterUI', regions.length);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
function validateRegionForm() {
|
|
1259
|
+
|
|
1260
|
+
clearFieldErrors(['region1', 'region2', 'regionColor', 'regionAlpha']);
|
|
1261
|
+
|
|
1262
|
+
const region1Input = document.getElementById('region1').value;
|
|
1263
|
+
const region2Input = document.getElementById('region2').value;
|
|
1264
|
+
const color = document.getElementById('regionColor').value;
|
|
1265
|
+
const alpha = Number.parseFloat(document.getElementById('regionAlpha').value.trim());
|
|
1266
|
+
const editIdRaw = document.getElementById('regionEditId').value;
|
|
1267
|
+
|
|
1268
|
+
let region1Value;
|
|
1269
|
+
let region2Value;
|
|
1270
|
+
try {
|
|
1271
|
+
region1Value = normalizeRegionInput(region1Input);
|
|
1272
|
+
region2Value = normalizeRegionInput(region2Input);
|
|
1273
|
+
} catch (err) {
|
|
1274
|
+
const message = err && err.message ? err.message : String(err);
|
|
1275
|
+
if (region1Input.trim() && /format/.test(message)) {
|
|
1276
|
+
setFieldError('region1', message);
|
|
1277
|
+
} else if (region2Input.trim() && /format/.test(message)) {
|
|
1278
|
+
setFieldError('region2', message);
|
|
1279
|
+
} else {
|
|
1280
|
+
if (!region1Value && region1Input.trim() === '') setFieldError('region1', 'Region 1 is required.');
|
|
1281
|
+
if (!region2Value && region2Input.trim() === '') setFieldError('region2', 'Region 2 is required.');
|
|
1282
|
+
}
|
|
1283
|
+
return false;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
if (!region1Value || !region2Value) {
|
|
1287
|
+
if (!region1Value) setFieldError('region1', 'Region 1 is required.');
|
|
1288
|
+
if (!region2Value) setFieldError('region2', 'Region 2 is required.');
|
|
1289
|
+
return false;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
let sequenceContext;
|
|
1293
|
+
try {
|
|
1294
|
+
sequenceContext = getSequenceContext();
|
|
1295
|
+
} catch (err) {
|
|
1296
|
+
showMsg('Please fix sequence/structure inputs first: ' + err.message, 'error');
|
|
1297
|
+
return false;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
try {
|
|
1301
|
+
vaRRI.createRegionHighlight({
|
|
1302
|
+
sequence1Range: region1Value,
|
|
1303
|
+
sequence2Range: region2Value,
|
|
1304
|
+
color,
|
|
1305
|
+
alpha,
|
|
1306
|
+
}, sequenceContext);
|
|
1307
|
+
} catch (err) {
|
|
1308
|
+
const message = err && err.message ? err.message : String(err);
|
|
1309
|
+
if (/color/i.test(message)) setFieldError('regionColor', message);
|
|
1310
|
+
else if (/region 1|sequence 1/i.test(message)) setFieldError('region1', message);
|
|
1311
|
+
else if (/region 2|sequence 2/i.test(message)) setFieldError('region2', message);
|
|
1312
|
+
else setFieldError('region1', message);
|
|
1313
|
+
return false;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
return true;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
function submitRegionForm() {
|
|
1320
|
+
|
|
1321
|
+
if(!validateRegionForm()) {
|
|
1322
|
+
return false;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
let sequenceContext;
|
|
1326
|
+
try {
|
|
1327
|
+
sequenceContext = getSequenceContext();
|
|
1328
|
+
} catch (err) {
|
|
1329
|
+
showMsg('Please fix sequence/structure inputs first: ' + err.message, 'error');
|
|
1330
|
+
return false;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
try {
|
|
1334
|
+
const color = document.getElementById('regionColor').value;
|
|
1335
|
+
const alpha = Number.parseFloat(document.getElementById('regionAlpha').value.trim());
|
|
1336
|
+
const editIdRaw = document.getElementById('regionEditId').value;
|
|
1337
|
+
const region1Value = normalizeRegionInput(document.getElementById('region1').value);
|
|
1338
|
+
const region2Value = normalizeRegionInput(document.getElementById('region2').value);
|
|
1339
|
+
if (editIdRaw) {
|
|
1340
|
+
vaRRI.updateRegionHighlight(Number(editIdRaw), {
|
|
1341
|
+
sequence1Range: region1Value,
|
|
1342
|
+
sequence2Range: region2Value,
|
|
1343
|
+
color,
|
|
1344
|
+
alpha,
|
|
1345
|
+
}, sequenceContext);
|
|
1346
|
+
} else {
|
|
1347
|
+
vaRRI.registerRegionHighlight({
|
|
1348
|
+
sequence1Range: region1Value,
|
|
1349
|
+
sequence2Range: region2Value,
|
|
1350
|
+
color,
|
|
1351
|
+
alpha,
|
|
1352
|
+
}, sequenceContext);
|
|
1353
|
+
}
|
|
1354
|
+
} catch (err) {
|
|
1355
|
+
const message = err && err.message ? err.message : String(err);
|
|
1356
|
+
if (/color/i.test(message)) setFieldError('regionColor', message);
|
|
1357
|
+
else if (/region 1|sequence 1/i.test(message)) setFieldError('region1', message);
|
|
1358
|
+
else if (/region 2|sequence 2/i.test(message)) setFieldError('region2', message);
|
|
1359
|
+
else setFieldError('region1', message);
|
|
1360
|
+
return false;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
resetRegionForm();
|
|
1364
|
+
renderRegionList();
|
|
1365
|
+
runVisualization();
|
|
1366
|
+
return true; // Successfully submitted
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
function resetMutationForm() {
|
|
1370
|
+
resetAnnotationForm(
|
|
1371
|
+
['mutationEditId', 'mutationSequence', 'mutationPosition', 'mutationBase', 'mutationColor'],
|
|
1372
|
+
null,
|
|
1373
|
+
'.mutation-item.active'
|
|
1374
|
+
);
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
function renderMutationList() {
|
|
1378
|
+
const listEl = document.getElementById('mutation-list');
|
|
1379
|
+
const mutations = vaRRI.getPointMutations();
|
|
1380
|
+
|
|
1381
|
+
if (mutations.length === 0) {
|
|
1382
|
+
listEl.innerHTML = '<li class="highlight-empty">No mutations defined.</li>';
|
|
1383
|
+
updateListCounter('mutationCounterUI', 0);
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
listEl.innerHTML = '';
|
|
1388
|
+
mutations.forEach(mutation => {
|
|
1389
|
+
const item = document.createElement('li');
|
|
1390
|
+
item.className = 'highlight-item mutation-item';
|
|
1391
|
+
item.dataset.mutationId = String(mutation.id);
|
|
1392
|
+
|
|
1393
|
+
const info = document.createElement('button');
|
|
1394
|
+
info.type = 'button';
|
|
1395
|
+
info.className = 'highlight-item-main';
|
|
1396
|
+
info.textContent = `Seq ${mutation.sequence} : ${mutation.labelText}`;
|
|
1397
|
+
info.style.borderLeft = `10px solid ${mutation.color}`;
|
|
1398
|
+
info.title = 'Click to edit point mutation.';
|
|
1399
|
+
|
|
1400
|
+
info.addEventListener('click', (e) => {
|
|
1401
|
+
document.querySelectorAll('.mutation-item.active').forEach(el => el.classList.remove('active'));
|
|
1402
|
+
item.classList.add('active');
|
|
1403
|
+
document.getElementById('mutationEditId').value = String(mutation.id);
|
|
1404
|
+
document.getElementById('mutationSequence').value = mutation.sequence;
|
|
1405
|
+
document.getElementById('mutationPosition').value = String(mutation.position);
|
|
1406
|
+
document.getElementById('mutationBase').value = mutation.replacement;
|
|
1407
|
+
document.getElementById('mutationColor').value = cssColorToHex(mutation.color);
|
|
1408
|
+
clearFieldErrors(['mutationSequence', 'mutationPosition', 'mutationBase', 'mutationColor']);
|
|
1409
|
+
// open dialog and store if needed
|
|
1410
|
+
openDialog('mutationDialog', 'Changing Point Mutation ...', mutation.position, e, function(label, value) {
|
|
1411
|
+
return submitMutationForm();
|
|
1412
|
+
}, resetMutationForm);
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1415
|
+
const removeBtn = document.createElement('button');
|
|
1416
|
+
removeBtn.type = 'button';
|
|
1417
|
+
removeBtn.className = 'highlight-delete';
|
|
1418
|
+
removeBtn.setAttribute('aria-label', `Remove mutation ${mutation.id}`);
|
|
1419
|
+
removeBtn.textContent = '🗑️';
|
|
1420
|
+
removeBtn.title = 'Remove mutation';
|
|
1421
|
+
|
|
1422
|
+
removeBtn.addEventListener('click', () => {
|
|
1423
|
+
const removed = vaRRI.removePointMutation(mutation.id);
|
|
1424
|
+
if (!removed) return;
|
|
1425
|
+
if (document.getElementById('mutationEditId').value === String(mutation.id)) {
|
|
1426
|
+
resetMutationForm();
|
|
1427
|
+
}
|
|
1428
|
+
renderMutationList();
|
|
1429
|
+
runVisualization();
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
item.appendChild(info);
|
|
1433
|
+
item.appendChild(removeBtn);
|
|
1434
|
+
listEl.appendChild(item);
|
|
1435
|
+
});
|
|
1436
|
+
updateListCounter('mutationCounterUI', mutations.length);
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
function validateMutationForm() {
|
|
1441
|
+
clearFieldErrors(['mutationSequence', 'mutationPosition', 'mutationBase', 'mutationColor']);
|
|
1442
|
+
|
|
1443
|
+
const sequence = document.getElementById('mutationSequence').value;
|
|
1444
|
+
const positionInput = document.getElementById('mutationPosition').value.trim();
|
|
1445
|
+
const replacement = document.getElementById('mutationBase').value.trim();
|
|
1446
|
+
const color = document.getElementById('mutationColor').value;
|
|
1447
|
+
const editIdRaw = document.getElementById('mutationEditId').value;
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
let sequenceContext;
|
|
1451
|
+
try {
|
|
1452
|
+
sequenceContext = getSequenceContext();
|
|
1453
|
+
} catch (err) {
|
|
1454
|
+
showMsg('Please fix sequence/structure inputs first: ' + err.message, 'error');
|
|
1455
|
+
return false;
|
|
1456
|
+
}
|
|
1457
|
+
let validInput = true;
|
|
1458
|
+
|
|
1459
|
+
let position = '';
|
|
1460
|
+
if (!positionInput) {
|
|
1461
|
+
setFieldError('mutationPosition', 'Mutation position must not be empty.');
|
|
1462
|
+
validInput = false;
|
|
1463
|
+
} else {
|
|
1464
|
+
// check if position input is within sequence range
|
|
1465
|
+
try {
|
|
1466
|
+
position = vaRRI.validateOffset(positionInput);
|
|
1467
|
+
} catch (err) {
|
|
1468
|
+
setFieldError('mutationPosition', err && err.message ? err.message : String(err));
|
|
1469
|
+
validInput = false;
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
if (!replacement) {
|
|
1474
|
+
setFieldError('mutationBase', 'Replacement base must not be empty.');
|
|
1475
|
+
validInput =false;
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
if (!validInput) {
|
|
1479
|
+
return false;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
try {
|
|
1484
|
+
vaRRI.createPointMutation({ sequence, position, replacement, color }, sequenceContext);
|
|
1485
|
+
} catch (err) {
|
|
1486
|
+
const message = err && err.message ? err.message : String(err);
|
|
1487
|
+
const isSequenceError = false;///sequence/i.test(message);
|
|
1488
|
+
const isPositionError = /position|index/i.test(message);
|
|
1489
|
+
const isBaseError = /replacement|reference|nucleotide|single letter/i.test(message);
|
|
1490
|
+
|
|
1491
|
+
if (isSequenceError) setFieldError('mutationSequence', message);
|
|
1492
|
+
else if (isPositionError) setFieldError('mutationPosition', message);
|
|
1493
|
+
else if (isBaseError) setFieldError('mutationBase', message);
|
|
1494
|
+
else setFieldError('mutationColor', message);
|
|
1495
|
+
return false;
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
return true;
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
function submitMutationForm() {
|
|
1502
|
+
|
|
1503
|
+
if (!validateMutationForm()) {
|
|
1504
|
+
return false;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
const sequence = document.getElementById('mutationSequence').value;
|
|
1508
|
+
const replacement = document.getElementById('mutationBase').value.trim();
|
|
1509
|
+
const color = document.getElementById('mutationColor').value;
|
|
1510
|
+
const editIdRaw = document.getElementById('mutationEditId').value;
|
|
1511
|
+
const position = vaRRI.validateOffset(document.getElementById('mutationPosition').value.trim());
|
|
1512
|
+
|
|
1513
|
+
let sequenceContext;
|
|
1514
|
+
try {
|
|
1515
|
+
sequenceContext = getSequenceContext();
|
|
1516
|
+
} catch (err) {
|
|
1517
|
+
showMsg('Please fix sequence/structure inputs first: ' + err.message, 'error');
|
|
1518
|
+
return false;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
try {
|
|
1522
|
+
if (editIdRaw) {
|
|
1523
|
+
vaRRI.updatePointMutation(Number(editIdRaw), { sequence, position, replacement, color }, sequenceContext);
|
|
1524
|
+
} else {
|
|
1525
|
+
vaRRI.registerPointMutation({ sequence, position, replacement, color }, sequenceContext);
|
|
1526
|
+
}
|
|
1527
|
+
} catch (err) {
|
|
1528
|
+
const message = err && err.message ? err.message : String(err);
|
|
1529
|
+
const isSequenceError = /sequence/i.test(message);
|
|
1530
|
+
const isPositionError = /position|index/i.test(message);
|
|
1531
|
+
const isBaseError = /replacement|reference|nucleotide|single letter/i.test(message);
|
|
1532
|
+
|
|
1533
|
+
if (isSequenceError) setFieldError('mutationSequence', message);
|
|
1534
|
+
else if (isPositionError) setFieldError('mutationPosition', message);
|
|
1535
|
+
else if (isBaseError) setFieldError('mutationBase', message);
|
|
1536
|
+
else setFieldError('mutationColor', message);
|
|
1537
|
+
return false;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
resetMutationForm();
|
|
1541
|
+
renderMutationList();
|
|
1542
|
+
runVisualization();
|
|
1543
|
+
return true;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
/**
|
|
1547
|
+
* Parses FASTA / Dot-Bracket format strings with comment support and strict structure rules.
|
|
1548
|
+
*
|
|
1549
|
+
* @param {string} input - The raw text content to parse.
|
|
1550
|
+
* @returns {Array<Object>} Array of parsed record objects.
|
|
1551
|
+
*/
|
|
1552
|
+
function parseFasta(input) {
|
|
1553
|
+
if (typeof input !== 'string') {
|
|
1554
|
+
throw new TypeError('Input must be a string.');
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
const lines = input.split(/\r?\n/);
|
|
1558
|
+
const records = [];
|
|
1559
|
+
|
|
1560
|
+
let currentHeader = null;
|
|
1561
|
+
let currentLines = [];
|
|
1562
|
+
|
|
1563
|
+
// Allowed Alphabets
|
|
1564
|
+
const SEQ_REGEX = /^[a-zA-Z]+$/;
|
|
1565
|
+
const STRUCT_REGEX = /^[.()\[\]<>{}]+$/;
|
|
1566
|
+
|
|
1567
|
+
function finalizeRecord(header, rawContentLines) {
|
|
1568
|
+
const trimmedHeader = header.trim();
|
|
1569
|
+
const firstSpaceIdx = trimmedHeader.search(/\s/);
|
|
1570
|
+
|
|
1571
|
+
const id = firstSpaceIdx === -1 ? trimmedHeader : trimmedHeader.slice(0, firstSpaceIdx);
|
|
1572
|
+
const description = firstSpaceIdx === -1 ? '' : trimmedHeader.slice(firstSpaceIdx + 1).trim();
|
|
1573
|
+
|
|
1574
|
+
// Strip all inline whitespace from sequence/structure lines
|
|
1575
|
+
const cleanedLines = rawContentLines.map(l => l.replace(/\s+/g, ''));
|
|
1576
|
+
|
|
1577
|
+
if (cleanedLines.length === 0) {
|
|
1578
|
+
throw new Error(`Record ">${id}" contains no sequence data.`);
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
// Check if the second line is a secondary structure string
|
|
1582
|
+
const isSecondLineStructure =
|
|
1583
|
+
cleanedLines.length >= 2 &&
|
|
1584
|
+
STRUCT_REGEX.test(cleanedLines[1]) &&
|
|
1585
|
+
!SEQ_REGEX.test(cleanedLines[1]);
|
|
1586
|
+
|
|
1587
|
+
if (isSecondLineStructure) {
|
|
1588
|
+
// Rule: If structure is provided, ONLY 2 content lines (Seq + Struct) are allowed
|
|
1589
|
+
if (cleanedLines.length !== 2) {
|
|
1590
|
+
throw new Error(
|
|
1591
|
+
`Record ">${id}" has structure notation, but contains ${cleanedLines.length} lines instead of strictly 2 (1 sequence line + 1 structure line).`
|
|
1592
|
+
);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
const [sequence, structure] = cleanedLines;
|
|
1596
|
+
|
|
1597
|
+
if (!SEQ_REGEX.test(sequence)) {
|
|
1598
|
+
throw new Error(`Record ">${id}" sequence line contains invalid characters. Allowed: [a-zA-Z]`);
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
return {
|
|
1602
|
+
id,
|
|
1603
|
+
description,
|
|
1604
|
+
header: trimmedHeader,
|
|
1605
|
+
sequence,
|
|
1606
|
+
structure
|
|
1607
|
+
};
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
// Multi-line Sequence Case
|
|
1611
|
+
for (let i = 0; i < cleanedLines.length; i++) {
|
|
1612
|
+
if (!SEQ_REGEX.test(cleanedLines[i])) {
|
|
1613
|
+
throw new Error(
|
|
1614
|
+
`Record ">${id}" line ${i + 1} contains invalid characters for sequence: "${rawContentLines[i]}"`
|
|
1615
|
+
);
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
return {
|
|
1620
|
+
id,
|
|
1621
|
+
description,
|
|
1622
|
+
header: trimmedHeader,
|
|
1623
|
+
sequence: cleanedLines.join(''),
|
|
1624
|
+
structure: null
|
|
1625
|
+
};
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
// Iterative Line Parser
|
|
1629
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1630
|
+
const line = lines[i].trim();
|
|
1631
|
+
|
|
1632
|
+
// 1. Skip empty lines
|
|
1633
|
+
if (!line) continue;
|
|
1634
|
+
|
|
1635
|
+
// 2. Skip full-line comments (starting with ; or #)
|
|
1636
|
+
if (line.startsWith(';') || line.startsWith('#')) continue;
|
|
1637
|
+
|
|
1638
|
+
// 3. Header processing
|
|
1639
|
+
if (line.startsWith('>')) {
|
|
1640
|
+
if (currentHeader !== null) {
|
|
1641
|
+
records.push(finalizeRecord(currentHeader, currentLines));
|
|
1642
|
+
}
|
|
1643
|
+
currentHeader = line.slice(1);
|
|
1644
|
+
currentLines = [];
|
|
1645
|
+
} else {
|
|
1646
|
+
if (currentHeader === null) {
|
|
1647
|
+
throw new Error(`Line ${i + 1}: Found data line before record header ('>')`);
|
|
1648
|
+
}
|
|
1649
|
+
currentLines.push(line);
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
// Flush last record
|
|
1654
|
+
if (currentHeader !== null) {
|
|
1655
|
+
records.push(finalizeRecord(currentHeader, currentLines));
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
return records;
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
/**
|
|
1662
|
+
* Reads FASTA input from a sequence textarea, parses it, and populates
|
|
1663
|
+
* sequence and structure textareas with '&'-joined fields.
|
|
1664
|
+
*
|
|
1665
|
+
* @param {string} fastaId - DOM ID of the FASTA input textarea.
|
|
1666
|
+
* @param {string} sequenceId - DOM ID of the sequence textarea.
|
|
1667
|
+
* @param {string} [structureId] - DOM ID of the structure textarea (optional).
|
|
1668
|
+
* @returns {boolean} True if processing occurred, false if no FASTA input was found.
|
|
1669
|
+
* @throws Will throw an error if the FASTA input is invalid or violates the strict structure rules.
|
|
1670
|
+
*/
|
|
1671
|
+
function processFastaTextarea(fastaId, sequenceId, structureId) {
|
|
1672
|
+
const fastaElement = document.getElementById(fastaId);
|
|
1673
|
+
const seqElement = document.getElementById(sequenceId);
|
|
1674
|
+
const structElement = structureId ? document.getElementById(structureId) : null;
|
|
1675
|
+
|
|
1676
|
+
if (!seqElement || !fastaElement || !structElement) return false;
|
|
1677
|
+
|
|
1678
|
+
const input = fastaElement.value;
|
|
1679
|
+
|
|
1680
|
+
// Check whether the input starts with optional whitespace followed by '>'
|
|
1681
|
+
if (!/^\s*>/.test(input)) {
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
// Parse FASTA input (no try/catch block)
|
|
1686
|
+
const records = parseFasta(input);
|
|
1687
|
+
|
|
1688
|
+
if (!records || records.length === 0) return;
|
|
1689
|
+
|
|
1690
|
+
// "&"-join sequence fields and feed back to sequence textarea
|
|
1691
|
+
seqElement.value = records.map(r => r.sequence).join('&');
|
|
1692
|
+
|
|
1693
|
+
// Check if every record contains a valid structure string
|
|
1694
|
+
const allHaveStructure = records.every(r => typeof r.structure === 'string' && r.structure.length > 0);
|
|
1695
|
+
|
|
1696
|
+
// If all records have structure and structureId is provided, write joined structures
|
|
1697
|
+
if (allHaveStructure && structureId) {
|
|
1698
|
+
structElement.value = records.map(r => r.structure).join('&');
|
|
1699
|
+
}
|
|
1700
|
+
return true;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
function validateFields(args) {
|
|
1704
|
+
clearAllFieldErrors();
|
|
1705
|
+
clearMsg();
|
|
1706
|
+
let valid = true;
|
|
1707
|
+
|
|
1708
|
+
// Sequence
|
|
1709
|
+
if (!args.sequence) {
|
|
1710
|
+
setFieldError('sequence', 'No sequence given.');
|
|
1711
|
+
valid = false;
|
|
1712
|
+
} else {
|
|
1713
|
+
try {
|
|
1714
|
+
// if (processFastaTextarea('sequence', 'sequence', 'structure')) {
|
|
1715
|
+
// // update args with the processed values from the textareas
|
|
1716
|
+
// args.sequence = document.getElementById('sequence').value;
|
|
1717
|
+
// args.structure = document.getElementById('structure').value;
|
|
1718
|
+
// }
|
|
1719
|
+
|
|
1720
|
+
vaRRI.validateSequenceInput(args.sequence);
|
|
1721
|
+
updateHighlights('sequence');
|
|
1722
|
+
syncDimensions('sequence');
|
|
1723
|
+
syncScroll('sequence');
|
|
1724
|
+
}
|
|
1725
|
+
catch (e) { setFieldError('sequence', e.message); valid = false; }
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
// Structure
|
|
1729
|
+
if (!args.structure) {
|
|
1730
|
+
setFieldError('structure', 'No structure given.');
|
|
1731
|
+
valid = false;
|
|
1732
|
+
} else if (args.sequence) {
|
|
1733
|
+
try {
|
|
1734
|
+
vaRRI.validateStructureInput(args.structure, args.sequence);
|
|
1735
|
+
updateHighlights('structure');
|
|
1736
|
+
syncDimensions('structure');
|
|
1737
|
+
syncScroll('structure');
|
|
1738
|
+
}
|
|
1739
|
+
catch (e) { setFieldError('structure', e.message); valid = false; }
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
// Cropping
|
|
1743
|
+
if (args.cropping) {
|
|
1744
|
+
try { vaRRI.validateCroppingInput(args.structure, args.cropping); }
|
|
1745
|
+
catch (e) {
|
|
1746
|
+
setFieldError('structure', e.message);
|
|
1747
|
+
setFieldError('cropping', e.message);
|
|
1748
|
+
valid = false;
|
|
1749
|
+
}
|
|
1750
|
+
// update cropping value field "cropping-value"
|
|
1751
|
+
const croppingValueEl = document.getElementById('cropping-value');
|
|
1752
|
+
if (croppingValueEl) {
|
|
1753
|
+
croppingValueEl.textContent = args.cropping;
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
// Start index 1
|
|
1758
|
+
let offset1;
|
|
1759
|
+
try { offset1 = vaRRI.validateOffset(String(args.startIndex1 || '1')); }
|
|
1760
|
+
catch (e) { setFieldError('startIndex1', e.message); valid = false; }
|
|
1761
|
+
|
|
1762
|
+
// Start index 2
|
|
1763
|
+
let offset2;
|
|
1764
|
+
try { offset2 = vaRRI.validateOffset(String(args.startIndex2 || '1')); }
|
|
1765
|
+
catch (e) { setFieldError('startIndex2', e.message); valid = false; }
|
|
1766
|
+
|
|
1767
|
+
// Profile textarea syntax
|
|
1768
|
+
try {
|
|
1769
|
+
convertCsvProfileData('profileData1');
|
|
1770
|
+
parseProfileLines('profileData1');
|
|
1771
|
+
}
|
|
1772
|
+
catch (e) { setFieldError(e.fieldId || 'profileData1', e.message || String(e)); valid = false; }
|
|
1773
|
+
|
|
1774
|
+
try {
|
|
1775
|
+
convertCsvProfileData('profileData2');
|
|
1776
|
+
parseProfileLines('profileData2');
|
|
1777
|
+
}
|
|
1778
|
+
catch (e) { setFieldError(e.fieldId || 'profileData2', e.message || String(e)); valid = false; }
|
|
1779
|
+
|
|
1780
|
+
if (valid) {
|
|
1781
|
+
const compatibility = validateStartIndexCompatibility(args);
|
|
1782
|
+
if (!compatibility.ok) {
|
|
1783
|
+
if (compatibility.startField) setFieldError(compatibility.startField, compatibility.message);
|
|
1784
|
+
if (compatibility.focusField) setFieldError(compatibility.focusField, compatibility.message);
|
|
1785
|
+
showMsg(compatibility.message, 'error');
|
|
1786
|
+
valid = false;
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
return valid;
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
async function runVisualization() {
|
|
1794
|
+
clearMsg();
|
|
1795
|
+
applyColors();
|
|
1796
|
+
|
|
1797
|
+
const args = getBaseVisualizationArgs();
|
|
1798
|
+
|
|
1799
|
+
syncGeneratedRegionHighlight();
|
|
1800
|
+
renderRegionList();
|
|
1801
|
+
|
|
1802
|
+
if (!validateFields(args)) return;
|
|
1803
|
+
|
|
1804
|
+
let v;
|
|
1805
|
+
try {
|
|
1806
|
+
v = vaRRI.validate(args);
|
|
1807
|
+
} catch (err) {
|
|
1808
|
+
showMsg('Validation error: ' + err.message, 'error');
|
|
1809
|
+
return;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
let accessData;
|
|
1813
|
+
try {
|
|
1814
|
+
accessData = parseProfileAccessData(v, args);
|
|
1815
|
+
} catch (err) {
|
|
1816
|
+
setFieldError(err.fieldId || 'profileData1', err.message || String(err));
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
// Clear previous output
|
|
1821
|
+
const runId = ++latestVisualizationRunId;
|
|
1822
|
+
const container = document.getElementById(currentContainerId);
|
|
1823
|
+
container.innerHTML = '';
|
|
1824
|
+
container.style.visibility = 'hidden';
|
|
1825
|
+
|
|
1826
|
+
const forceLayout = document.getElementById('forceLayout').checked;
|
|
1827
|
+
const forceLayoutLinearStructure = forceLayout &&
|
|
1828
|
+
!!document.getElementById('forceLayoutLinearStructure')?.checked;
|
|
1829
|
+
const forceLayoutLinearRRI = forceLayout &&
|
|
1830
|
+
!!document.getElementById('forceLayoutLinearRRI')?.checked;
|
|
1831
|
+
const freeTrailingEnds = forceLayout && !!document.getElementById('forceLayoutFreeTails')?.checked;
|
|
1832
|
+
const pullPseudoknotBasepairs = forceLayout && !!document.getElementById('forceLayoutPullCrossing')?.checked;
|
|
1833
|
+
const accessColors = getProfileAccessibilityColors();
|
|
1834
|
+
const accessColorMode = getProfileAccessibilityMode();
|
|
1835
|
+
|
|
1836
|
+
try {
|
|
1837
|
+
const renderState = await vaRRI.render(currentContainerId, v, {
|
|
1838
|
+
forceLayout,
|
|
1839
|
+
forceLayoutLinearStructure,
|
|
1840
|
+
forceLayoutLinearRRI,
|
|
1841
|
+
freeTrailingEnds,
|
|
1842
|
+
pullPseudoknotBasepairs,
|
|
1843
|
+
accessData,
|
|
1844
|
+
accessColors,
|
|
1845
|
+
accessColorMode,
|
|
1846
|
+
});
|
|
1847
|
+
if (runId !== latestVisualizationRunId || renderState?.cancelled) return;
|
|
1848
|
+
container.style.visibility = '';
|
|
1849
|
+
|
|
1850
|
+
renderRegionList();
|
|
1851
|
+
showMsg('Visualisation ready. Use the export buttons to save.', 'success');
|
|
1852
|
+
} catch (err) {
|
|
1853
|
+
if (runId !== latestVisualizationRunId) return;
|
|
1854
|
+
container.style.visibility = '';
|
|
1855
|
+
renderRegionList();
|
|
1856
|
+
showMsg('Render error: ' + err.message, 'error');
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
function queueVisualization(delay = 0) {
|
|
1861
|
+
if (autoVisualizationTimeoutId !== null) {
|
|
1862
|
+
clearTimeout(autoVisualizationTimeoutId);
|
|
1863
|
+
autoVisualizationTimeoutId = null;
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
if (delay < 1) {
|
|
1867
|
+
runVisualization();
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
autoVisualizationTimeoutId = setTimeout(() => {
|
|
1872
|
+
autoVisualizationTimeoutId = null;
|
|
1873
|
+
runVisualization();
|
|
1874
|
+
}, delay);
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
function syncAnimationDependentControls() {
|
|
1878
|
+
const animationCheckbox = document.getElementById('forceLayout');
|
|
1879
|
+
if (!animationCheckbox) return;
|
|
1880
|
+
|
|
1881
|
+
const enabled = !!animationCheckbox.checked;
|
|
1882
|
+
|
|
1883
|
+
const linearCheckboxes = [
|
|
1884
|
+
document.getElementById('forceLayoutLinearStructure'),
|
|
1885
|
+
document.getElementById('forceLayoutLinearRRI'),
|
|
1886
|
+
];
|
|
1887
|
+
|
|
1888
|
+
// Linear layouts can turn force layout on themselves. Turning force layout
|
|
1889
|
+
// off explicitly clears both while leaving them available for the next use.
|
|
1890
|
+
linearCheckboxes.forEach(checkbox => {
|
|
1891
|
+
if (!checkbox) return;
|
|
1892
|
+
checkbox.disabled = false;
|
|
1893
|
+
if (!enabled) checkbox.checked = false;
|
|
1894
|
+
});
|
|
1895
|
+
|
|
1896
|
+
[
|
|
1897
|
+
document.getElementById('forceLayoutFreeTails'),
|
|
1898
|
+
document.getElementById('forceLayoutPullCrossing'),
|
|
1899
|
+
].forEach(checkbox => {
|
|
1900
|
+
if (!checkbox) return;
|
|
1901
|
+
checkbox.disabled = !enabled;
|
|
1902
|
+
if (!enabled) checkbox.checked = false;
|
|
1903
|
+
});
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
function enableForceLayoutForSelectedLinearOptions() {
|
|
1907
|
+
const forceLayout = document.getElementById('forceLayout');
|
|
1908
|
+
if (!forceLayout) return;
|
|
1909
|
+
|
|
1910
|
+
if (document.getElementById('forceLayoutLinearStructure')?.checked ||
|
|
1911
|
+
document.getElementById('forceLayoutLinearRRI')?.checked) {
|
|
1912
|
+
forceLayout.checked = true;
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
/* ##################### DIALOGS ##################### */
|
|
1917
|
+
|
|
1918
|
+
|
|
1919
|
+
|
|
1920
|
+
/**
|
|
1921
|
+
* Initialisiert Live-Validierung auf einer Liste von Eingabefeldern.
|
|
1922
|
+
*
|
|
1923
|
+
* @param {Array<string|HTMLElement>} fields - Array von Feld-IDs oder DOM-Elementen
|
|
1924
|
+
* @param {Function} validationFn - Validierungsfunktion, die bei Änderung aufgerufen wird
|
|
1925
|
+
*/
|
|
1926
|
+
function initDialogLiveValidation(fields, validationFn, defaultEventType) {
|
|
1927
|
+
if (!fields || typeof validationFn !== 'function') return;
|
|
1928
|
+
|
|
1929
|
+
for (var i = 0; i < fields.length; i++) {
|
|
1930
|
+
var field = fields[i];
|
|
1931
|
+
var el = typeof field === 'string' ? document.getElementById(field) : field;
|
|
1932
|
+
if (!el) continue;
|
|
1933
|
+
|
|
1934
|
+
// 'change' für Select-Boxen & Farbwähler, 'input' für Text-/Zahlen-Eingaben
|
|
1935
|
+
var eventType = (el.tagName === 'SELECT' || el.type === 'color' || el.type === 'checkbox') ? 'change' : (defaultEventType ? defaultEventType : 'input');
|
|
1936
|
+
|
|
1937
|
+
el.addEventListener(eventType, function() {
|
|
1938
|
+
validationFn();
|
|
1939
|
+
});
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
/**
|
|
1944
|
+
* Öffnet einen Dialog an der Klick-Position.
|
|
1945
|
+
*
|
|
1946
|
+
* @param {string} dialogId - Basis-ID des <dialog>-Elements
|
|
1947
|
+
* @param {string} dialogHeader - Überschrift des Dialogs
|
|
1948
|
+
* @param {string|number} initialValue - Startwert für das Input-Feld
|
|
1949
|
+
* @param {MouseEvent} clickEvent - Das Klick-Event für die Positionsbestimmung
|
|
1950
|
+
* @param {Function} callback - Wird bei Klick auf "OK" ausgeführt
|
|
1951
|
+
* @param {Function} onCancel - Wird bei Klick auf "Cancel" ausgeführt
|
|
1952
|
+
*/
|
|
1953
|
+
function openDialog(dialogId, title, initialValue, clickEvent, onConfirm, onCancel) {
|
|
1954
|
+
var dialog = document.getElementById(dialogId);
|
|
1955
|
+
if (!dialog) return;
|
|
1956
|
+
makeDialogDraggable(dialog);
|
|
1957
|
+
|
|
1958
|
+
var form = dialog.querySelector('form');
|
|
1959
|
+
var firstInput = dialog.querySelector('input, select');
|
|
1960
|
+
|
|
1961
|
+
var header = dialog.querySelector('.dialog-header');
|
|
1962
|
+
if (header && title) {
|
|
1963
|
+
header.textContent = title;
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
//if (firstInput && initialValue !== null && initialValue !== undefined) {
|
|
1967
|
+
// firstInput.value = initialValue;
|
|
1968
|
+
//}
|
|
1969
|
+
var input = dialog.querySelector('input');
|
|
1970
|
+
if (input) input.value = initialValue !== undefined ? initialValue : '';
|
|
1971
|
+
|
|
1972
|
+
if (clickEvent) {
|
|
1973
|
+
dialog.style.left = Math.min(clickEvent.clientX, window.innerWidth - 250) + 'px';
|
|
1974
|
+
dialog.style.top = Math.min(clickEvent.clientY, window.innerHeight - 150) + 'px';
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
// Interzeptiert den Form-Submit VOR dem Schließen
|
|
1978
|
+
if (form) {
|
|
1979
|
+
form.onsubmit = function(e) {
|
|
1980
|
+
var submitter = e.submitter || document.activeElement;
|
|
1981
|
+
var isCancel = submitter && submitter.value === 'cancel';
|
|
1982
|
+
|
|
1983
|
+
// Nur validieren, wenn nicht "Cancel" geklickt wurde
|
|
1984
|
+
if (!isCancel && onConfirm) {
|
|
1985
|
+
var isValid = onConfirm(input ? input.value : '');
|
|
1986
|
+
if (!isValid) {
|
|
1987
|
+
e.preventDefault(); // Stoppt das Schließen des Dialogs!
|
|
1988
|
+
return false;
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
};
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
// 2. Schließen auswerten (Cancel-Button ODER ESC-Taste)
|
|
1995
|
+
dialog.onclose = function() {
|
|
1996
|
+
if (dialog.returnValue !== 'ok') {
|
|
1997
|
+
if (typeof onCancel === 'function') {
|
|
1998
|
+
onCancel();
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
};
|
|
2002
|
+
|
|
2003
|
+
dialog.showModal();
|
|
2004
|
+
if (input) { input.focus(); input.select(); }
|
|
2005
|
+
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
/**
|
|
2009
|
+
* Macht ein Dialog-Element über seinen .dialog-header verschiebbar.
|
|
2010
|
+
*
|
|
2011
|
+
* @param {HTMLElement|string} dialogRef - Das Dialog DOM-Element oder dessen ID
|
|
2012
|
+
*/
|
|
2013
|
+
function makeDialogDraggable(dialogRef) {
|
|
2014
|
+
var dialog = typeof dialogRef === 'string' ? document.getElementById(dialogRef) : dialogRef;
|
|
2015
|
+
if (!dialog) return;
|
|
2016
|
+
|
|
2017
|
+
var header = dialog.querySelector('.dialog-header');
|
|
2018
|
+
if (!header || header._isDraggableInitialized) return;
|
|
2019
|
+
|
|
2020
|
+
header._isDraggableInitialized = true;
|
|
2021
|
+
var offsetX = 0;
|
|
2022
|
+
var offsetY = 0;
|
|
2023
|
+
|
|
2024
|
+
header.addEventListener('pointerdown', function(e) {
|
|
2025
|
+
// Interaktion ignorieren, falls im Header Buttons geklickt werden
|
|
2026
|
+
if (e.target.tagName === 'BUTTON' || e.target.tagName === 'INPUT') return;
|
|
2027
|
+
|
|
2028
|
+
var rect = dialog.getBoundingClientRect();
|
|
2029
|
+
offsetX = e.clientX - rect.left;
|
|
2030
|
+
offsetY = e.clientY - rect.top;
|
|
2031
|
+
|
|
2032
|
+
header.setPointerCapture(e.pointerId);
|
|
2033
|
+
|
|
2034
|
+
function onPointerMove(pe) {
|
|
2035
|
+
var newLeft = pe.clientX - offsetX;
|
|
2036
|
+
var newTop = pe.clientY - offsetY;
|
|
2037
|
+
|
|
2038
|
+
// Position direkt am Element aktualisieren
|
|
2039
|
+
dialog.style.left = newLeft + 'px';
|
|
2040
|
+
dialog.style.top = newTop + 'px';
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
function onPointerUp(pe) {
|
|
2044
|
+
header.releasePointerCapture(pe.pointerId);
|
|
2045
|
+
header.removeEventListener('pointermove', onPointerMove);
|
|
2046
|
+
header.removeEventListener('pointerup', onPointerUp);
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
header.addEventListener('pointermove', onPointerMove);
|
|
2050
|
+
header.addEventListener('pointerup', onPointerUp);
|
|
2051
|
+
});
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
/**
|
|
2055
|
+
* Attaches a respective dialog directly to an ID or DOM element.
|
|
2056
|
+
*/
|
|
2057
|
+
function bindDialog(dialogType, targetId, dialogHeader, onUpdate, onCancel) {
|
|
2058
|
+
var element = typeof targetId === 'string' ? document.getElementById(targetId) : targetId;
|
|
2059
|
+
if (!element) return;
|
|
2060
|
+
|
|
2061
|
+
element.addEventListener('click', function(e) {
|
|
2062
|
+
var currentValue = element.value !== undefined ? element.value : element.textContent;
|
|
2063
|
+
|
|
2064
|
+
openDialog(dialogType, dialogHeader, currentValue, e, function(newValue) {
|
|
2065
|
+
if (onUpdate) {
|
|
2066
|
+
// Custom update behavior if provided
|
|
2067
|
+
return onUpdate(element, newValue);
|
|
2068
|
+
} else if (element.value !== undefined) {
|
|
2069
|
+
element.value = newValue;
|
|
2070
|
+
return true;
|
|
2071
|
+
} else {
|
|
2072
|
+
element.textContent = newValue;
|
|
2073
|
+
return true;
|
|
2074
|
+
}
|
|
2075
|
+
}, onCancel);
|
|
2076
|
+
});
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
|
|
2080
|
+
function attachDialogListeners() {
|
|
2081
|
+
// Simple input:
|
|
2082
|
+
//bindDialog('numberDialog', 'numInput');
|
|
2083
|
+
|
|
2084
|
+
// add highlight dialog to the highlight range input
|
|
2085
|
+
initDialogLiveValidation( ['subseqSequence', 'subseqRange', 'subseqColor', 'subseqAlpha'], validateSubseqForm );
|
|
2086
|
+
bindDialog('subseqHighlightDialog', 'highlightSubmitBtn', 'Highlight Subsequence ...', function(label, newValue) {
|
|
2087
|
+
return submitSubseqForm();
|
|
2088
|
+
}, resetSubseqForm);
|
|
2089
|
+
initDialogLiveValidation( ['region1', 'region2', 'regionColor', 'regionAlpha'], validateRegionForm );
|
|
2090
|
+
bindDialog('regionHighlightDialog', 'regionSubmitBtn', 'Highlight RRI Region ...', function(label, newValue) {
|
|
2091
|
+
return submitRegionForm();
|
|
2092
|
+
}, resetRegionForm);
|
|
2093
|
+
initDialogLiveValidation( ['mutationSequence', 'mutationPosition', 'mutationBase', 'mutationColor'], validateMutationForm );
|
|
2094
|
+
bindDialog('mutationDialog', 'mutationSubmitBtn', 'Define Point Mutation ...', function(label, newValue) {
|
|
2095
|
+
return submitMutationForm();
|
|
2096
|
+
}, resetMutationForm);
|
|
2097
|
+
|
|
2098
|
+
initDialogLiveValidation( ['fastaInput'], validateFastaForm, 'input' );
|
|
2099
|
+
initDialogLiveValidation( ['fastaInput'], validateFastaForm, 'change' );
|
|
2100
|
+
bindDialog('fastaDialog', 'fastaInputBtn', null, function(label, newValue) {
|
|
2101
|
+
return submitFastaForm();
|
|
2102
|
+
}, resetFastaForm);
|
|
2103
|
+
|
|
2104
|
+
// Slider label with custom handler:
|
|
2105
|
+
bindDialog('numberDialog', 'rotation', 'Rotate ...°', function(label, newValue) {
|
|
2106
|
+
committedRotationDeg = Number(newValue);
|
|
2107
|
+
document.getElementById('rotationSlider').dispatchEvent(new Event('change', { bubbles: true }));
|
|
2108
|
+
return true;
|
|
2109
|
+
});
|
|
2110
|
+
bindDialog('numberDialog', 'cropping-value', 'Crop to ... free nt', function(label, newValue) {
|
|
2111
|
+
document.getElementById('cropping').value = newValue;
|
|
2112
|
+
document.getElementById('cropping').dispatchEvent(new Event('change', { bubbles: true }));
|
|
2113
|
+
return true;
|
|
2114
|
+
});
|
|
2115
|
+
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
/* ################################################### */
|
|
2119
|
+
|
|
2120
|
+
function attachAutoVisualizationListeners() {
|
|
2121
|
+
const committedFieldIds = [
|
|
2122
|
+
'structure',
|
|
2123
|
+
'sequence',
|
|
2124
|
+
'cropping',
|
|
2125
|
+
'startIndex1',
|
|
2126
|
+
'startIndex2',
|
|
2127
|
+
'mutationPosition',
|
|
2128
|
+
'profileData1',
|
|
2129
|
+
'profileData2',
|
|
2130
|
+
];
|
|
2131
|
+
const listenerConfig = {
|
|
2132
|
+
coloring: { eventName: 'change' },
|
|
2133
|
+
highlighting: { eventName: 'change' },
|
|
2134
|
+
backgroundhighlighting: { eventName: 'change' },
|
|
2135
|
+
distinctBpTypes: { eventName: 'change' },
|
|
2136
|
+
forceLayout: { eventName: 'change' },
|
|
2137
|
+
forceLayoutFreeTails: { eventName: 'change' },
|
|
2138
|
+
forceLayoutPullCrossing: { eventName: 'change' },
|
|
2139
|
+
forceLayoutLinearStructure: { eventName: 'change' },
|
|
2140
|
+
forceLayoutLinearRRI: { eventName: 'change' },
|
|
2141
|
+
profileColor1: { eventName: 'change' },
|
|
2142
|
+
profileColor2: { eventName: 'change' },
|
|
2143
|
+
profileColorRepresentsOne1: { eventName: 'change' },
|
|
2144
|
+
profileColorRepresentsOne2: { eventName: 'change' },
|
|
2145
|
+
profileIdxRef1: { eventName: 'change' },
|
|
2146
|
+
profileIdxRef2: { eventName: 'change' },
|
|
2147
|
+
};
|
|
2148
|
+
|
|
2149
|
+
committedFieldIds.forEach(id => {
|
|
2150
|
+
const el = document.getElementById(id);
|
|
2151
|
+
if (!el) return;
|
|
2152
|
+
|
|
2153
|
+
el.addEventListener('input', () => {
|
|
2154
|
+
clearFieldError(id);
|
|
2155
|
+
});
|
|
2156
|
+
|
|
2157
|
+
el.addEventListener('change', () => {
|
|
2158
|
+
const skippedValue = skipNextCommittedFieldChange.get(el);
|
|
2159
|
+
if (skippedValue !== undefined) {
|
|
2160
|
+
skipNextCommittedFieldChange.delete(el);
|
|
2161
|
+
if (skippedValue === el.value) return;
|
|
2162
|
+
}
|
|
2163
|
+
clearFieldError(id);
|
|
2164
|
+
queueVisualization();
|
|
2165
|
+
});
|
|
2166
|
+
|
|
2167
|
+
if (el.tagName === 'TEXTAREA') return;
|
|
2168
|
+
|
|
2169
|
+
el.addEventListener('keydown', event => {
|
|
2170
|
+
if (event.key !== 'Enter') return;
|
|
2171
|
+
skipNextCommittedFieldChange.set(el, el.value);
|
|
2172
|
+
clearFieldError(id);
|
|
2173
|
+
queueVisualization();
|
|
2174
|
+
});
|
|
2175
|
+
});
|
|
2176
|
+
|
|
2177
|
+
Object.entries(listenerConfig).forEach(([id, config]) => {
|
|
2178
|
+
const el = document.getElementById(id);
|
|
2179
|
+
if (!el) return;
|
|
2180
|
+
|
|
2181
|
+
el.addEventListener(config.eventName, () => {
|
|
2182
|
+
if ((id === 'forceLayoutLinearStructure' || id === 'forceLayoutLinearRRI') && el.checked) {
|
|
2183
|
+
enableForceLayoutForSelectedLinearOptions();
|
|
2184
|
+
syncAnimationDependentControls();
|
|
2185
|
+
}
|
|
2186
|
+
if (id === 'forceLayout') syncAnimationDependentControls();
|
|
2187
|
+
clearFieldError(id);
|
|
2188
|
+
queueVisualization();
|
|
2189
|
+
});
|
|
2190
|
+
});
|
|
2191
|
+
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
// -------------------------------------------------------------------------
|
|
2195
|
+
// Export
|
|
2196
|
+
// -------------------------------------------------------------------------
|
|
2197
|
+
function exportSVG() {
|
|
2198
|
+
try {
|
|
2199
|
+
vaRRI.downloadSVG(currentContainerId, 'vaRRI_output.svg');
|
|
2200
|
+
} catch (err) {
|
|
2201
|
+
showMsg('Export error: ' + err.message, 'error');
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
function exportPNG() {
|
|
2206
|
+
try {
|
|
2207
|
+
vaRRI.downloadPNG(currentContainerId, 'vaRRI_output.png');
|
|
2208
|
+
} catch (err) {
|
|
2209
|
+
showMsg('Export error: ' + err.message, 'error');
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
|
|
2214
|
+
// -------------------------------------------------------------------------
|
|
2215
|
+
// Drag-and-drop file loading
|
|
2216
|
+
// -------------------------------------------------------------------------
|
|
2217
|
+
|
|
2218
|
+
function setupFileDragAndDrop(textareaId) {
|
|
2219
|
+
const el = document.getElementById(textareaId);
|
|
2220
|
+
if (!el) return;
|
|
2221
|
+
|
|
2222
|
+
['dragenter', 'dragover'].forEach(eventName => {
|
|
2223
|
+
el.addEventListener(eventName, (e) => {
|
|
2224
|
+
e.preventDefault();
|
|
2225
|
+
e.stopPropagation();
|
|
2226
|
+
el.classList.add('drag-over');
|
|
2227
|
+
});
|
|
2228
|
+
});
|
|
2229
|
+
|
|
2230
|
+
['dragleave', 'dragend'].forEach(eventName => {
|
|
2231
|
+
el.addEventListener(eventName, (e) => {
|
|
2232
|
+
e.preventDefault();
|
|
2233
|
+
e.stopPropagation();
|
|
2234
|
+
el.classList.remove('drag-over');
|
|
2235
|
+
});
|
|
2236
|
+
});
|
|
2237
|
+
|
|
2238
|
+
el.addEventListener('drop', (e) => {
|
|
2239
|
+
e.preventDefault();
|
|
2240
|
+
e.stopPropagation();
|
|
2241
|
+
el.classList.remove('drag-over');
|
|
2242
|
+
|
|
2243
|
+
const files = e.dataTransfer && e.dataTransfer.files;
|
|
2244
|
+
if (!files || files.length === 0) return;
|
|
2245
|
+
|
|
2246
|
+
const file = files[0];
|
|
2247
|
+
const reader = new FileReader();
|
|
2248
|
+
reader.onload = (event) => {
|
|
2249
|
+
el.value = (event.target && event.target.result) || '';
|
|
2250
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
2251
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
2252
|
+
};
|
|
2253
|
+
reader.readAsText(file);
|
|
2254
|
+
});
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
|
|
2258
|
+
|
|
2259
|
+
// ------------------------------------------------------------------------
|
|
2260
|
+
// URL parameter generation
|
|
2261
|
+
// ------------------------------------------------------------------------
|
|
2262
|
+
/**
|
|
2263
|
+
* Generate a share link by scanning supported form controls.
|
|
2264
|
+
* UI-only controls are excluded through UI_ONLY_FIELDS.
|
|
2265
|
+
*
|
|
2266
|
+
* @param {HTMLElement} btnElement - Optional button used for copy feedback
|
|
2267
|
+
*/
|
|
2268
|
+
function getRegisteredAnnotations(getterName) {
|
|
2269
|
+
if (typeof vaRRI === 'undefined' || typeof vaRRI[getterName] !== 'function') return [];
|
|
2270
|
+
return vaRRI[getterName]();
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
function encodeAnnotationStyle(color, alpha) {
|
|
2274
|
+
const colorValue = color ? color.replace('#', '') : '';
|
|
2275
|
+
if (!colorValue) return '';
|
|
2276
|
+
const alphaValue = alpha !== undefined && alpha !== null ? ':' + alpha : '';
|
|
2277
|
+
return ':' + colorValue + alphaValue;
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
function generateShareableURL() {
|
|
2281
|
+
const params = new URLSearchParams();
|
|
2282
|
+
|
|
2283
|
+
// Exclude transient UI controls.
|
|
2284
|
+
const blacklist = new Set(UI_ONLY_FIELDS);
|
|
2285
|
+
|
|
2286
|
+
// Collect supported form controls.
|
|
2287
|
+
const formElements = document.querySelectorAll('input, textarea, select');
|
|
2288
|
+
|
|
2289
|
+
formElements.forEach(el => {
|
|
2290
|
+
const id = el.id;
|
|
2291
|
+
|
|
2292
|
+
if (!id || blacklist.has(id)) return;
|
|
2293
|
+
|
|
2294
|
+
if (['button', 'submit', 'reset', 'file'].includes(el.type)) return;
|
|
2295
|
+
|
|
2296
|
+
// Encode toggles explicitly.
|
|
2297
|
+
if (el.type === 'checkbox' || el.type === 'radio') {
|
|
2298
|
+
params.append(id, el.checked ? '1' : '0');
|
|
2299
|
+
return;
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
// Encode non-empty text and selection values.
|
|
2303
|
+
if (typeof el.value === 'string') {
|
|
2304
|
+
const val = el.value.trim();
|
|
2305
|
+
if (val !== '') {
|
|
2306
|
+
params.append(id, val);
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
});
|
|
2310
|
+
|
|
2311
|
+
// Rotation
|
|
2312
|
+
const rotationValue = committedRotationDeg !== null ? committedRotationDeg : 0;
|
|
2313
|
+
if (rotationValue !== 0) {
|
|
2314
|
+
params.append('rotation', String(rotationValue));
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
// Point mutations: "<seq>:<pos><replacement>[:<color>]"
|
|
2318
|
+
const mutations = getRegisteredAnnotations('getPointMutations');
|
|
2319
|
+
if (mutations && mutations.length > 0) {
|
|
2320
|
+
const encodedMutations = mutations.map(m => {
|
|
2321
|
+
return m.sequence + ':' + m.position + m.replacement + encodeAnnotationStyle(m.color);
|
|
2322
|
+
}).join(',');
|
|
2323
|
+
|
|
2324
|
+
params.append('mutations', encodedMutations);
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
// Subsequence highlights: "<seq>:<start>-<end>[:<color>[:<alpha>]]"
|
|
2328
|
+
const highlights = getRegisteredAnnotations('getSubsequenceHighlights');
|
|
2329
|
+
if (highlights && highlights.length > 0) {
|
|
2330
|
+
const encodedHighlights = highlights
|
|
2331
|
+
.map(h => {
|
|
2332
|
+
const annotationStyle = encodeAnnotationStyle(h.color, h.alpha);
|
|
2333
|
+
const sequence = h.sequence || '1';
|
|
2334
|
+
|
|
2335
|
+
// Prefer explicit rangeText if available (e.g., "18-20")
|
|
2336
|
+
if (typeof h.rangeText === 'string' && h.rangeText && h.rangeText !== 'undefined') {
|
|
2337
|
+
return `${sequence}:${h.rangeText}${annotationStyle}`;
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
// Fall back to nested ranges array (e.g., [[18, 20]])
|
|
2341
|
+
if (Array.isArray(h.range) && h.range.length > 0) {
|
|
2342
|
+
return h.range.map(r => {
|
|
2343
|
+
const rangeStr = Array.isArray(r) ? `${r[0]}-${r[1]}` : r;
|
|
2344
|
+
return `${sequence}:${rangeStr}${annotationStyle}`;
|
|
2345
|
+
}).join(',');
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
// Finally, fall back to single range property
|
|
2349
|
+
if (typeof h.range === 'string' && h.range !== 'undefined') {
|
|
2350
|
+
return `${sequence}:${h.range}${annotationStyle}`;
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
return null;
|
|
2354
|
+
})
|
|
2355
|
+
.filter(Boolean)
|
|
2356
|
+
.join(',');
|
|
2357
|
+
|
|
2358
|
+
if (encodedHighlights) {
|
|
2359
|
+
params.append('subseqHighlights', encodedHighlights);
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
// Region highlights: "<start1>-<end1>&<start2>-<end2>[:<color>[:<alpha>]]"
|
|
2364
|
+
const regionHighlights = getRegisteredAnnotations('getRegionHighlights');
|
|
2365
|
+
if (regionHighlights && regionHighlights.length > 0) {
|
|
2366
|
+
const encodedRegionHighlights = regionHighlights
|
|
2367
|
+
.filter(h => !h.generated)
|
|
2368
|
+
.map(h => {
|
|
2369
|
+
const annotationStyle = encodeAnnotationStyle(h.color, h.alpha);
|
|
2370
|
+
const rangeText = typeof h.rangeText === 'string' && h.rangeText && h.rangeText !== 'undefined'
|
|
2371
|
+
? h.rangeText
|
|
2372
|
+
: `${Array.isArray(h.sequence1Range) ? h.sequence1Range.join('-') : ''}&${Array.isArray(h.sequence2Range) ? h.sequence2Range.join('-') : ''}`;
|
|
2373
|
+
|
|
2374
|
+
return `${rangeText}${annotationStyle}`;
|
|
2375
|
+
})
|
|
2376
|
+
.filter(Boolean)
|
|
2377
|
+
.join(',');
|
|
2378
|
+
|
|
2379
|
+
if (encodedRegionHighlights) {
|
|
2380
|
+
params.append('regionHighlights', encodedRegionHighlights);
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
const queryString = params.toString();
|
|
2385
|
+
if (!queryString) return;
|
|
2386
|
+
|
|
2387
|
+
// Preserve local file paths while stripping any existing query or fragment.
|
|
2388
|
+
let baseUrl;
|
|
2389
|
+
if (window.location.protocol === 'file:') {
|
|
2390
|
+
baseUrl = window.location.href.split('?')[0].split('#')[0];
|
|
2391
|
+
} else {
|
|
2392
|
+
baseUrl = `${window.location.origin}${window.location.pathname}`;
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
const shareableURL = `${baseUrl}?${queryString}`;
|
|
2396
|
+
|
|
2397
|
+
return shareableURL;
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
function generateShareableURLToClipboard(btnElement) {
|
|
2401
|
+
|
|
2402
|
+
const shareableURL = generateShareableURL();
|
|
2403
|
+
if (!shareableURL) {
|
|
2404
|
+
showMsg('No shareable URL could be generated. Please ensure that at least one form field has a value.', 'error');
|
|
2405
|
+
return;
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
// Copy the URL and briefly confirm success in the triggering button.
|
|
2409
|
+
navigator.clipboard.writeText(shareableURL)
|
|
2410
|
+
.then(() => {
|
|
2411
|
+
if (btnElement) {
|
|
2412
|
+
const originalText = btnElement.innerHTML;
|
|
2413
|
+
btnElement.innerHTML = '✓ Copied!';
|
|
2414
|
+
setTimeout(() => { btnElement.innerHTML = originalText; }, 2000);
|
|
2415
|
+
}
|
|
2416
|
+
})
|
|
2417
|
+
.catch(err => {
|
|
2418
|
+
console.error('Could not copy link to clipboard:', err);
|
|
2419
|
+
prompt('Copy your shareable URL below:', shareableURL);
|
|
2420
|
+
});
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
// ------------------------------------------------------------------------
|
|
2424
|
+
// URL parameter loading
|
|
2425
|
+
// ------------------------------------------------------------------------
|
|
2426
|
+
|
|
2427
|
+
/**
|
|
2428
|
+
* Helper to parse hex colors cleanly (restores '#' if missing).
|
|
2429
|
+
*/
|
|
2430
|
+
function parseUrlColor(colorStr, defaultColor) {
|
|
2431
|
+
if (!colorStr) return defaultColor;
|
|
2432
|
+
const clean = colorStr.trim().replace('#', '').toUpperCase();
|
|
2433
|
+
return /^[0-9A-F]{3,8}$/i.test(clean) ? `#${clean}` : defaultColor;
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
function getUrlListParameter(urlParams, argName) {
|
|
2437
|
+
const value = urlParams.get(argName) || '';
|
|
2438
|
+
return value.split(',').map(item => item.trim()).filter(Boolean);
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
/**
|
|
2442
|
+
* Checks whether point mutations are present in the URL parameters and loads them into vaRRI.
|
|
2443
|
+
* Expected encoding: "<seq>:<pos><character>[:<color>]", comma-separated.
|
|
2444
|
+
*/
|
|
2445
|
+
function loadUrlMutationsToVaRRI(argName = 'mutations', urlParams = new URLSearchParams(window.location.search)) {
|
|
2446
|
+
if (urlParams.has(argName)) {
|
|
2447
|
+
const mutations = getUrlListParameter(urlParams, argName);
|
|
2448
|
+
|
|
2449
|
+
mutations.forEach(mutation => {
|
|
2450
|
+
const match = mutation.match(/^([12]):(-?\d+)(.)(?::([0-9a-fA-F]{3,8}))?$/i);
|
|
2451
|
+
if (match) {
|
|
2452
|
+
const sequence = match[1];
|
|
2453
|
+
const position = Number.parseInt(match[2], 10);
|
|
2454
|
+
const replacement = match[3];
|
|
2455
|
+
const color = parseUrlColor(match[4], getDefaultMutationColor());
|
|
2456
|
+
|
|
2457
|
+
try {
|
|
2458
|
+
vaRRI.registerPointMutation(
|
|
2459
|
+
{ sequence, position, replacement, color },
|
|
2460
|
+
getSequenceContext()
|
|
2461
|
+
);
|
|
2462
|
+
} catch (err) {
|
|
2463
|
+
console.warn(`Failed to register mutation from URL parameter: ${mutation}. Error: ${err.message}`);
|
|
2464
|
+
}
|
|
2465
|
+
} else {
|
|
2466
|
+
console.warn(`Invalid mutation format in URL parameter: ${mutation}`);
|
|
2467
|
+
}
|
|
2468
|
+
});
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
/**
|
|
2473
|
+
* Checks whether subsequence highlights are present in the URL parameters and loads them into vaRRI.
|
|
2474
|
+
* Expected encoding: "<seq>:<start>-<end>[:<color>[:<alpha>]]", comma-separated.
|
|
2475
|
+
*/
|
|
2476
|
+
function loadUrlSubsequenceHighlightsToVaRRI(argName = 'subseqHighlights', urlParams = new URLSearchParams(window.location.search)) {
|
|
2477
|
+
if (urlParams.has(argName)) {
|
|
2478
|
+
const highlights = getUrlListParameter(urlParams, argName);
|
|
2479
|
+
|
|
2480
|
+
highlights.forEach(highlight => {
|
|
2481
|
+
const match = highlight.match(/^([12]):(-?\d+)-(-?\d+)(?::([0-9a-fA-F]{3,8})(?::([01](?:\.\d+)?))?)?$/i);
|
|
2482
|
+
if (match) {
|
|
2483
|
+
const sequence = match[1];
|
|
2484
|
+
const start = Number.parseInt(match[2], 10);
|
|
2485
|
+
const end = Number.parseInt(match[3], 10);
|
|
2486
|
+
const color = parseUrlColor(match[4], getDefaultSubsequenceHighlightColor());
|
|
2487
|
+
const alpha = match[5] !== undefined ? Number.parseFloat(match[5]) : DEFAULT_VALUES.subseqAlpha;
|
|
2488
|
+
|
|
2489
|
+
try {
|
|
2490
|
+
vaRRI.registerSubsequenceHighlight(
|
|
2491
|
+
{ sequence, range: `${start}-${end}`, color, alpha },
|
|
2492
|
+
getSequenceContext()
|
|
2493
|
+
);
|
|
2494
|
+
} catch (err) {
|
|
2495
|
+
console.warn(`Failed to register subsequence highlight from URL parameter: ${highlight}. Error: ${err.message}`);
|
|
2496
|
+
}
|
|
2497
|
+
} else {
|
|
2498
|
+
console.warn(`Invalid subsequence highlight format in URL parameter: ${highlight}`);
|
|
2499
|
+
}
|
|
2500
|
+
});
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
/**
|
|
2505
|
+
* Checks whether region highlights are present in the URL parameters and loads them into vaRRI.
|
|
2506
|
+
* Expected encoding: "<start1>-<end1>&<start2>-<end2>[:<color>[:<alpha>]]", comma-separated.
|
|
2507
|
+
*/
|
|
2508
|
+
function loadUrlRegionHighlightsToVaRRI(argName = 'regionHighlights', urlParams = new URLSearchParams(window.location.search)) {
|
|
2509
|
+
if (urlParams.has(argName)) {
|
|
2510
|
+
const regionHighlights = getUrlListParameter(urlParams, argName);
|
|
2511
|
+
|
|
2512
|
+
regionHighlights.forEach(region => {
|
|
2513
|
+
const match = region.match(/^(-?\d+)-(-?\d+)&(-?\d+)-(-?\d+)(?::([0-9a-fA-F]{3,8})(?::([01](?:\.\d+)?))?)?$/i);
|
|
2514
|
+
if (match) {
|
|
2515
|
+
const sequence1Range = [Number.parseInt(match[1], 10), Number.parseInt(match[2], 10)];
|
|
2516
|
+
const sequence2Range = [Number.parseInt(match[3], 10), Number.parseInt(match[4], 10)];
|
|
2517
|
+
const color = parseUrlColor(match[5], getDefaultRegionHighlightColor());
|
|
2518
|
+
const alpha = match[6] !== undefined ? Number.parseFloat(match[6]) : DEFAULT_VALUES.regionAlpha;
|
|
2519
|
+
|
|
2520
|
+
try {
|
|
2521
|
+
vaRRI.registerRegionHighlight(
|
|
2522
|
+
{ sequence1Range, sequence2Range, color, alpha },
|
|
2523
|
+
getSequenceContext()
|
|
2524
|
+
);
|
|
2525
|
+
} catch (err) {
|
|
2526
|
+
console.warn(`Failed to register region highlight from URL parameter: ${region}. Error: ${err.message}`);
|
|
2527
|
+
}
|
|
2528
|
+
} else {
|
|
2529
|
+
console.warn(`Invalid region highlight format in URL parameter: ${region}`);
|
|
2530
|
+
}
|
|
2531
|
+
});
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
|
|
2536
|
+
|
|
2537
|
+
// Helper to populate any DOM element from a URL parameter by ID.
|
|
2538
|
+
// Handles text inputs, textareas, checkboxes, select dropdowns, and color inputs.
|
|
2539
|
+
function loadUrlArgumentToInputField(argName, value, inputFieldId = argName) {
|
|
2540
|
+
const el = document.getElementById(inputFieldId);
|
|
2541
|
+
|
|
2542
|
+
if (!el) {
|
|
2543
|
+
return false;
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
// Boolean controls.
|
|
2547
|
+
if (el.type === 'checkbox') {
|
|
2548
|
+
el.checked = (value === 'on' || value === 'true' || value === '1');
|
|
2549
|
+
}
|
|
2550
|
+
// Color inputs always receive a valid hexadecimal value.
|
|
2551
|
+
else if (el.type === 'color') {
|
|
2552
|
+
el.value = parseUrlColor(value, el.value);
|
|
2553
|
+
}
|
|
2554
|
+
// Textareas, selects, and textual/numeric inputs.
|
|
2555
|
+
else {
|
|
2556
|
+
el.value = value;
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
// Refresh lightweight previews; auto-render listeners are attached after URL loading.
|
|
2560
|
+
el.dispatchEvent(new Event('input', { bubbles: false }));
|
|
2561
|
+
|
|
2562
|
+
return true;
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
/**
|
|
2566
|
+
* Master initialization function to execute on page load.
|
|
2567
|
+
*/
|
|
2568
|
+
function syncGeneratedRegionHighlight() {
|
|
2569
|
+
const bgMode = document.getElementById('backgroundhighlighting')?.value;
|
|
2570
|
+
if (bgMode !== 'region') {
|
|
2571
|
+
vaRRI.getRegionHighlights().filter(highlight => highlight.generated).forEach(highlight => {
|
|
2572
|
+
vaRRI.removeRegionHighlight(highlight.id);
|
|
2573
|
+
});
|
|
2574
|
+
return;
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
let sequenceContext;
|
|
2578
|
+
try {
|
|
2579
|
+
sequenceContext = getSequenceContext();
|
|
2580
|
+
} catch (err) {
|
|
2581
|
+
return;
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
let validated;
|
|
2585
|
+
try {
|
|
2586
|
+
validated = vaRRI.validate({
|
|
2587
|
+
...getBaseVisualizationArgs(),
|
|
2588
|
+
subsequenceHighlights: [],
|
|
2589
|
+
regionHighlights: [],
|
|
2590
|
+
pointMutations: [],
|
|
2591
|
+
});
|
|
2592
|
+
} catch (err) {
|
|
2593
|
+
return;
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
const ranges = vaRRI.computeBackgroundRegionRanges(validated);
|
|
2597
|
+
if (!ranges) {
|
|
2598
|
+
vaRRI.getRegionHighlights().filter(highlight => highlight.generated).forEach(highlight => {
|
|
2599
|
+
vaRRI.removeRegionHighlight(highlight.id);
|
|
2600
|
+
});
|
|
2601
|
+
return;
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
const payload = {
|
|
2605
|
+
sequence1Range: ranges.sequence1Range,
|
|
2606
|
+
sequence2Range: ranges.sequence2Range,
|
|
2607
|
+
color: document.getElementById('colorRriRegion')?.value || getDefaultRegionHighlightColor(),
|
|
2608
|
+
};
|
|
2609
|
+
|
|
2610
|
+
const existing = vaRRI.getRegionHighlights().find(highlight => highlight.generated);
|
|
2611
|
+
if (existing) {
|
|
2612
|
+
vaRRI.updateRegionHighlight(existing.id, {
|
|
2613
|
+
...payload,
|
|
2614
|
+
generated: true,
|
|
2615
|
+
}, sequenceContext);
|
|
2616
|
+
} else {
|
|
2617
|
+
vaRRI.registerGeneratedRegionHighlight({
|
|
2618
|
+
offset1: validated.offset1,
|
|
2619
|
+
offset2: validated.offset2,
|
|
2620
|
+
sequence1: validated.sequence1,
|
|
2621
|
+
sequence2: validated.sequence2,
|
|
2622
|
+
}, {
|
|
2623
|
+
...payload,
|
|
2624
|
+
generated: true,
|
|
2625
|
+
});
|
|
2626
|
+
}
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
function loadAllUrlParameters(
|
|
2630
|
+
urlParams = new URLSearchParams(window.location.search),
|
|
2631
|
+
{ revealProfilePanel = true } = {}
|
|
2632
|
+
) {
|
|
2633
|
+
let hasProfileData = false;
|
|
2634
|
+
|
|
2635
|
+
// Rendering-only flag.
|
|
2636
|
+
if (urlParams.has('showRenderingOnly')
|
|
2637
|
+
&& urlParams.get('showRenderingOnly') !== 'false'
|
|
2638
|
+
&& urlParams.get('showRenderingOnly') !== '0'
|
|
2639
|
+
){
|
|
2640
|
+
// disable all UI elements that are not part of the rendering container
|
|
2641
|
+
document.body.classList.add('rendering-only');
|
|
2642
|
+
// enable "Full Page" button
|
|
2643
|
+
const fullPageBtn = document.getElementById('openVarriBtn');
|
|
2644
|
+
if (fullPageBtn) {
|
|
2645
|
+
fullPageBtn.style.display = 'inline-block';
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
|
|
2650
|
+
// Populate standard form controls.
|
|
2651
|
+
urlParams.forEach((value, paramKey) => {
|
|
2652
|
+
// Structured annotation parameters are loaded separately.
|
|
2653
|
+
if (paramKey === 'mutations'
|
|
2654
|
+
|| paramKey === 'subseqHighlights'
|
|
2655
|
+
|| paramKey === 'regionHighlights'
|
|
2656
|
+
|| paramKey === 'showRenderingOnly'
|
|
2657
|
+
|| paramKey === 'rotation'
|
|
2658
|
+
) return;
|
|
2659
|
+
|
|
2660
|
+
const loaded = loadUrlArgumentToInputField(paramKey, value, paramKey);
|
|
2661
|
+
|
|
2662
|
+
if (loaded && (paramKey.startsWith('profileData'))) {
|
|
2663
|
+
hasProfileData = true;
|
|
2664
|
+
}
|
|
2665
|
+
});
|
|
2666
|
+
|
|
2667
|
+
// Load structured annotation objects.
|
|
2668
|
+
loadUrlMutationsToVaRRI('mutations', urlParams);
|
|
2669
|
+
loadUrlSubsequenceHighlightsToVaRRI('subseqHighlights', urlParams);
|
|
2670
|
+
loadUrlRegionHighlightsToVaRRI('regionHighlights', urlParams);
|
|
2671
|
+
|
|
2672
|
+
if( urlParams.has('rotation') ) {
|
|
2673
|
+
const rotationValue = Number.parseFloat(urlParams.get('rotation'));
|
|
2674
|
+
if (!Number.isNaN(rotationValue)) {
|
|
2675
|
+
committedRotationDeg = rotationValue;
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
// Reveal the profile panel when a share link contains profile data.
|
|
2680
|
+
if (hasProfileData && revealProfilePanel) {
|
|
2681
|
+
const profileDetails = document.getElementById('profileData1')?.closest('details');
|
|
2682
|
+
if (profileDetails) {
|
|
2683
|
+
profileDetails.open = true;
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
function attachUiActionListeners() {
|
|
2689
|
+
const clickHandlers = {
|
|
2690
|
+
clearAllBtn: clearAll,
|
|
2691
|
+
exportPngBtn: exportPNG,
|
|
2692
|
+
exportSvgBtn: exportSVG,
|
|
2693
|
+
profileApplyBtn: runVisualization,
|
|
2694
|
+
profileClearBtn: resetProfileForm,
|
|
2695
|
+
shareLinkBtn: event => generateShareableURLToClipboard(event.currentTarget),
|
|
2696
|
+
openVarriBtn: () => window.open(generateShareableURL(), '_blank'),
|
|
2697
|
+
};
|
|
2698
|
+
|
|
2699
|
+
Object.entries(clickHandlers).forEach(([id, handler]) => {
|
|
2700
|
+
document.getElementById(id)?.addEventListener('click', handler);
|
|
2701
|
+
});
|
|
2702
|
+
|
|
2703
|
+
const exampleDropdown = document.getElementById('exampleDropdown');
|
|
2704
|
+
const exampleOptions = document.getElementById('exampleDropdownOptions');
|
|
2705
|
+
|
|
2706
|
+
exampleOptions?.addEventListener('click', event => {
|
|
2707
|
+
const button = event.target.closest('[data-example]');
|
|
2708
|
+
if (button && loadExample(button.dataset.example)) {
|
|
2709
|
+
document.getElementById('exampleDropdownTrigger')?.focus();
|
|
2710
|
+
}
|
|
2711
|
+
});
|
|
2712
|
+
|
|
2713
|
+
exampleDropdown?.addEventListener('keydown', event => {
|
|
2714
|
+
if (event.key === 'Escape' && exampleDropdown.open) {
|
|
2715
|
+
event.preventDefault();
|
|
2716
|
+
exampleDropdown.open = false;
|
|
2717
|
+
document.getElementById('exampleDropdownTrigger')?.focus();
|
|
2718
|
+
}
|
|
2719
|
+
});
|
|
2720
|
+
|
|
2721
|
+
document.addEventListener('click', event => {
|
|
2722
|
+
if (exampleDropdown?.open && !exampleDropdown.contains(event.target)) {
|
|
2723
|
+
exampleDropdown.open = false;
|
|
2724
|
+
}
|
|
2725
|
+
});
|
|
2726
|
+
|
|
2727
|
+
document.querySelectorAll('.js-render-color').forEach(input => {
|
|
2728
|
+
input.addEventListener('change', () => {
|
|
2729
|
+
applyColors();
|
|
2730
|
+
runVisualization();
|
|
2731
|
+
});
|
|
2732
|
+
});
|
|
2733
|
+
|
|
2734
|
+
// register listener for the "Full screen UI" checkbox for CSS class toggling
|
|
2735
|
+
const checkbox = document.getElementById('hideFooterAndHeader');
|
|
2736
|
+
checkbox.addEventListener('change', (event) => {
|
|
2737
|
+
document.body.classList.toggle('hide-footer-and-header', event.target.checked);
|
|
2738
|
+
});
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
function registerSequenceBackdropSync(textareaId) {
|
|
2742
|
+
// synchronize scroll position and resize of sequence textarea with backdrop
|
|
2743
|
+
const textarea = document.getElementById(textareaId);
|
|
2744
|
+
const backdrop = document.getElementById(`backdrop-${textareaId}`);
|
|
2745
|
+
textarea.addEventListener('scroll', () => {
|
|
2746
|
+
backdrop.scrollTop = textarea.scrollTop;
|
|
2747
|
+
backdrop.scrollLeft = textarea.scrollLeft;
|
|
2748
|
+
});
|
|
2749
|
+
|
|
2750
|
+
// Listen for resize events
|
|
2751
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
2752
|
+
syncDimensions(textareaId);
|
|
2753
|
+
syncScroll(textareaId);
|
|
2754
|
+
});
|
|
2755
|
+
resizeObserver.observe(textarea);
|
|
2756
|
+
// Sync scroll positions
|
|
2757
|
+
textarea.addEventListener('scroll', () => {syncScroll(textareaId);});
|
|
2758
|
+
textarea.addEventListener('input', () => {
|
|
2759
|
+
updateHighlights(textareaId);
|
|
2760
|
+
syncDimensions(textareaId);
|
|
2761
|
+
syncScroll(textareaId);
|
|
2762
|
+
});
|
|
2763
|
+
|
|
2764
|
+
const colorSeq1Input = document.getElementById('colorSeq1');
|
|
2765
|
+
const colorSeq2Input = document.getElementById('colorSeq2');
|
|
2766
|
+
const colorBasepairInput = document.getElementById('colorBasepair');
|
|
2767
|
+
colorSeq1Input.addEventListener('input', () => {updateHighlights(textareaId);});
|
|
2768
|
+
colorSeq2Input.addEventListener('input', () => {updateHighlights(textareaId);});
|
|
2769
|
+
colorBasepairInput.addEventListener('input', () => {updateHighlights(textareaId);});
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
|
|
2773
|
+
window.addEventListener('load', () => {
|
|
2774
|
+
// Build example controls from the shared catalog used by the literature links.
|
|
2775
|
+
renderExampleDropdown();
|
|
2776
|
+
|
|
2777
|
+
// Reset state before applying examples or URL parameters.
|
|
2778
|
+
clearAll();
|
|
2779
|
+
|
|
2780
|
+
// Bind explicit UI actions before loading URL state.
|
|
2781
|
+
attachUiActionListeners();
|
|
2782
|
+
|
|
2783
|
+
registerSequenceBackdropSync('sequence');
|
|
2784
|
+
registerSequenceBackdropSync('structure');
|
|
2785
|
+
// also within FASTA dialog
|
|
2786
|
+
setupFileDragAndDrop('fastaInput');
|
|
2787
|
+
registerSequenceBackdropSync('fastaSequence');
|
|
2788
|
+
registerSequenceBackdropSync('fastaStructure');
|
|
2789
|
+
|
|
2790
|
+
// Set up drag-and-drop
|
|
2791
|
+
setupFileDragAndDrop('sequence');
|
|
2792
|
+
setupFileDragAndDrop('profileData1');
|
|
2793
|
+
setupFileDragAndDrop('profileData2');
|
|
2794
|
+
|
|
2795
|
+
const rotationSlider = document.getElementById('rotationSlider');
|
|
2796
|
+
if (rotationSlider) {
|
|
2797
|
+
rotationSlider.addEventListener('input', applySliderRotation);
|
|
2798
|
+
rotationSlider.addEventListener('change', commitSliderRotation);
|
|
2799
|
+
}
|
|
2800
|
+
const profileInputIds = ['profileData1', 'profileData2'];
|
|
2801
|
+
profileInputIds.forEach(id => {
|
|
2802
|
+
const el = document.getElementById(id);
|
|
2803
|
+
if (el) {
|
|
2804
|
+
el.addEventListener('input', () => updateInputCounter(profileInputIds, 'profileCounterUI'));
|
|
2805
|
+
el.addEventListener('change', () => updateInputCounter(profileInputIds, 'profileCounterUI'));
|
|
2806
|
+
}
|
|
2807
|
+
});
|
|
2808
|
+
|
|
2809
|
+
// Load URL state before attaching auto-render listeners.
|
|
2810
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
2811
|
+
|
|
2812
|
+
if (urlParams.size > 0) {
|
|
2813
|
+
// Populate state without triggering intermediate renders.
|
|
2814
|
+
loadAllUrlParameters(urlParams);
|
|
2815
|
+
|
|
2816
|
+
// trigger change event for hideFooterAndHeader checkbox to apply CSS class if needed
|
|
2817
|
+
document.getElementById('hideFooterAndHeader').dispatchEvent(new Event('change', { bubbles: true }));
|
|
2818
|
+
|
|
2819
|
+
// Refresh annotation lists and counters.
|
|
2820
|
+
renderSubseqHighlightList();
|
|
2821
|
+
renderRegionList();
|
|
2822
|
+
renderMutationList();
|
|
2823
|
+
resetFastaForm();
|
|
2824
|
+
resetSubseqForm();
|
|
2825
|
+
resetRegionForm();
|
|
2826
|
+
resetMutationForm();
|
|
2827
|
+
updateInputCounter(profileInputIds, 'profileCounterUI');
|
|
2828
|
+
|
|
2829
|
+
enableForceLayoutForSelectedLinearOptions();
|
|
2830
|
+
syncAnimationDependentControls();
|
|
2831
|
+
|
|
2832
|
+
// Render once after all URL state has been applied.
|
|
2833
|
+
runVisualization();
|
|
2834
|
+
// rotate if needed
|
|
2835
|
+
commitSliderRotation();
|
|
2836
|
+
} else {
|
|
2837
|
+
loadExample('2mol');
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
// Enable automatic rendering only after initialization.
|
|
2841
|
+
attachAutoVisualizationListeners();
|
|
2842
|
+
// Enable dialogs after initialization.
|
|
2843
|
+
attachDialogListeners();
|
|
2844
|
+
});
|