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/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
+ });