varri-js 1.0.0

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/src/vaRRI.js ADDED
@@ -0,0 +1,3957 @@
1
+ /**
2
+ * vaRRI.js — Browser-only JavaScript port of the vaRRI RNA-RNA interaction
3
+ * visualiser.
4
+ *
5
+ * This library translates the Python vaRRI source into pure browser JavaScript,
6
+ * removing all command-line dependencies (RNAfold, RNAplfold, Playwright).
7
+ * It relies on Fornac (https://github.com/ViennaRNA/fornac) and D3.js which
8
+ * must be loaded before this script.
9
+ *
10
+ * @module vaRRI
11
+ */
12
+
13
+ (function (global) {
14
+ 'use strict';
15
+
16
+ // -----------------------------------------------------------------------
17
+ // Constants
18
+ // -----------------------------------------------------------------------
19
+
20
+ /** Number of invisible gap nodes Fornac inserts between two molecules. */
21
+ const GAP = 3;
22
+
23
+ /** Metadata type for invisible linear-RRI span constraints. */
24
+ const LINEAR_RRI_LINK_TYPE = 'rri_linear';
25
+
26
+ /** Metadata type for invisible intramolecular span constraints. */
27
+ const LINEAR_STRUCTURE_LINK_TYPE = 'structure_linear';
28
+
29
+ /** Fractional per-tick correction used to guide index labels outside a helix rail. */
30
+ const LINEAR_HELIX_LABEL_BIAS_GAIN = 0.2;
31
+
32
+ /** Maximum label correction per tick, relative to its ordinary link length. */
33
+ const LINEAR_HELIX_LABEL_BIAS_MAX_STEP = 0.05;
34
+
35
+ /** Small positive half-plane target used only while a label is on the wrong side. */
36
+ const LINEAR_HELIX_LABEL_BIAS_TARGET = 0.1;
37
+
38
+ /** Active requestAnimationFrame ID for the background-highlight animation loop (null when idle). */
39
+ let _animFrameId = null;
40
+
41
+ /** Active timeout ID for delayed post-processing after a render (null when idle). */
42
+ let _renderTimeoutId = null;
43
+
44
+ /** Resolver for the render promise that is currently waiting for post-processing. */
45
+ let _pendingRenderResolve = null;
46
+
47
+ /** Live Fornac container used by the current render, if any. */
48
+ let _activeContainer = null;
49
+
50
+ /**
51
+ * Colours used by vaRRI rendering functions initialized to defaults.
52
+ *
53
+ *
54
+ * Change these at runtime with {@link setColors}; the new values take
55
+ * effect on the next call to any rendering function.
56
+ */
57
+ const COLORS = {
58
+ /** Fill colour for nucleotide circles of sequence 1 in strand-colouring mode. */
59
+ sequence1: 'lightblue',
60
+ /** Fill colour for nucleotide circles of sequence 2 in strand-colouring mode. */
61
+ sequence2: '#F4BB44',
62
+ /** Default fill colour for sequence-1 accessibility/profile overlays. */
63
+ seq1profileColor: 'purple',
64
+ /** Default fill colour for sequence-2 accessibility/profile overlays. */
65
+ seq2profileColor: 'red',
66
+ /** Default fill colour for point mutation overlays. */
67
+ mutationColor: 'Darkgreen',
68
+ /** Stroke colour used for intermolecular nucleotide and index-label highlighting. */
69
+ intermolecularHighlight: 'red',
70
+ /** Fill/stroke colour used for background (region / basepair-stack) highlighting. */
71
+ backgroundHighlight: 'red',
72
+ /** Stroke colour used for subsequence-highlighting polylines and circles. */
73
+ subsequenceHighlight: 'purple',
74
+ /** Stroke colour used for basepair links. */
75
+ basepair: 'red',
76
+ };
77
+
78
+ /** In-memory registries for user-defined annotations. */
79
+ const SUBSEQUENCE_REGISTRY = { items: [], nextId: 1, label: 'Highlight' };
80
+ const REGION_REGISTRY = { items: [], nextId: 1, label: 'Region highlight' };
81
+ const MUTATION_REGISTRY = { items: [], nextId: 1, label: 'Mutation' };
82
+
83
+ // Short aliases keep rendering code focused on domain objects.
84
+ const SUBSEQUENCE_HIGHLIGHTS = SUBSEQUENCE_REGISTRY.items;
85
+ const REGION_HIGHLIGHTS = REGION_REGISTRY.items;
86
+ const POINT_MUTATIONS = MUTATION_REGISTRY.items;
87
+
88
+ function clearRegistry(registry) {
89
+ registry.items.length = 0;
90
+ registry.nextId = 1;
91
+ }
92
+
93
+ function getRegistryItem(registry, id) {
94
+ const item = registry.items.find(candidate => candidate.id === id);
95
+ if (!item) throw new Error(registry.label + ' with id ' + id + ' not found.');
96
+ return item;
97
+ }
98
+
99
+ function listRegistryItems(registry, cloneItem) {
100
+ return registry.items.map(cloneItem);
101
+ }
102
+
103
+ function registerRegistryItem(registry, item, cloneItem) {
104
+ item.id = registry.nextId++;
105
+ registry.items.push(item);
106
+ return cloneItem(item);
107
+ }
108
+
109
+ function removeRegistryItem(registry, id) {
110
+ const index = registry.items.findIndex(item => item.id === id);
111
+ if (index === -1) return false;
112
+ registry.items.splice(index, 1);
113
+ return true;
114
+ }
115
+
116
+ /**
117
+ * Return a deep-enough clone of a highlight object for external consumers.
118
+ *
119
+ * @param {Object} highlight
120
+ * @returns {Object}
121
+ */
122
+ function cloneSubsequenceHighlight(highlight) {
123
+ return {
124
+ id: highlight.id,
125
+ sequence: highlight.sequence,
126
+ range: highlight.range.map(([start, end]) => [start, end]),
127
+ color: highlight.color,
128
+ alpha: highlight.alpha,
129
+ rangeText: highlight.rangeText,
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Validate and normalize a sequence selector for subsequence highlighting.
135
+ *
136
+ * @param {string|number} sequence
137
+ * @returns {'1'|'2'}
138
+ */
139
+ function normaliseHighlightSequence(sequence) {
140
+ const seq = String(sequence);
141
+ if (seq !== '1' && seq !== '2') {
142
+ throw new Error('Highlight sequence must be "1" or "2".');
143
+ }
144
+ return seq;
145
+ }
146
+
147
+ /**
148
+ * Normalize and validate a highlight range input.
149
+ *
150
+ * @param {string|Array<[number, number]>} rangeInput
151
+ * @param {{id:string, offset:number, length:number}=} context
152
+ * @returns {{range:Array<[number, number]>, rangeText:string}}
153
+ */
154
+ function normaliseHighlightRanges(rangeInput, context) {
155
+ if (typeof rangeInput === 'string') {
156
+ const range = parseSubsequences(
157
+ rangeInput,
158
+ context ? context.offset : undefined,
159
+ context ? context.length : undefined,
160
+ context ? context.id : undefined
161
+ );
162
+ if (!range || range.length === 0) {
163
+ throw new Error('Highlight range must not be empty.');
164
+ }
165
+ return { range, rangeText: rangeInput.trim() };
166
+ }
167
+
168
+ if (!Array.isArray(rangeInput) || rangeInput.length === 0) {
169
+ throw new Error('Highlight range must not be empty.');
170
+ }
171
+
172
+ const range = rangeInput.map((pair, idx) => {
173
+ if (!Array.isArray(pair) || pair.length !== 2) {
174
+ throw new Error(`${context.id ? context.id+": ": ""} Invalid subsequence range ${pair} at index ${idx}. Expected [start, end].`);
175
+ }
176
+ const start = Number(pair[0]);
177
+ const end = Number(pair[1]);
178
+ if (!Number.isInteger(start) || !Number.isInteger(end)) {
179
+ throw new Error(`${context.id ? context.id+": ": ""}Invalid subsequence range at index ${idx}. Range bounds must be integers.`);
180
+ }
181
+ if (start === 0 || end === 0) {
182
+ throw new Error(`${context.id ? context.id+": ": ""}Invalid subsequence range at index ${idx}. Index 0 is not valid.`);
183
+ }
184
+ if (start > end) {
185
+ throw new Error(`${context.id ? context.id+": ": ""}Invalid subsequence range at index ${idx}. Start index must be <= end index.`);
186
+ }
187
+ return [start, end];
188
+ });
189
+
190
+ if (context) {
191
+ parseSubsequences(
192
+ range.map(([start, end]) => `${start}-${end}`).join(','),
193
+ context.offset,
194
+ context.length,
195
+ context.id
196
+ );
197
+ }
198
+
199
+ return {
200
+ range,
201
+ rangeText: range.map(([start, end]) => `${start}-${end}`).join(','),
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Build a normalized subsequence-highlight object from user input.
207
+ *
208
+ * @param {{sequence:string|number, range:string|Array<[number, number]>, color?:string, alpha?:number, id?:number}} input
209
+ * @param {{'1'?:{offset:number, length:number}, '2'?:{offset:number, length:number}}=} sequenceContext
210
+ * @returns {{id:number, sequence:'1'|'2', range:Array<[number, number]>, color:string, rangeText:string}}
211
+ */
212
+ function createSubsequenceHighlight(input, sequenceContext = {}) {
213
+
214
+ const sequence = normaliseHighlightSequence(input.sequence);
215
+ const context = sequenceContext[sequence];
216
+ const normalizedRange = normaliseHighlightRanges(input.range, context);
217
+ const color = (input.color || '').trim() || COLORS.subsequenceHighlight;
218
+ const alpha = input.alpha !== undefined ? Number(input.alpha) : 0.3;
219
+
220
+ return {
221
+ id: Number.isInteger(input.id) ? input.id : 0,
222
+ sequence,
223
+ range: normalizedRange.range,
224
+ color,
225
+ alpha,
226
+ rangeText: normalizedRange.rangeText,
227
+ };
228
+ }
229
+
230
+ /**
231
+ * Register a new subsequence highlight object.
232
+ *
233
+ * @param {{sequence:string|number, range:string|Array<[number, number]>, color?:string, alpha?:number}} input
234
+ * @param {{'1'?:{offset:number, length:number}, '2'?:{offset:number, length:number}}=} sequenceContext
235
+ * @returns {Object}
236
+ */
237
+ function registerSubsequenceHighlight(input, sequenceContext = {}) {
238
+ return registerRegistryItem(
239
+ SUBSEQUENCE_REGISTRY,
240
+ createSubsequenceHighlight(input, sequenceContext),
241
+ cloneSubsequenceHighlight
242
+ );
243
+ }
244
+
245
+ /**
246
+ * Update an existing subsequence highlight object.
247
+ *
248
+ * @param {number} id
249
+ * @param {{sequence?:string|number, range?:string|Array<[number, number]>, color?:string}} patch
250
+ * @param {{'1'?:{offset:number, length:number}, '2'?:{offset:number, length:number}}=} sequenceContext
251
+ * @returns {Object}
252
+ */
253
+ function updateSubsequenceHighlight(id, patch, sequenceContext = {}) {
254
+ const target = getRegistryItem(SUBSEQUENCE_REGISTRY, id);
255
+
256
+ const normalized = createSubsequenceHighlight({
257
+ id,
258
+ sequence: patch.sequence !== undefined ? patch.sequence : target.sequence,
259
+ range: patch.range !== undefined ? patch.range : target.range,
260
+ color: patch.color !== undefined ? patch.color : target.color,
261
+ alpha: patch.alpha !== undefined ? patch.alpha : target.alpha,
262
+ }, sequenceContext);
263
+
264
+ Object.assign(target, normalized);
265
+
266
+ return cloneSubsequenceHighlight(target);
267
+ }
268
+
269
+ /**
270
+ * Remove a subsequence highlight object by id.
271
+ *
272
+ * @param {number} id
273
+ * @returns {boolean}
274
+ */
275
+ function removeSubsequenceHighlight(id) {
276
+ return removeRegistryItem(SUBSEQUENCE_REGISTRY, id);
277
+ }
278
+
279
+ /**
280
+ * Remove all registered subsequence highlights.
281
+ */
282
+ function clearSubsequenceHighlights() {
283
+ clearRegistry(SUBSEQUENCE_REGISTRY);
284
+ }
285
+
286
+ /**
287
+ * Read registered subsequence highlights.
288
+ *
289
+ * @returns {Array<Object>}
290
+ */
291
+ function getSubsequenceHighlights() {
292
+ return listRegistryItems(SUBSEQUENCE_REGISTRY, cloneSubsequenceHighlight);
293
+ }
294
+
295
+ /**
296
+ * Return a deep-enough clone of a region-highlight object for external consumers.
297
+ *
298
+ * @param {Object} highlight
299
+ * @returns {Object}
300
+ */
301
+ function cloneRegionHighlight(highlight) {
302
+ return {
303
+ id: highlight.id,
304
+ sequence1Range: [highlight.sequence1Range[0], highlight.sequence1Range[1]],
305
+ sequence2Range: [highlight.sequence2Range[0], highlight.sequence2Range[1]],
306
+ color: highlight.color,
307
+ alpha: highlight.alpha,
308
+ rangeText: highlight.rangeText,
309
+ generated: !!highlight.generated,
310
+ };
311
+ }
312
+
313
+ /**
314
+ * Normalize a range input for region highlighting.
315
+ *
316
+ * @param {string|Array<number|[number, number]>} rangeInput
317
+ * @param {{id: string, offset:number, length:number}=} context
318
+ * @returns {{range:[number, number], rangeText:string}}
319
+ */
320
+ function normaliseRegionRange(rangeInput, context = {}) {
321
+ if (typeof rangeInput === 'string') {
322
+ const ranges = parseSubsequences(rangeInput, context.offset, context.length, context.id);
323
+ if (!ranges || ranges.length === 0) {
324
+ throw new Error('Region range must not be empty.');
325
+ }
326
+ if (ranges.length > 1) {
327
+ throw new Error('Region highlighting supports a single range per sequence.');
328
+ }
329
+ const [start, end] = ranges[0];
330
+ return { range: [start, end], rangeText: rangeInput.trim() };
331
+ }
332
+
333
+ if (!Array.isArray(rangeInput) || rangeInput.length === 0) {
334
+ throw new Error('Region range must not be empty.');
335
+ }
336
+
337
+ const pair = rangeInput;
338
+ if (!Array.isArray(pair) || pair.length !== 2) {
339
+ throw new Error('Invalid region range. Expected [start, end].');
340
+ }
341
+
342
+ const start = Number(pair[0]);
343
+ const end = Number(pair[1]);
344
+ if (!Number.isInteger(start) || !Number.isInteger(end)) {
345
+ throw new Error('Invalid region range. Range bounds must be integers.');
346
+ }
347
+ if (start === 0 || end === 0) {
348
+ throw new Error('Invalid region range. Index 0 is not valid.');
349
+ }
350
+ if (start > end) {
351
+ throw new Error('Invalid region range. Start index must be <= end index.');
352
+ }
353
+
354
+ if (context) {
355
+ parseSubsequences(`${start}-${end}`, context.offset, context.length, context.id);
356
+ }
357
+
358
+ return {
359
+ range: [start, end],
360
+ rangeText: `${start}-${end}`,
361
+ };
362
+ }
363
+
364
+ /**
365
+ * Build a normalized region-highlight object from user input.
366
+ *
367
+ * @param {{sequence1Range:string|[number, number], sequence2Range:string|[number, number], color?:string, alpha?:number, generated?:boolean, id?:number}} input
368
+ * @param {{'1'?:{offset:number, length:number}, '2'?:{offset:number, length:number}}=} sequenceContext
369
+ * @returns {{id:number, sequence1Range:[number, number], sequence2Range:[number, number], color:string, rangeText:string, generated:boolean}}
370
+ */
371
+ function createRegionHighlight(input, sequenceContext = {}) {
372
+ const context1 = sequenceContext['1'];
373
+ const context2 = sequenceContext['2'];
374
+ const seq1Range = normaliseRegionRange(input.sequence1Range, context1);
375
+ const seq2Range = normaliseRegionRange(input.sequence2Range, context2);
376
+ const color = (input.color || '').trim() || COLORS.backgroundHighlight;
377
+ const alpha = input.alpha !== undefined ? Number(input.alpha) : 0.2;
378
+
379
+ return {
380
+ id: Number.isInteger(input.id) ? input.id : 0,
381
+ sequence1Range: seq1Range.range,
382
+ sequence2Range: seq2Range.range,
383
+ color,
384
+ alpha,
385
+ rangeText: `${seq1Range.rangeText}&${seq2Range.rangeText}`,
386
+ generated: !!input.generated,
387
+ };
388
+ }
389
+
390
+ /**
391
+ * Register a new region highlight object.
392
+ *
393
+ * @param {{sequence1Range:string|[number, number], sequence2Range:string|[number, number], color?:string, alpha?:number, generated?:boolean}} input
394
+ * @param {{'1'?:{offset:number, length:number}, '2'?:{offset:number, length:number}}=} sequenceContext
395
+ * @returns {Object}
396
+ */
397
+ function registerRegionHighlight(input, sequenceContext = {}) {
398
+ return registerRegistryItem(
399
+ REGION_REGISTRY,
400
+ createRegionHighlight(input, sequenceContext),
401
+ cloneRegionHighlight
402
+ );
403
+ }
404
+
405
+ /**
406
+ * Update an existing region highlight object.
407
+ *
408
+ * @param {number} id
409
+ * @param {{sequence1Range?:string|[number, number], sequence2Range?:string|[number, number], color?:string, generated?:boolean}} patch
410
+ * @param {{'1'?:{offset:number, length:number}, '2'?:{offset:number, length:number}}=} sequenceContext
411
+ * @returns {Object}
412
+ */
413
+ function updateRegionHighlight(id, patch, sequenceContext = {}) {
414
+ const target = getRegistryItem(REGION_REGISTRY, id);
415
+
416
+ const normalized = createRegionHighlight({
417
+ id,
418
+ sequence1Range: patch.sequence1Range !== undefined ? patch.sequence1Range : target.sequence1Range,
419
+ sequence2Range: patch.sequence2Range !== undefined ? patch.sequence2Range : target.sequence2Range,
420
+ color: patch.color !== undefined ? patch.color : target.color,
421
+ alpha: patch.alpha !== undefined ? patch.alpha : target.alpha,
422
+ generated: patch.generated !== undefined ? patch.generated : target.generated,
423
+ }, sequenceContext);
424
+
425
+ Object.assign(target, normalized);
426
+
427
+ return cloneRegionHighlight(target);
428
+ }
429
+
430
+ /**
431
+ * Remove a region highlight object by id.
432
+ *
433
+ * @param {number} id
434
+ * @returns {boolean}
435
+ */
436
+ function removeRegionHighlight(id) {
437
+ return removeRegistryItem(REGION_REGISTRY, id);
438
+ }
439
+
440
+ /**
441
+ * Remove all registered region highlights.
442
+ */
443
+ function clearRegionHighlights() {
444
+ clearRegistry(REGION_REGISTRY);
445
+ }
446
+
447
+ /**
448
+ * Read registered region highlights.
449
+ *
450
+ * @returns {Array<Object>}
451
+ */
452
+ function getRegionHighlights() {
453
+ return listRegistryItems(REGION_REGISTRY, cloneRegionHighlight);
454
+ }
455
+
456
+ /**
457
+ * Return a deep-enough clone of a point-mutation object for external consumers.
458
+ *
459
+ * @param {Object} mutation
460
+ * @returns {Object}
461
+ */
462
+ function clonePointMutation(mutation) {
463
+ return {
464
+ id: mutation.id,
465
+ sequence: mutation.sequence,
466
+ position: mutation.position,
467
+ replacement: mutation.replacement,
468
+ reference: mutation.reference,
469
+ nodeId: mutation.nodeId,
470
+ color: mutation.color,
471
+ labelText: mutation.labelText,
472
+ };
473
+ }
474
+
475
+ /**
476
+ * Validate and normalize a mutation sequence selector.
477
+ *
478
+ * @param {string|number} sequence
479
+ * @returns {'1'|'2'}
480
+ */
481
+ function normaliseMutationSequence(sequence) {
482
+ const seq = String(sequence);
483
+ if (seq !== '1' && seq !== '2') {
484
+ throw new Error('Mutation sequence must be "1" or "2".');
485
+ }
486
+ return seq;
487
+ }
488
+
489
+ /**
490
+ * Build a map of valid sequence positions to their bases.
491
+ *
492
+ * @param {{offset:number, sequence:string}|undefined} context
493
+ * @returns {Object.<number, string>}
494
+ */
495
+ function buildSequencePositionMap(context) {
496
+ const map = {};
497
+ if (!context || !Number.isInteger(context.offset) || typeof context.sequence !== 'string') {
498
+ return map;
499
+ }
500
+
501
+ getSequenceIndices('s', context.offset, context.sequence.length).forEach(([, position], index) => {
502
+ map[position] = context.sequence[index];
503
+ });
504
+ return map;
505
+ }
506
+
507
+ /**
508
+ * Normalize a mutation position and validate it against the current sequence context.
509
+ *
510
+ * @param {number|string} positionInput
511
+ * @param {{offset:number, sequence:string}|undefined} context
512
+ * @returns {number}
513
+ */
514
+ function normaliseMutationPosition(positionInput, context) {
515
+ if (positionInput === undefined || positionInput === null || positionInput === '') {
516
+ throw new Error('Mutation position must not be empty.');
517
+ }
518
+ const position = validateOffset(String(positionInput));
519
+
520
+ if (context) {
521
+ const sequencePositionMap = buildSequencePositionMap(context);
522
+ if (!(position in sequencePositionMap)) {
523
+ throw new Error('Mutation position must be a valid sequence index.');
524
+ }
525
+ }
526
+
527
+ return position;
528
+ }
529
+
530
+ /**
531
+ * Validate a point-mutation replacement base.
532
+ *
533
+ * @param {string} replacement
534
+ * @returns {string}
535
+ */
536
+ function normaliseMutationReplacement(replacement) {
537
+ const newLetter = String(replacement || '').trim();
538
+ if (newLetter.length !== 1) {
539
+ throw new Error('Mutation replacement must be a single letter.');
540
+ }
541
+ return newLetter;
542
+ }
543
+
544
+ /**
545
+ * Build a normalized point-mutation object from user input.
546
+ *
547
+ * @param {{sequence:string|number, position:number|string, replacement:string, color?:string, id?:number}} input
548
+ * @param {{'1'?:{offset:number, sequence:string}, '2'?:{offset:number, sequence:string}}=} sequenceContext
549
+ * @returns {{id:number, sequence:'1'|'2', position:number, replacement:string, reference:string, nodeId:number, color:string, labelText:string}}
550
+ */
551
+ function createPointMutation(input, sequenceContext = {}) {
552
+ const sequence = normaliseMutationSequence(input.sequence);
553
+ const context = sequenceContext[sequence];
554
+ const position = normaliseMutationPosition(input.position, context);
555
+ const replacement = normaliseMutationReplacement(input.replacement);
556
+ const color = (input.color || '').trim() || COLORS.intermolecularHighlight;
557
+
558
+ const referenceMap = context ? buildSequencePositionMap(context) : {};
559
+ const reference = referenceMap[position] || '';
560
+
561
+ return {
562
+ id: Number.isInteger(input.id) ? input.id : 0,
563
+ sequence,
564
+ position,
565
+ replacement,
566
+ reference,
567
+ nodeId: 0,
568
+ color,
569
+ labelText: `${reference || '?'}${position}${replacement}`,
570
+ };
571
+ }
572
+
573
+ /**
574
+ * Register a new point mutation.
575
+ *
576
+ * @param {{sequence:string|number, position:number|string, replacement:string, color?:string}} input
577
+ * @param {{'1'?:{offset:number, sequence:string}, '2'?:{offset:number, sequence:string}}=} sequenceContext
578
+ * @returns {Object}
579
+ */
580
+ function registerPointMutation(input, sequenceContext = {}) {
581
+ return registerRegistryItem(
582
+ MUTATION_REGISTRY,
583
+ createPointMutation(input, sequenceContext),
584
+ clonePointMutation
585
+ );
586
+ }
587
+
588
+ /**
589
+ * Update an existing point mutation.
590
+ *
591
+ * @param {number} id
592
+ * @param {{sequence?:string|number, position?:number|string, replacement?:string, color?:string}} patch
593
+ * @param {{'1'?:{offset:number, sequence:string}, '2'?:{offset:number, sequence:string}}=} sequenceContext
594
+ * @returns {Object}
595
+ */
596
+ function updatePointMutation(id, patch, sequenceContext = {}) {
597
+ const target = getRegistryItem(MUTATION_REGISTRY, id);
598
+
599
+ const normalized = createPointMutation({
600
+ id,
601
+ sequence: patch.sequence !== undefined ? patch.sequence : target.sequence,
602
+ position: patch.position !== undefined ? patch.position : target.position,
603
+ replacement: patch.replacement !== undefined ? patch.replacement : target.replacement,
604
+ color: patch.color !== undefined ? patch.color : target.color,
605
+ }, sequenceContext);
606
+
607
+ Object.assign(target, normalized);
608
+
609
+ return clonePointMutation(target);
610
+ }
611
+
612
+ /**
613
+ * Remove a point mutation by id.
614
+ *
615
+ * @param {number} id
616
+ * @returns {boolean}
617
+ */
618
+ function removePointMutation(id) {
619
+ return removeRegistryItem(MUTATION_REGISTRY, id);
620
+ }
621
+
622
+ /**
623
+ * Remove all registered point mutations.
624
+ */
625
+ function clearPointMutations() {
626
+ clearRegistry(MUTATION_REGISTRY);
627
+ }
628
+
629
+ /**
630
+ * Read registered point mutations.
631
+ *
632
+ * @returns {Array<Object>}
633
+ */
634
+ function getPointMutations() {
635
+ return listRegistryItems(MUTATION_REGISTRY, clonePointMutation);
636
+ }
637
+
638
+ /**
639
+ * Find the node ID that corresponds to a given sequence position.
640
+ *
641
+ * @param {Object} v
642
+ * @param {'1'|'2'} sequence
643
+ * @param {number} position
644
+ * @returns {number}
645
+ */
646
+ function getNodeIdForSequencePosition(v, sequence, position) {
647
+ for (const [nodeId, [seqName, seqPosition]] of Object.entries(getIndexDictionary(v))) {
648
+ if (seqName === `s${sequence}` && seqPosition === position) {
649
+ return parseInt(nodeId, 10);
650
+ }
651
+ }
652
+ return 0;
653
+ }
654
+
655
+ /**
656
+ * Override one or more default rendering colours.
657
+ *
658
+ * Only the keys present in `overrides` are changed; all others retain
659
+ * their current values. The new colours take effect on the next call to
660
+ * any rendering function.
661
+ *
662
+ * Valid keys: `sequence1`, `sequence2`, `seq1profileColor`, `seq2profileColor`,
663
+ * `mutationColor`, `intermolecularHighlight`, `backgroundHighlight`, `subsequenceHighlight`, `basepair`.
664
+ *
665
+ * @param {Partial<typeof COLORS>} overrides Key → CSS-colour-string map.
666
+ */
667
+ function setColors(overrides) {
668
+ Object.assign(COLORS, overrides);
669
+ }
670
+
671
+ /**
672
+ * Return a shallow copy of the current colour settings.
673
+ *
674
+ * @returns {typeof COLORS}
675
+ */
676
+ function getColors() {
677
+ return { ...COLORS };
678
+ }
679
+
680
+ // -----------------------------------------------------------------------
681
+ // Utilities (ported from utils.py)
682
+ // -----------------------------------------------------------------------
683
+
684
+ /**
685
+ * Identify intermolecular basepair positions in a structure string.
686
+ *
687
+ * Analyses a dot-bracket structure and returns positions involved in
688
+ * intermolecular basepairs. Unmatched opening or closing brackets are
689
+ * considered intermolecular.
690
+ *
691
+ * Supports `()`, `[]`, `{}`, `<>` bracket types independently.
692
+ *
693
+ * @param {string} struc Structure string in dot-bracket notation.
694
+ * @param {number} [shift=0] Offset added to every returned index.
695
+ * @returns {Array<[number, string]>} Sorted list of [1-based index, bracket] pairs.
696
+ */
697
+ function listIntermolNodes(struc, shift = 0) {
698
+ const interBasepairs = [];
699
+ const openBasepairs = { '(': [], '<': [], '[': [], '{': [] };
700
+ const bracketPairs = [['(', ')'], ['[', ']'], ['{', '}'], ['<', '>']];
701
+
702
+ for (let i = 0; i < struc.length; i++) {
703
+ const char = struc[i];
704
+ const index = i + 1; // 1-based
705
+ for (const [open, close] of bracketPairs) {
706
+ if (char === open) {
707
+ openBasepairs[open].push([index + shift, char]);
708
+ break;
709
+ }
710
+ if (char === close) {
711
+ if (openBasepairs[open].length > 0) {
712
+ openBasepairs[open].pop();
713
+ } else {
714
+ interBasepairs.push([index + shift, char]);
715
+ }
716
+ break;
717
+ }
718
+ }
719
+ }
720
+
721
+ for (const pairs of Object.values(openBasepairs)) {
722
+ interBasepairs.push(...pairs);
723
+ }
724
+
725
+ interBasepairs.sort((a, b) => a[0] - b[0]);
726
+ return interBasepairs;
727
+ }
728
+
729
+ // -----------------------------------------------------------------------
730
+ // Input validation (ported from input_validation.py)
731
+ // -----------------------------------------------------------------------
732
+
733
+ /**
734
+ * Split a string at the first `&` character.
735
+ *
736
+ * Always returns exactly two strings; the second is empty when `&` is absent.
737
+ *
738
+ * @param {string} str
739
+ * @returns {[string, string]}
740
+ */
741
+ function splitAtAmpersand(str) {
742
+ const idx = str.indexOf('&');
743
+ if (idx === -1) return [str, ''];
744
+ return [str.slice(0, idx), str.slice(idx + 1)];
745
+ }
746
+
747
+ /**
748
+ * Validate a structure string for correctly-paired brackets.
749
+ *
750
+ * Ensures `()`, `<>`, `[]`, `{}` are properly opened and closed.
751
+ *
752
+ * @param {string} structure Dot-bracket structure, may contain `&`.
753
+ * @throws {Error} When bracket counts do not balance.
754
+ */
755
+ function checkStructureInputSimple(structure) {
756
+ const basepairs = { '(': 0, '<': 0, '[': 0, '{': 0 };
757
+ const closingBp = { ')': '(', '>': '<', ']': '[', '}': '{' };
758
+
759
+ for (const char of structure) {
760
+ if (char in basepairs) {
761
+ basepairs[char]++;
762
+ } else if (char in closingBp) {
763
+ const open = closingBp[char];
764
+ basepairs[open]--;
765
+ if (basepairs[open] < 0) {
766
+ throw new Error(
767
+ `The number of brackets does not line up. Too many closing ${char} brackets:\n${structure}`
768
+ );
769
+ }
770
+ }
771
+ }
772
+
773
+ for (const [bp, count] of Object.entries(basepairs)) {
774
+ if (count > 0) {
775
+ throw new Error(
776
+ `The number of brackets does not line up. Too many opening ${bp} brackets:\n${structure}`
777
+ );
778
+ }
779
+ }
780
+ }
781
+
782
+ /**
783
+ * Find base-pair indices in a dot-bracket structure string.
784
+ *
785
+ * @param {string} structure
786
+ * @returns {Array<[number, number]>} List of [open, close] index pairs (0-based).
787
+ */
788
+ function findBasePairs(structure) {
789
+ const basepairList = [];
790
+ const openBasepairs = { '(': [], '<': [], '[': [], '{': [] };
791
+ const closingBp = { ')': '(', '>': '<', ']': '[', '}': '{' };
792
+
793
+ for (let i = 0; i < structure.length; i++) {
794
+ const char = structure[i];
795
+ if (char in openBasepairs) {
796
+ openBasepairs[char].push(i);
797
+ } else if (char in closingBp) {
798
+ const open = closingBp[char];
799
+ if (openBasepairs[open].length > 0) {
800
+ const openIdx = openBasepairs[open].pop();
801
+ basepairList.push([openIdx, i]);
802
+ }
803
+ }
804
+ }
805
+ return basepairList;
806
+ }
807
+
808
+ /**
809
+ * Validate a sequence string — must consist of IUPAC nucleotide characters,
810
+ * optionally separated by a single `&`.
811
+ *
812
+ * @param {string} sequence
813
+ * @returns {string} The validated sequence.
814
+ * @throws {Error}
815
+ */
816
+ function validateSequenceInput(sequence) {
817
+ if (sequence === '') throw new Error('No sequence given');
818
+ if (/^([aAcCgGtTuUrRyYsSwWkKmMbBdDhHvVnN]+&)?[aAcCgGtTuUrRyYsSwWkKmMbBdDhHvVnN]+$/.test(sequence)) {
819
+ return sequence;
820
+ }
821
+ // find first invalid character for better error message
822
+ const invalidChars = sequence.replace(/[aAcCgGtTuUrRyYsSwWkKmMbBdDhHvVnN&]/g, '');
823
+ throw new Error(`The given sequence input has invalid none-IUPAC characters: ${invalidChars}`);
824
+ }
825
+
826
+ /**
827
+ * Validate cropping input. Must be an integer string, and disallowed for
828
+ * @param {string} cropping (integer string to be validated)
829
+ * @param {string} structure Validated structure string, used to check for unpaired-only structures)
830
+ * @returns the validated cropping string
831
+ * @throws {Error} When cropping is not a valid integer or when cropping is disallowed for unpaired-only structures.
832
+ */
833
+ function validateCroppingInput(structure, cropping) {
834
+ // check if cropping is not set, return default value
835
+ if (!cropping) return '-1'; // default value
836
+
837
+ // check if cropping is a valid integer string
838
+ if (!/^-?\d+$/.test(cropping)) {
839
+ throw new Error(`The given cropping input is not an integer: ${cropping}`);
840
+ }
841
+
842
+ // negative cropping is indicating no cropping, return -1
843
+ if (parseInt(cropping, 10) < 0) return -1;
844
+
845
+ // check if structure is only composed of dots (unpaired) and if so, disallow cropping
846
+ if( structure ) {
847
+ if (!structure.match(/[^.&]/)) {
848
+ throw new Error('Cropping is not allowed for structures with only unpaired nucleotides.');
849
+ }
850
+ if (structure.includes('&')) {
851
+ // check structure of the first molecule (before &) if present
852
+ const [struc1, struc2] = splitAtAmpersand(structure);
853
+ if (!struc1.match(/[^.]/) || !struc2.match(/[^.]/)) {
854
+ throw new Error('Cropping is not allowed for structures with only unpaired nucleotides in either molecule.');
855
+ }
856
+ }
857
+ }
858
+ return cropping;
859
+ }
860
+
861
+
862
+
863
+ /**
864
+ * Validate a structure string in dot-bracket notation.
865
+ *
866
+ * @param {string} structure
867
+ * @param {string} sequence Used to check length parity when `&` is present.
868
+ * @returns {string} The validated structure.
869
+ * @throws {Error}
870
+ */
871
+ function validateStructureInput(structure, sequence) {
872
+ if (structure === '') throw new Error('No structure given');
873
+
874
+ if (structure.includes('&')) {
875
+ const [struc1, struc2] = splitAtAmpersand(structure);
876
+ const [seq1, seq2] = splitAtAmpersand(sequence);
877
+ for (const [idx, struc, seq] of [[1, struc1, seq1], [2, struc2, seq2]]) {
878
+ if (struc.length !== seq.length) {
879
+ throw new Error(
880
+ `Structure length (${struc.length}) and Sequence length (${seq.length}) ` +
881
+ `of molecule ${idx} do not match`
882
+ );
883
+ }
884
+ }
885
+ } else {
886
+ if (structure.length !== sequence.length) {
887
+ throw new Error(
888
+ `Structure length (${structure.length}) and Sequence length (${sequence.length}) do not match`
889
+ );
890
+ }
891
+ }
892
+
893
+ if (/^([\.()<>\[\]{}]+&)?[\.()<>\[\]{}]+$/.test(structure)) {
894
+ checkStructureInputSimple(structure);
895
+ return structure;
896
+ }
897
+ throw new Error(`The given structure input is not valid: ${structure}`);
898
+ }
899
+
900
+ /**
901
+ * Validate an offset value.
902
+ *
903
+ * @param {string} offsetStr String representation of the offset.
904
+ * @returns {number}
905
+ * @throws {Error}
906
+ */
907
+ function validateOffset(offsetStr) {
908
+ if (offsetStr === '0') throw new Error('Index 0 is not valid; use a value of -1 or less, or 1 or greater');
909
+ if (/^-?\d+$/.test(offsetStr)) return parseInt(offsetStr, 10);
910
+ throw new Error(`The given index input is not valid: ${offsetStr}`);
911
+ }
912
+
913
+ /**
914
+ * Validate the highlighting option.
915
+ *
916
+ * @param {string} highlighting
917
+ * @returns {string}
918
+ * @throws {Error}
919
+ */
920
+ function validateHighlighting(highlighting) {
921
+ const valid = ['nothing', 'basepairs', 'region'];
922
+ if (valid.includes(highlighting)) return highlighting;
923
+ throw new Error(
924
+ `The given highlighting input (${highlighting}) is not accepted [nothing, basepairs, region]`
925
+ );
926
+ }
927
+
928
+ /**
929
+ * Validate the backgroundhighlighting option.
930
+ *
931
+ * @param {string} bgHighlighting
932
+ * @returns {string}
933
+ * @throws {Error}
934
+ */
935
+ function validateBackgroundhighlighting(bgHighlighting) {
936
+ const valid = ['nothing', 'basepairs', 'region'];
937
+ if (valid.includes(bgHighlighting)) return bgHighlighting;
938
+ throw new Error(
939
+ `The given backgroundhighlighting input (${bgHighlighting}) is not accepted [nothing, basepairs, region]`
940
+ );
941
+ }
942
+
943
+ /**
944
+ * Split structure string and apply the Fornac `&...` fix.
945
+ *
946
+ * Fornac incorrectly cuts the first 2 nodes of the second sequence when
947
+ * the separator is exactly `&`. Inserting `&...` compensates for this.
948
+ *
949
+ * @param {string} structure Raw structure (may contain `&`).
950
+ * @returns {{structure1: string, structure2: string, structure: string, structure_dict: Object}}
951
+ */
952
+ function formatStructure(structure) {
953
+ const [first, second] = splitAtAmpersand(structure);
954
+
955
+ // Fix: Fornac incorrectly cuts the first 2 nodes of the second sequence
956
+ // when the separator is exactly `&`. Inserting 3 gap dots compensates.
957
+ // Build strings explicitly from the already-split parts to avoid
958
+ // partial-replacement ambiguity on the `&` character.
959
+ const fixedStructure = second !== '' ? first + '&...' + second : first;
960
+ const bareStructure = second !== '' ? first + '...' + second : first;
961
+
962
+ const structureDict = {};
963
+ for (let i = 0; i < bareStructure.length; i++) {
964
+ structureDict[String(i + 1)] = bareStructure[i];
965
+ }
966
+
967
+ return { structure1: first, structure2: second, structure: fixedStructure, structure_dict: structureDict };
968
+ }
969
+
970
+ /**
971
+ * Split sequence string and apply the Fornac `&...` fix.
972
+ *
973
+ * @param {string} sequence Raw sequence (may contain `&`).
974
+ * @returns {{sequence1: string, sequence2: string, sequence: string, sequence_dict: Object}}
975
+ */
976
+ function formatSequence(sequence) {
977
+ const [first, second] = splitAtAmpersand(sequence);
978
+
979
+ // Same Fornac fix as formatStructure — build from split parts explicitly.
980
+ const fixedSequence = second !== '' ? first + '&...' + second : first;
981
+ const bareSequence = second !== '' ? first + '...' + second : first;
982
+
983
+ const sequenceDict = {};
984
+ for (let i = 0; i < bareSequence.length; i++) {
985
+ sequenceDict[String(i + 1)] = bareSequence[i];
986
+ }
987
+
988
+ return { sequence1: first, sequence2: second, sequence: fixedSequence, sequence_dict: sequenceDict };
989
+ }
990
+
991
+ /**
992
+ * Determine how many molecules are given (`"1"` or `"2"`).
993
+ *
994
+ * @param {{sequence2: string}} validated
995
+ * @returns {"1"|"2"}
996
+ */
997
+ function getMolecules(validated) {
998
+ return validated.sequence2 !== '' ? '2' : '1';
999
+ }
1000
+
1001
+ /**
1002
+ * Generate indexed sequence positions with RNA-style numbering (skipping 0).
1003
+ *
1004
+ * @param {string} seqId Sequence identifier, e.g. `"s1"`.
1005
+ * @param {number} offset Starting index.
1006
+ * @param {number} length Length of the sequence.
1007
+ * @returns {Array<[string, number]>} Array of [seqId, index] pairs.
1008
+ */
1009
+ function getSequenceIndices(seqId, offset, length) {
1010
+ const indices = [];
1011
+ for (let i = offset; i < offset + length; i++) {
1012
+ indices.push([seqId, i]);
1013
+ }
1014
+ // RNA-style: skip 0
1015
+ const zeroIdx = indices.findIndex(([, n]) => n === 0);
1016
+ if (zeroIdx !== -1) {
1017
+ indices.splice(zeroIdx, 1);
1018
+ const [seq, lastNum] = indices[indices.length - 1];
1019
+ indices.push([seq, lastNum + 1]);
1020
+ }
1021
+ return indices;
1022
+ }
1023
+
1024
+ /**
1025
+ * Build a mapping from Fornac node ID (1-based) to [sequenceId, position].
1026
+ *
1027
+ * @param {{offset1: number, offset2: number, sequence1: string, sequence2: string}} v
1028
+ * @returns {Object.<number, [string, number]>}
1029
+ */
1030
+ function getIndexDictionary(v) {
1031
+ const { offset1, offset2, sequence1, sequence2 } = v;
1032
+ const gapList = Array.from({ length: GAP }, () => ['e', 0]);
1033
+
1034
+ const indices = [
1035
+ ...getSequenceIndices('s1', offset1, sequence1.length),
1036
+ ...gapList,
1037
+ ...getSequenceIndices('s2', offset2, sequence2.length),
1038
+ ];
1039
+
1040
+ const dict = {};
1041
+ indices.forEach(([seq, num], i) => {
1042
+ dict[i + 1] = [seq, num];
1043
+ });
1044
+ return dict;
1045
+ }
1046
+
1047
+ /**
1048
+ * Crop leading and trailing unpaired nucleotides from sequences and structures.
1049
+ *
1050
+ * @param {string} rawSeq
1051
+ * @param {string} validStruc
1052
+ * @param {integer} offset1
1053
+ * @param {integer} offset2
1054
+ * @param {integer} cropping
1055
+ * @returns Object with updated rawSeq, validStruc, offset1, offset2
1056
+ */
1057
+ function applyCropping(rawSeq, validStruc, offset1, offset2, cropping) {
1058
+
1059
+ // check if cropping is not set or is negative, return original values
1060
+ if( !cropping || cropping < 0 ) {
1061
+ return { rawSeq, validStruc, offset1, offset2 };
1062
+ }
1063
+
1064
+ let seq = rawSeq.split('&');
1065
+ let str = validStruc.split('&');
1066
+ let off = [offset1, offset2];
1067
+
1068
+ for (let i = 0; i < seq.length; i++) {
1069
+ // leading cropping
1070
+ let unpairedLeading = str[i].match(/^\.+/);
1071
+ if (unpairedLeading && unpairedLeading[0].length > cropping) {
1072
+ seq[i] = seq[i].slice(unpairedLeading[0].length - cropping);
1073
+ str[i] = str[i].slice(unpairedLeading[0].length - cropping);
1074
+ const offOld = off[i];
1075
+ off[i] += unpairedLeading[0].length - cropping;
1076
+ if (off[i] >= 0 && offOld < 0) { off[i] += 1; } // skip 0
1077
+ }
1078
+ // trailing cropping
1079
+ let trailing = str[i].match(/\.+$/);
1080
+ if (trailing && trailing[0].length > cropping) {
1081
+ seq[i] = seq[i].slice(0, seq[i].length - (trailing[0].length - cropping));
1082
+ str[i] = str[i].slice(0, str[i].length - (trailing[0].length - cropping));
1083
+ }
1084
+ }
1085
+
1086
+ // return updated values
1087
+ return { rawSeq: seq.join("&"), validStruc: str.join("&"), offset1: off[0], offset2: off[1] };
1088
+ }
1089
+
1090
+ /**
1091
+ * Validate all inputs and return a `validated` parameter object ready for rendering.
1092
+ *
1093
+ * @param {Object} args Raw input parameters.
1094
+ * @param {string} args.structure Dot-bracket structure, one or two molecules separated by `&`.
1095
+ * @param {string} args.sequence RNA sequence, one or two molecules separated by `&`.
1096
+ * @param {string} [args.cropping="-1"] Cropping value (integer string).
1097
+ * @param {string} [args.startIndex1="1"] Start index for sequence 1.
1098
+ * @param {string} [args.startIndex2="1"] Start index for sequence 2.
1099
+ * @param {string} [args.labelInterval="10"] Interval for index label display.
1100
+ * @param {string} [args.coloring="strand"] Coloring option: `"strand"` or `"loop"`.
1101
+ * @param {string} [args.highlighting="region"] Highlighting option: `"nothing"`, `"basepairs"`, `"region"`.
1102
+ * @param {string} [args.backgroundhighlighting="basepairs"] Background-highlighting option.
1103
+ * @param {boolean} [args.distinctBpTypes=true] Whether to display G-U basepairs as dashed lines.
1104
+ * @param {Array<{sequence:string|number, range:string|Array<[number, number]>, color?:string}>} [args.subsequenceHighlights=[]]
1105
+ * Generic subsequence-highlight definitions.
1106
+ * @returns {Object} Validated parameter dictionary.
1107
+ * @throws {Error} On invalid input.
1108
+ */
1109
+ function validate(args) {
1110
+ const v = {};
1111
+
1112
+ // Sequence
1113
+ const rawSeq = (args.sequence || '').trim();
1114
+ validateSequenceInput(rawSeq);
1115
+
1116
+ // Structure
1117
+ const rawStruc = (args.structure || '').trim();
1118
+ const validStruc = validateStructureInput(rawStruc, rawSeq);
1119
+
1120
+ // Offsets
1121
+ v.offset1 = validateOffset(String(args.startIndex1 || '1'));
1122
+ v.offset2 = validateOffset(String(args.startIndex2 || '1'));
1123
+
1124
+ // Cropping
1125
+ const cropping = validateCroppingInput(validStruc, String(args.cropping || '-1'));
1126
+
1127
+ // update sequences, structures and offsets based on cropping
1128
+ const cropped = applyCropping(rawSeq, validStruc, v.offset1, v.offset2, cropping);
1129
+
1130
+ // update offset information
1131
+ v.offset1 = cropped.offset1;
1132
+ v.offset2 = cropped.offset2;
1133
+
1134
+ // create the formatted sequence and structure objects
1135
+ const seqFmt = formatSequence(cropped.rawSeq);
1136
+ Object.assign(v, seqFmt);
1137
+ const strucFmt = formatStructure(cropped.validStruc);
1138
+ Object.assign(v, strucFmt);
1139
+
1140
+ // Molecules
1141
+ v.molecules = getMolecules(v);
1142
+
1143
+ // Options
1144
+ v.coloring = args.coloring || 'strand';
1145
+ v.highlighting = validateHighlighting(args.highlighting || 'region');
1146
+ v.backgroundhighlighting = validateBackgroundhighlighting(
1147
+ args.backgroundhighlighting || 'basepairs'
1148
+ );
1149
+ v.distinctBpTypes = args.distinctBpTypes !== false; // default true
1150
+ v.labelInterval = parseInt(String(args.labelInterval || '10'), 10) || 10;
1151
+
1152
+ // Subsequence highlights
1153
+ const sequenceContext = {
1154
+ '1': { offset: v.offset1, length: v.sequence1.length, sequence: v.sequence1 },
1155
+ '2': { offset: v.offset2, length: v.sequence2.length, sequence: v.sequence2 },
1156
+ };
1157
+
1158
+ if (Array.isArray(args.subsequenceHighlights)) {
1159
+ v.subsequenceHighlights = args.subsequenceHighlights.map(h =>
1160
+ createSubsequenceHighlight(h, sequenceContext)
1161
+ );
1162
+ } else {
1163
+ v.subsequenceHighlights = [];
1164
+ }
1165
+
1166
+ if (Array.isArray(args.regionHighlights)) {
1167
+ v.regionHighlights = args.regionHighlights.map(highlight =>
1168
+ createRegionHighlight(highlight, sequenceContext)
1169
+ );
1170
+ } else {
1171
+ v.regionHighlights = [];
1172
+ }
1173
+
1174
+ if (Array.isArray(args.pointMutations)) {
1175
+ v.pointMutations = args.pointMutations.map(mutation =>
1176
+ createPointMutation(mutation, sequenceContext)
1177
+ );
1178
+
1179
+ const seenMutationPositions = new Set();
1180
+ v.pointMutations.forEach(mutation => {
1181
+ const key = `${mutation.sequence}:${mutation.position}`;
1182
+ if (seenMutationPositions.has(key)) {
1183
+ throw new Error(`Duplicate point mutation at ${key}.`);
1184
+ }
1185
+ seenMutationPositions.add(key);
1186
+
1187
+ mutation.nodeId = getNodeIdForSequencePosition(v, mutation.sequence, mutation.position);
1188
+ if (!mutation.nodeId) {
1189
+ throw new Error(`Mutation position ${mutation.position} is not visible in the current rendering.`);
1190
+ }
1191
+ mutation.labelText = `${mutation.reference || '?'}${mutation.position}${mutation.replacement}`;
1192
+ });
1193
+ } else {
1194
+ v.pointMutations = [];
1195
+ }
1196
+
1197
+ return v;
1198
+ }
1199
+
1200
+ /**
1201
+ * Parse a comma-separated list of `"start-end"` range strings.
1202
+ *
1203
+ * @param {string|null|undefined} input
1204
+ * @param {number} [startIndex]
1205
+ * @param {number} [sequenceLength]
1206
+ * @param {string|null|undefined} [sequenceId]
1207
+ * @returns {Array<[number,number]>|null}
1208
+ */
1209
+ function parseSubsequences(input, startIndex, sequenceLength, sequenceId) {
1210
+ if (!input || input.trim() === '') return null;
1211
+ let validIndices = null;
1212
+ if (Number.isInteger(startIndex) && Number.isInteger(sequenceLength) && sequenceLength >= 0) {
1213
+ validIndices = new Set(
1214
+ getSequenceIndices('s', startIndex, sequenceLength).map(([, index]) => index)
1215
+ );
1216
+ }
1217
+ const ranges = input.split(',').map(s => s.trim()).filter(Boolean);
1218
+ return ranges.map(r => {
1219
+ const match = r.match(/^(-?\d+)-(-?\d+)$/);
1220
+ if (!match) {
1221
+ throw new Error(`${sequenceId ? sequenceId+": " : ""}Invalid subsequence range: "${r}". Expected "start-end".`);
1222
+ }
1223
+ const start = parseInt(match[1], 10);
1224
+ const end = parseInt(match[2], 10);
1225
+
1226
+ if (start === 0 || end === 0) {
1227
+ throw new Error(`${sequenceId ? sequenceId+": " : ""}Invalid subsequence range: "${r}". Index 0 is not valid.`);
1228
+ }
1229
+ if (start > end) {
1230
+ throw new Error(`${sequenceId ? sequenceId+": " : ""}Invalid subsequence range: "${r}". Start index must be <= end index.`);
1231
+ }
1232
+ if (validIndices && (!validIndices.has(start) || !validIndices.has(end))) {
1233
+ throw new Error(
1234
+ `${sequenceId ? sequenceId+": " : ""}Invalid subsequence range: "${r}". Range endpoints must be valid sequence indices.`
1235
+ );
1236
+ }
1237
+
1238
+ return [start, end];
1239
+ });
1240
+ }
1241
+
1242
+ // -----------------------------------------------------------------------
1243
+ // DOM modification helpers (ported from modifications.py)
1244
+ // -----------------------------------------------------------------------
1245
+
1246
+ /**
1247
+ * Set an attribute on all elements that match `[targetAttr="targetValue"]`.
1248
+ *
1249
+ * @param {string} targetAttr
1250
+ * @param {string} targetValue
1251
+ * @param {string} setAttr
1252
+ * @param {string} setValue
1253
+ */
1254
+ function setAttributeForElements(targetAttr, targetValue, setAttr, setValue) {
1255
+ document.querySelectorAll(`[${targetAttr}="${targetValue}"]`).forEach(el => {
1256
+ el.setAttribute(setAttr, setValue);
1257
+ });
1258
+ }
1259
+
1260
+ /**
1261
+ * Generate a color list for two sequences.
1262
+ *
1263
+ * Each nucleotide in `seq1` maps to {@link COLORS.sequence1};
1264
+ * each nucleotide in `seq2` maps to {@link COLORS.sequence2}.
1265
+ *
1266
+ * @param {string} seq1
1267
+ * @param {string} seq2
1268
+ * @returns {string[]}
1269
+ */
1270
+ function sequenceColoring(seq1, seq2) {
1271
+ return [
1272
+ ...Array.from(seq1, () => COLORS.sequence1),
1273
+ ...Array.from(seq2, () => COLORS.sequence2),
1274
+ ];
1275
+ }
1276
+
1277
+ /**
1278
+ * Apply strand-based coloring to all nucleotide circles in the Fornac plot.
1279
+ *
1280
+ * @param {{sequence1: string, sequence2: string}} v
1281
+ */
1282
+ function changeBackgroundColor(v) {
1283
+ const coloring = sequenceColoring(v.sequence1, v.sequence2);
1284
+ if (coloring.length === 0) return;
1285
+ const nodes = document.querySelectorAll('[r="5"]');
1286
+ nodes.forEach((node, index) => {
1287
+ node.setAttribute('style', `fill: ${coloring[index]};`);
1288
+ });
1289
+ }
1290
+
1291
+ /**
1292
+ * Assign `start` and `end` attributes to every `<line>` link element.
1293
+ *
1294
+ * Fornac stores link identity in a tooltip text child; this function
1295
+ * parses it and promotes the IDs to proper attributes.
1296
+ */
1297
+ function setLinksId() {
1298
+ document.querySelectorAll('line').forEach(line => {
1299
+ const textContent = line.children[0] && line.children[0].textContent;
1300
+ if (!textContent) return;
1301
+ const parts = textContent.split(':')[1];
1302
+ if (!parts) return;
1303
+ const ids = parts.split('-').filter(x => !isNaN(parseInt(x, 10)) && x !== '');
1304
+ if (ids.length >= 2) {
1305
+ line.setAttribute('start', ids[0].trim());
1306
+ line.setAttribute('end', ids[1].trim());
1307
+ }
1308
+ });
1309
+ }
1310
+
1311
+ /**
1312
+ * Assign sequential `label_gnum` / `label_num` IDs to label elements.
1313
+ */
1314
+ function setLabelsId() {
1315
+ document.querySelectorAll('g[num="n-1"]').forEach((label, index) => {
1316
+ label.setAttribute('label_gnum', String(index + 1));
1317
+ if (label.firstChild) {
1318
+ label.firstChild.setAttribute('label_num', String(index + 1));
1319
+ }
1320
+ });
1321
+ }
1322
+
1323
+ /**
1324
+ * Update node tooltip text to display correct sequence and index labels.
1325
+ *
1326
+ * @param {Object} v Validated parameter dictionary.
1327
+ */
1328
+ function updateNodeToolTips(v) {
1329
+ const indexDict = getIndexDictionary(v);
1330
+ for (const [key, [seq, num]] of Object.entries(indexDict)) {
1331
+ document.querySelectorAll(`circle[node_num="${key}"]`).forEach(node => {
1332
+ if (node.firstChild) {
1333
+ node.firstChild.innerHTML = `${seq}[${num}]`;
1334
+ }
1335
+ });
1336
+ }
1337
+ }
1338
+
1339
+ /**
1340
+ * Validate whether a label marker should be placed at the given position.
1341
+ *
1342
+ * Prevents two adjacent markers from being displayed simultaneously.
1343
+ *
1344
+ * @param {number} pos
1345
+ * @param {Object.<number, number>} indexing
1346
+ * @param {number} number
1347
+ * @returns {number} The number to place, or 0 to suppress.
1348
+ */
1349
+ function validateLabelPos(pos, indexing, number) {
1350
+ for (const neighbor of [pos - 1, pos + 1]) {
1351
+ if (neighbor in indexing && indexing[neighbor] !== 0) {
1352
+ return 0;
1353
+ }
1354
+ }
1355
+ return number;
1356
+ }
1357
+
1358
+ /**
1359
+ * Apply the intermolecular-highlight stroke style to the label at the given index.
1360
+ *
1361
+ * @param {number} targetIndex
1362
+ */
1363
+ function highlightLabel(targetIndex) {
1364
+ document.querySelectorAll(`[label_num="${targetIndex}"]`).forEach(label => {
1365
+ label.setAttribute('style', `stroke: ${COLORS.intermolecularHighlight};stroke-width: 0.8;`);
1366
+ });
1367
+ }
1368
+
1369
+ /**
1370
+ * Set or update the SVG title used as a hover tooltip for a label.
1371
+ *
1372
+ * @param {SVGElement} label
1373
+ * @param {string} text
1374
+ */
1375
+ function setLabelTooltip(label, text) {
1376
+ const parent = label.parentElement;
1377
+ if (!parent) return;
1378
+
1379
+ const existingTitleOnLabel = label.querySelector('title');
1380
+ if (existingTitleOnLabel) existingTitleOnLabel.remove();
1381
+
1382
+ let title = parent.querySelector('title');
1383
+ if (!title) {
1384
+ title = document.createElementNS('http://www.w3.org/2000/svg', 'title');
1385
+ parent.insertBefore(title, parent.firstChild);
1386
+ }
1387
+ title.textContent = text;
1388
+ }
1389
+
1390
+ /**
1391
+ * Remove label group elements at the given index.
1392
+ *
1393
+ * @param {number} index
1394
+ */
1395
+ function removeLabel(index) {
1396
+ document.querySelectorAll(`[label_gnum="${index}"]`).forEach(node => node.remove());
1397
+ }
1398
+
1399
+ /**
1400
+ * Remove label-link line elements at the given index.
1401
+ *
1402
+ * @param {number} index
1403
+ */
1404
+ function removeLabelLink(index) {
1405
+ document.querySelectorAll(`line[start="${index}"]`).forEach(line => {
1406
+ if (line.getAttribute('link_type') === 'label_link') {
1407
+ line.remove();
1408
+ }
1409
+ });
1410
+ }
1411
+
1412
+ /** Return every combined nucleotide position and its selected index label. */
1413
+ function getIndexLabelValues(v) {
1414
+ const { structure1, structure2, sequence1, labelInterval, molecules, sequence_dict } = v;
1415
+ const length1 = sequence1.length;
1416
+ const lengthTotal = Object.keys(sequence_dict).length;
1417
+ const indexDict = getIndexDictionary(v);
1418
+ const indexLabels = {};
1419
+ for (const key of Object.keys(indexDict)) {
1420
+ indexLabels[parseInt(key, 10)] = 0;
1421
+ }
1422
+
1423
+ // Priority 1 — sequence boundaries
1424
+ for (const pos of [1, length1, length1 + GAP + 1, lengthTotal]) {
1425
+ if (!(pos in indexDict)) break;
1426
+ const [, number] = indexDict[pos];
1427
+ indexLabels[pos] = validateLabelPos(pos, indexLabels, number);
1428
+ }
1429
+
1430
+ // Priority 2 — intermolecular basepair region boundaries
1431
+ if (molecules === '2') {
1432
+ const basepairRegion = getIntermolBasepairRegion(structure1, structure2);
1433
+ for (const region of basepairRegion) {
1434
+ for (const pos of region) {
1435
+ if (!(pos in indexDict)) continue;
1436
+ const [, number] = indexDict[pos];
1437
+ indexLabels[pos] = validateLabelPos(pos, indexLabels, number);
1438
+ }
1439
+ }
1440
+ }
1441
+
1442
+ // Priority 3 — every labelInterval
1443
+ for (const [posStr, [, number]] of Object.entries(indexDict)) {
1444
+ const pos = parseInt(posStr, 10);
1445
+ if (number % labelInterval === 0 || number === 1) {
1446
+ indexLabels[pos] = validateLabelPos(pos, indexLabels, number);
1447
+ }
1448
+ }
1449
+
1450
+ return indexLabels;
1451
+ }
1452
+
1453
+ /**
1454
+ * Set index labels on the Fornac plot using a priority system.
1455
+ *
1456
+ * Priority order (highest → lowest):
1457
+ * 1. Start/end of each sequence.
1458
+ * 2. Start/end of intermolecular basepair region.
1459
+ * 3. Every `labelInterval`-th position.
1460
+ *
1461
+ * @param {Object} v Validated parameter dictionary.
1462
+ */
1463
+ function setIndexLabels(v) {
1464
+ const indexLabels = getIndexLabelValues(v);
1465
+ const mutationByNodeId = {};
1466
+ (Array.isArray(v.pointMutations) ? v.pointMutations : []).forEach(mutation => {
1467
+ if (mutation.nodeId) mutationByNodeId[mutation.nodeId] = mutation;
1468
+ });
1469
+
1470
+ if (v.molecules === '2') {
1471
+ getIntermolBasepairRegion(v.structure1, v.structure2)
1472
+ .flat()
1473
+ .forEach(highlightLabel);
1474
+ }
1475
+
1476
+ // Apply labels
1477
+ const labelValues = Object.entries(indexLabels);
1478
+ document.querySelectorAll('[label_type="label"]').forEach((label, index) => {
1479
+ const [posStr, value] = labelValues[index] || [];
1480
+ const pos = posStr ? parseInt(posStr, 10) : 0;
1481
+ const mutation = pos && mutationByNodeId[pos] ? mutationByNodeId[pos] : null;
1482
+
1483
+ if (mutation) {
1484
+ label.innerHTML = mutation.replacement;
1485
+ setLabelTooltip(label, `Mutation: ${mutation.labelText}`);
1486
+ label.setAttribute('style', `fill: ${mutation.color}; stroke: ${mutation.color}; stroke-width: 0.2; font-weight: bolder;`);
1487
+ addStyleToNodes([mutation.nodeId], `stroke: ${mutation.color}; stroke-width: 2px;`);
1488
+ return;
1489
+ }
1490
+
1491
+ label.removeAttribute('style');
1492
+ const parent = label.parentElement;
1493
+ const existingTitle = parent?.querySelector('title');
1494
+ if (existingTitle) existingTitle.remove();
1495
+ label.innerHTML = value !== undefined ? value : '';
1496
+ });
1497
+
1498
+ // Remove suppressed labels
1499
+ for (const [posStr, value] of Object.entries(indexLabels)) {
1500
+ const pos = parseInt(posStr, 10);
1501
+ if (value === 0 && !mutationByNodeId[pos]) {
1502
+ removeLabel(pos);
1503
+ removeLabelLink(pos);
1504
+ }
1505
+ }
1506
+ }
1507
+
1508
+ /**
1509
+ * Update tooltip text on link elements to display correct index values.
1510
+ *
1511
+ * @param {Object} v Validated parameter dictionary.
1512
+ */
1513
+ function updateLinkTooltips(v) {
1514
+ const updatedIndices = {};
1515
+ for (const [key, [, index]] of Object.entries(getIndexDictionary(v))) {
1516
+ updatedIndices[String(key)] = String(index);
1517
+ }
1518
+ document.querySelectorAll('line').forEach(line => {
1519
+ const start = line.getAttribute('start');
1520
+ const end = line.getAttribute('end');
1521
+ if (!line.firstChild) return;
1522
+ if (line.getAttribute('link_type') === 'label_link') {
1523
+ line.firstChild.textContent = updatedIndices[start] || '';
1524
+ } else {
1525
+ line.firstChild.textContent =
1526
+ (updatedIndices[start] || '') + '-' + (updatedIndices[end] || '');
1527
+ }
1528
+ });
1529
+ }
1530
+
1531
+ /**
1532
+ * Apply a CSS style string to an array of nodes by `node_num`.
1533
+ *
1534
+ * @param {number[]} nodeIds
1535
+ * @param {string} style
1536
+ */
1537
+ function addStyleToNodes(nodeIds, style) {
1538
+ nodeIds.forEach(nodeId => {
1539
+ document.querySelectorAll(`circle[node_num="${nodeId}"]`).forEach(node => {
1540
+ node.setAttribute('style', (node.getAttribute('style') || '') + style);
1541
+ });
1542
+ });
1543
+ }
1544
+
1545
+ /**
1546
+ * Retrieve the x,y position of a Fornac node from its `transform` attribute.
1547
+ *
1548
+ * @param {number} nodeId
1549
+ * @returns {number[]} [x, y] coordinates.
1550
+ */
1551
+ function getPositionOfNode(nodeId) {
1552
+ const pos = [];
1553
+ document.querySelectorAll(`g[num="n${nodeId}"]`).forEach(node => {
1554
+ const transform = node.getAttribute('transform') || '';
1555
+ const matches = [...transform.matchAll(/-?\d+(?:\.\d+)?/g)];
1556
+ matches.forEach(([val]) => pos.push(parseFloat(val)));
1557
+ });
1558
+ return pos;
1559
+ }
1560
+
1561
+ /**
1562
+ * Resolve where new overlay elements should be inserted.
1563
+ *
1564
+ * If a vaRRI rotation layer exists, insert into that layer so newly added
1565
+ * overlays follow the current rotation.
1566
+ *
1567
+ * @returns {SVGElement|null}
1568
+ */
1569
+ function getPlotInsertRoot() {
1570
+ const plot = document.getElementsByClassName('fornac-plot')[0];
1571
+ if (!plot) return null;
1572
+
1573
+ const rotationLayer = Array.from(plot.children).find(child =>
1574
+ child.tagName && child.tagName.toLowerCase() === 'g' &&
1575
+ child.getAttribute('data-varri-rotation-layer') === 'true'
1576
+ );
1577
+
1578
+ return rotationLayer || plot;
1579
+ }
1580
+
1581
+ /**
1582
+ * Create and insert an SVG element at the beginning of the Fornac plot.
1583
+ *
1584
+ * @param {string} elementType SVG tag name (e.g. `"circle"`, `"polyline"`).
1585
+ * @param {Object.<string,string>} attr Attribute key→value map.
1586
+ */
1587
+ function addElement(elementType, attr) {
1588
+ const el = document.createElementNS('http://www.w3.org/2000/svg', elementType);
1589
+ for (const [key, value] of Object.entries(attr)) {
1590
+ el.setAttribute(key, value);
1591
+ }
1592
+ const insertRoot = getPlotInsertRoot();
1593
+ if (insertRoot) insertRoot.insertBefore(el, insertRoot.firstChild);
1594
+ }
1595
+
1596
+ /**
1597
+ * Resolve the x/y coordinates of a list of Fornac node IDs.
1598
+ *
1599
+ * @param {number[]} indices Fornac node IDs to resolve.
1600
+ * @returns {Array<[number, number]>}
1601
+ */
1602
+ function getNodePointPairs(indices) {
1603
+ const points = [];
1604
+ indices.forEach(index => {
1605
+ document.querySelectorAll(`g[num="n${index}"]`).forEach(node => {
1606
+ const transform = node.getAttribute('transform') || '';
1607
+ const match = [...transform.matchAll(/-?\d+(?:\.\d+)?/g)];
1608
+ if (match.length >= 2) {
1609
+ points.push([parseFloat(match[0][0]), parseFloat(match[1][0])]);
1610
+ }
1611
+ });
1612
+ });
1613
+ return points;
1614
+ }
1615
+
1616
+ /**
1617
+ * Close a polygon point list by appending the first point at the end.
1618
+ *
1619
+ * @param {Array<[number, number]>} points
1620
+ * @returns {string[]}
1621
+ */
1622
+ function closePolygonPoints(points) {
1623
+ if (!Array.isArray(points) || points.length === 0) return [];
1624
+ const pointStrings = points.map(([x, y]) => `${x},${y}`);
1625
+ if (pointStrings.length < 2) return pointStrings;
1626
+ return [...pointStrings, pointStrings[0]];
1627
+ }
1628
+
1629
+ function insertSvgShape(tagName, pointString, style, extraAttrs) {
1630
+ const shape = document.createElementNS('http://www.w3.org/2000/svg', tagName);
1631
+ shape.setAttribute('points', pointString);
1632
+ shape.setAttribute('style', style);
1633
+ for (const [name, value] of Object.entries(extraAttrs)) {
1634
+ shape.setAttribute(name, value);
1635
+ }
1636
+ const insertRoot = getPlotInsertRoot();
1637
+ if (insertRoot) insertRoot.insertBefore(shape, insertRoot.firstChild);
1638
+ }
1639
+
1640
+ /**
1641
+ * Draw a polyline connecting a list of Fornac node positions.
1642
+ *
1643
+ * @param {number[]} indices Fornac node IDs to connect.
1644
+ * @param {string} style CSS style string for the polyline.
1645
+ */
1646
+ function polyline(indices, style, extraAttrs = {}) {
1647
+ const points = getNodePointPairs(indices);
1648
+ const pointString = points.map(([x, y]) => `${x},${y}`).join(' ');
1649
+
1650
+ insertSvgShape('polyline', pointString, style, extraAttrs);
1651
+ }
1652
+
1653
+ /**
1654
+ * Draw a closed polygon connecting a list of Fornac node positions.
1655
+ *
1656
+ * @param {number[]} indices Fornac node IDs to connect.
1657
+ * @param {string} style CSS style string for the polygon.
1658
+ */
1659
+ function polygon(indices, style, extraAttrs = {}) {
1660
+ const points = getNodePointPairs(indices);
1661
+ const pointString = closePolygonPoints(points).join(' ');
1662
+
1663
+ insertSvgShape('polygon', pointString, style, extraAttrs);
1664
+ }
1665
+
1666
+ /**
1667
+ * Compute [start, end] ranges of intermolecular basepair regions.
1668
+ *
1669
+ * @param {string} structure1
1670
+ * @param {string} structure2
1671
+ * @returns {Array<[number, number]>}
1672
+ */
1673
+ function getIntermolBasepairRegion(structure1, structure2) {
1674
+ const basepairRegion = [];
1675
+ const offset = structure1.length + GAP;
1676
+
1677
+ for (const [structure, shift] of [[structure1, 0], [structure2, offset]]) {
1678
+ const basepairList = listIntermolNodes(structure, shift).map(([idx]) => idx);
1679
+ if (basepairList.length === 0) return [];
1680
+ basepairRegion.push([basepairList[0], basepairList[basepairList.length - 1]]);
1681
+ }
1682
+ return basepairRegion;
1683
+ }
1684
+
1685
+ /**
1686
+ * Highlight nodes in the intermolecular basepair region with a stroke.
1687
+ *
1688
+ * @param {Object} v Validated parameter dictionary.
1689
+ */
1690
+ function highlightRegion(v) {
1691
+ const basepairRegion = getIntermolBasepairRegion(v.structure1, v.structure2);
1692
+ const intermolNodes = [];
1693
+ for (const [start, end] of basepairRegion) {
1694
+ for (let i = start; i <= end; i++) intermolNodes.push(i);
1695
+ }
1696
+ addStyleToNodes(intermolNodes, `stroke: ${COLORS.intermolecularHighlight};`);
1697
+ }
1698
+
1699
+ /**
1700
+ * Highlight individual intermolecular basepair nodes with a stroke.
1701
+ *
1702
+ * @param {Object} v Validated parameter dictionary.
1703
+ */
1704
+ function highlightBasepairs(v) {
1705
+ const split = v.sequence1.length + 1;
1706
+ // Highlight all nodes that are part of intermolecular basepairs of main layouting (basepair) or 2ndary layouting (pseudoknot)
1707
+ for (const type of ["basepair", "pseudoknot"]) {
1708
+ document.querySelectorAll(`[link_type="${type}"]`).forEach(link => {
1709
+ const nodes = [
1710
+ parseInt(link.getAttribute('start'), 10),
1711
+ parseInt(link.getAttribute('end'), 10),
1712
+ ];
1713
+ if (!(nodes[0] < split && nodes[1] > split)) return;
1714
+ nodes.forEach(nodeNum => {
1715
+ const node = document.querySelector(`circle[node_num="${nodeNum}"]`);
1716
+ if (node) {
1717
+ node.setAttribute('style', (node.getAttribute('style') || '') + `stroke: ${COLORS.intermolecularHighlight};`);
1718
+ }
1719
+ });
1720
+ });
1721
+ }
1722
+ }
1723
+
1724
+ /**
1725
+ * Remove duplicate basepair links (keep only links where start < end).
1726
+ */
1727
+ function removeSecondLink() {
1728
+ document.querySelectorAll('[link_type="basepair"]').forEach(link => {
1729
+ const start = parseInt(link.getAttribute('start'), 10);
1730
+ const end = parseInt(link.getAttribute('end'), 10);
1731
+ if (start > end) link.remove();
1732
+ });
1733
+ }
1734
+
1735
+ /**
1736
+ * Remove a Fornac node group element by ID.
1737
+ *
1738
+ * @param {number} id
1739
+ */
1740
+ function removeNode(id) {
1741
+ document.querySelectorAll(`[num="n${id}"]`).forEach(node => node.remove());
1742
+ }
1743
+
1744
+ /**
1745
+ * Remove the directional arrow from a node.
1746
+ *
1747
+ * @param {number} id
1748
+ */
1749
+ function removeArrow(id) {
1750
+ document.querySelectorAll(`[num="n${id}"]`).forEach(node => {
1751
+ if (node.firstChild) node.firstChild.remove();
1752
+ });
1753
+ }
1754
+
1755
+ /**
1756
+ * Remove a backbone link between two nodes.
1757
+ *
1758
+ * @param {number} startId
1759
+ * @param {number} endId
1760
+ */
1761
+ function removeLink(startId, endId) {
1762
+ const targetIds = `${startId},${endId}`;
1763
+ document.querySelectorAll('[link_type="backbone"]').forEach(link => {
1764
+ const ids = `${link.getAttribute('start')},${link.getAttribute('end')}`;
1765
+ if (ids === targetIds) link.remove();
1766
+ });
1767
+ }
1768
+
1769
+ /**
1770
+ * Remove dummy gap nodes that Fornac inserts between two molecules.
1771
+ *
1772
+ * @param {string} sequence The combined sequence string (with `&` and fix dots).
1773
+ */
1774
+ function removeDummyNodes(sequence) {
1775
+ for (let index = 0; index < sequence.length; index++) {
1776
+ if (sequence[index] === '.') {
1777
+ removeLink(index, index + 1);
1778
+ removeArrow(index + 1);
1779
+ removeNode(index);
1780
+ }
1781
+ }
1782
+ }
1783
+
1784
+ /**
1785
+ * Highlight subsequence ranges with polyline/circle overlays.
1786
+ *
1787
+ * @param {Object} v Validated parameter dictionary.
1788
+ * @param {"1"|"2"} seq Which sequence to highlight.
1789
+ * @param {Array<[number, number]>} range Parsed index range.
1790
+ * @param {string} color Highlight color.
1791
+ * @param {number} Highlight opacity.
1792
+ */
1793
+ function highlightSubsequence(v, seq, range, color, alpha) {
1794
+ const highlightDiameter = 14;
1795
+ const keyOffset = `offset${seq}`;
1796
+
1797
+ // Map RNA index → Fornac web node id for the relevant sequence
1798
+ const indexDict = {};
1799
+ for (const [web, [mol, index]] of Object.entries(getIndexDictionary(v))) {
1800
+ if (mol === `s${seq}`) {
1801
+ indexDict[index] = parseInt(web, 10);
1802
+ }
1803
+ }
1804
+
1805
+ const shift = seq === '2' ? v.sequence1.length + GAP : 0;
1806
+
1807
+ for (const [start, end] of (range || [])) {
1808
+ const startIndex = v[keyOffset];
1809
+
1810
+ if (start === end) {
1811
+ const webId = indexDict[start];
1812
+ const [x, y] = getPositionOfNode(webId);
1813
+ addElement('circle', {
1814
+ cx: String(x),
1815
+ cy: String(y),
1816
+ r: `${Math.ceil(highlightDiameter/2)}px`,
1817
+ style: `fill:${color};opacity:${alpha};`,
1818
+ 'data-varri-subseq': 'true',
1819
+ });
1820
+ continue;
1821
+ }
1822
+
1823
+ let distance1 = start - startIndex;
1824
+ let distance2 = end - start;
1825
+
1826
+ if (startIndex < 0 && start > 0) distance1 -= 1;
1827
+ if (start < 0 && end > 0) distance2 -= 1;
1828
+
1829
+ const startNode = distance1 + 1 + shift;
1830
+ const endNode = distance1 + distance2 + 1 + shift;
1831
+ const indices = [];
1832
+ for (let i = startNode; i <= endNode; i++) indices.push(i);
1833
+
1834
+ polyline(indices,
1835
+ `stroke:${color};stroke-width:14;opacity:${alpha};fill:None;` +
1836
+ 'stroke-linejoin:round;stroke-linecap:round',
1837
+ { 'data-varri-subseq': 'true' }
1838
+ );
1839
+ }
1840
+ }
1841
+
1842
+ /**
1843
+ * Remove all generated region highlights from the active registry.
1844
+ */
1845
+ function clearGeneratedRegionHighlights() {
1846
+ getRegionHighlights().filter(highlight => highlight.generated).forEach(highlight => {
1847
+ removeRegionHighlight(highlight.id);
1848
+ });
1849
+ }
1850
+
1851
+ /**
1852
+ * Register a generated region highlight from sequence ranges.
1853
+ *
1854
+ * @param {Object} v
1855
+ * @param {{sequence1Range:[number, number], sequence2Range:[number, number], color?:string, alpha?:number}} spec
1856
+ * @returns {Object}
1857
+ */
1858
+ function registerGeneratedRegionHighlight(v, spec) {
1859
+ const sequenceContext = {
1860
+ '1': { offset: v.offset1, length: v.sequence1 ? v.sequence1.length : 0, sequence: v.sequence1 },
1861
+ '2': { offset: v.offset2, length: v.sequence2 ? v.sequence2.length : 0, sequence: v.sequence2 },
1862
+ };
1863
+
1864
+ return registerRegionHighlight({
1865
+ sequence1Range: spec.sequence1Range,
1866
+ sequence2Range: spec.sequence2Range,
1867
+ color: spec.color || COLORS.backgroundHighlight,
1868
+ alpha: spec.alpha,
1869
+ generated: true,
1870
+ }, sequenceContext);
1871
+ }
1872
+
1873
+ /**
1874
+ * Derive a true sequence-position range (matching offset/skip-zero
1875
+ * numbering) for a given sequence from a list of combined node/structure
1876
+ * positions (as produced by {@link listIntermolPairs} or
1877
+ * {@link getIntermolBasepairRegion}).
1878
+ *
1879
+ * @param {Object} v
1880
+ * @param {number[]} positions Combined node positions (1-based, gap-inclusive).
1881
+ * @param {'1'|'2'} sequence
1882
+ * @returns {[number, number]|null}
1883
+ */
1884
+ function getBackgroundRangeForPositions(v, positions, sequence) {
1885
+ const indexDict = getIndexDictionary(v);
1886
+ const values = positions
1887
+ .map(position => indexDict[position])
1888
+ .filter(entry => Array.isArray(entry) && entry[0] === `s${sequence}`)
1889
+ .map(([, seqPosition]) => seqPosition)
1890
+ .filter(Number.isFinite);
1891
+
1892
+ if (values.length === 0) return null;
1893
+ return [Math.min(...values), Math.max(...values)];
1894
+ }
1895
+
1896
+ /**
1897
+ * Compute the generated region-highlight ranges for the "entire
1898
+ * intermolecular region" background-highlighting mode, expressed as true
1899
+ * sequence positions (matching offset/skip-zero numbering).
1900
+ *
1901
+ * @param {Object} v Validated parameter dictionary.
1902
+ * @returns {{sequence1Range:[number,number], sequence2Range:[number,number]}|null}
1903
+ */
1904
+ function computeBackgroundRegionRanges(v) {
1905
+ const basepairRegion = getIntermolBasepairRegion(v.structure1, v.structure2);
1906
+ if (!basepairRegion || basepairRegion.length < 2) return null;
1907
+
1908
+ const sequence1Range = getBackgroundRangeForPositions(v, basepairRegion[0], '1');
1909
+ const sequence2Range = getBackgroundRangeForPositions(v, basepairRegion[1], '2');
1910
+ if (!sequence1Range || !sequence2Range) return null;
1911
+
1912
+ return { sequence1Range, sequence2Range };
1913
+ }
1914
+
1915
+ /**
1916
+ * Build the node-ID path for a region highlight's filled polygon.
1917
+ *
1918
+ * @param {Object} v
1919
+ * @param {Object} highlight
1920
+ * @returns {number[]}
1921
+ */
1922
+ function getRegionHighlightNodePath(v, highlight) {
1923
+ const nodeIds = [];
1924
+ const seq1Range = Array.isArray(highlight.sequence1Range) ? highlight.sequence1Range : [];
1925
+ const seq2Range = Array.isArray(highlight.sequence2Range) ? highlight.sequence2Range : [];
1926
+
1927
+ for (let position = seq1Range[0]; position <= seq1Range[1]; position++) {
1928
+ const nodeId = getNodeIdForSequencePosition(v, '1', position);
1929
+ if (nodeId) nodeIds.push(nodeId);
1930
+ }
1931
+
1932
+ for (let position = seq2Range[0]; position <= seq2Range[1]; position++) {
1933
+ const nodeId = getNodeIdForSequencePosition(v, '2', position);
1934
+ if (nodeId) nodeIds.push(nodeId);
1935
+ }
1936
+
1937
+ return nodeIds;
1938
+ }
1939
+
1940
+ /**
1941
+ * Apply all region highlights from the active registry.
1942
+ *
1943
+ * @param {Object} v
1944
+ */
1945
+ function applyRegionHighlights(v) {
1946
+ const registryHighlights = getRegionHighlights();
1947
+ const highlights = registryHighlights.length > 0
1948
+ ? registryHighlights
1949
+ : (Array.isArray(v.regionHighlights) ? v.regionHighlights : []);
1950
+
1951
+ highlights.forEach(highlight => {
1952
+ const nodePath = getRegionHighlightNodePath(v, highlight);
1953
+ if (nodePath.length >= 3) {
1954
+ polygon(
1955
+ nodePath,
1956
+ `fill:${highlight.color || COLORS.backgroundHighlight};opacity:${highlight.alpha};stroke:${highlight.color || COLORS.backgroundHighlight};stroke-width:7`,
1957
+ { 'data-varri-region': 'true' }
1958
+ );
1959
+ } else if (nodePath.length === 2) {
1960
+ polyline(
1961
+ nodePath,
1962
+ `stroke:${highlight.color || COLORS.backgroundHighlight};opacity:${highlight.alpha};stroke-width:7`,
1963
+ { 'data-varri-region': 'true' }
1964
+ );
1965
+ }
1966
+ });
1967
+ }
1968
+
1969
+ /**
1970
+ * Apply all subsequence highlights from `v.subsequenceHighlights`.
1971
+ *
1972
+ * @param {Object} v
1973
+ */
1974
+ function applySubsequenceHighlights(v) {
1975
+ const highlights = Array.isArray(v.subsequenceHighlights) ? v.subsequenceHighlights : [];
1976
+ highlights.forEach(highlight => {
1977
+ highlightSubsequence(
1978
+ v,
1979
+ highlight.sequence,
1980
+ highlight.range,
1981
+ highlight.color || COLORS.subsequenceHighlight,
1982
+ highlight.alpha
1983
+ );
1984
+ });
1985
+ }
1986
+
1987
+ /**
1988
+ * Apply point-mutation styling to nucleotide nodes.
1989
+ *
1990
+ * @param {Object} v
1991
+ */
1992
+ function applyPointMutations(v) {
1993
+ const mutations = Array.isArray(v.pointMutations) ? v.pointMutations : [];
1994
+ mutations.forEach(mutation => {
1995
+ if (!mutation.nodeId) return;
1996
+ addStyleToNodes([mutation.nodeId], `stroke: ${mutation.color}; stroke-width: 2px;`);
1997
+ });
1998
+ }
1999
+
2000
+ /**
2001
+ * Visualise basepairs: apply the basepair colour to all basepair links,
2002
+ * and additionally mark G-U basepairs with a dashed line style.
2003
+ *
2004
+ * @param {Object} v Validated parameter dictionary.
2005
+ */
2006
+ function styleBasepairs(v) {
2007
+ // Apply basepair colour to all basepair links using inline style so it
2008
+ // overrides the Fornac CSS rule `line.fornac-link[link_type="basepair"]
2009
+ // { stroke: red; }`, which takes precedence over SVG presentation
2010
+ // attributes.
2011
+ document.querySelectorAll('[link_type="basepair"]').forEach(link => {
2012
+ link.style.stroke = COLORS.basepair;
2013
+ });
2014
+
2015
+ if (v.distinctBpTypes) {
2016
+ // Build a 1-based sequence map including gap dots
2017
+ const seq1 = v.sequence1;
2018
+ const seq2 = v.sequence2;
2019
+ const gapDots = '.'.repeat(GAP);
2020
+ const combined = seq1 + gapDots + seq2;
2021
+ const seqDict = {};
2022
+ for (let i = 0; i < combined.length; i++) {
2023
+ seqDict[String(i + 1)] = combined[i];
2024
+ }
2025
+
2026
+ document.querySelectorAll('[link_type="basepair"], [link_type="pseudoknot"]').forEach(link => {
2027
+ const l1 = seqDict[link.getAttribute('start')];
2028
+ const l2 = seqDict[link.getAttribute('end')];
2029
+ const bp = [l1, l2].sort().join('-').toLowerCase();
2030
+ if (bp === 'g-u') {
2031
+ link.style.strokeLinecap = 'butt';
2032
+ link.style.strokeDasharray = '1 1';
2033
+ } else if (bp === 'c-g' || bp === 'a-u') {
2034
+ link.style.strokeLinecap = 'butt';
2035
+ link.style.strokeDasharray = '';
2036
+ } else {
2037
+ link.style.strokeLinecap = 'round';
2038
+ link.style.strokeDasharray = '0 3';
2039
+ }
2040
+ });
2041
+ } else {
2042
+ document.querySelectorAll('[link_type="basepair"], [link_type="pseudoknot"]').forEach(link => {
2043
+ link.style.strokeLinecap = 'butt';
2044
+ link.style.strokeDasharray = '';
2045
+ });
2046
+ }
2047
+ }
2048
+
2049
+ /**
2050
+ * Parse basepairs from a dot-bracket-like structure dictionary.
2051
+ *
2052
+ * @param {Object.<string, string>} struc Position → bracket character map.
2053
+ * @returns {Array<[number, number]>} Sorted basepair index pairs.
2054
+ */
2055
+ function listBasepairs(struc) {
2056
+ const basepairs = [];
2057
+ const openBasepairs = { '(': [], '<': [], '[': [], '{': [] };
2058
+ const brackets = [['(', ')'], ['[', ']'], ['{', '}'], ['<', '>']];
2059
+
2060
+ for (const [indexStr, char] of Object.entries(struc)) {
2061
+ const index = parseInt(indexStr, 10);
2062
+ for (const [open, close] of brackets) {
2063
+ if (char === open) { openBasepairs[open].push(index); break; }
2064
+ if (char === close) {
2065
+ if (openBasepairs[open].length > 0) {
2066
+ basepairs.push([openBasepairs[open].pop(), index]);
2067
+ }
2068
+ break;
2069
+ }
2070
+ }
2071
+ }
2072
+ basepairs.sort((a, b) => a[0] - b[0]);
2073
+ return basepairs;
2074
+ }
2075
+
2076
+ /**
2077
+ * Extract intermolecular basepair pairs from the combined structure.
2078
+ *
2079
+ * @param {Object} v Validated parameter dictionary.
2080
+ * @returns {Array<[number, number]>}
2081
+ */
2082
+ function listIntermolPairs(v) {
2083
+ const struc = v.structure_dict;
2084
+ const struc1 = v.structure1;
2085
+ const struc2 = v.structure2;
2086
+ const shift = struc1.length + GAP;
2087
+
2088
+ const intermol = {};
2089
+ for (const i of Object.keys(struc)) intermol[i] = '.';
2090
+
2091
+ for (const [index, bracket] of [
2092
+ ...listIntermolNodes(struc1),
2093
+ ...listIntermolNodes(struc2, shift),
2094
+ ]) {
2095
+ intermol[String(index)] = bracket;
2096
+ }
2097
+
2098
+ return listBasepairs(intermol);
2099
+ }
2100
+
2101
+ /**
2102
+ * Normalize base-pair endpoints and return a deterministic, duplicate-free
2103
+ * list ordered by the first and then the second nucleotide.
2104
+ *
2105
+ * @param {Array<[number, number]>} basepairs
2106
+ * @returns {Array<[number, number]>}
2107
+ */
2108
+ function normaliseBasepairList(basepairs) {
2109
+ const seen = new Set();
2110
+ const pairs = [];
2111
+
2112
+ (Array.isArray(basepairs) ? basepairs : []).forEach(pair => {
2113
+ if (!Array.isArray(pair) || pair.length < 2) return;
2114
+ const first = Number(pair[0]);
2115
+ const second = Number(pair[1]);
2116
+ if (!Number.isInteger(first) || !Number.isInteger(second) || first === second) return;
2117
+
2118
+ const normalized = first < second ? [first, second] : [second, first];
2119
+ const key = normalized[0] + ':' + normalized[1];
2120
+ if (seen.has(key)) return;
2121
+ seen.add(key);
2122
+ pairs.push(normalized);
2123
+ });
2124
+
2125
+ return pairs.sort((left, right) =>
2126
+ left[0] - right[0] || left[1] - right[1]
2127
+ );
2128
+ }
2129
+
2130
+ /**
2131
+ * Find the direct nested children of every base pair.
2132
+ *
2133
+ * For a fixed outer pair, candidates are visited by increasing opening
2134
+ * endpoint. A candidate is covered by an earlier candidate exactly when
2135
+ * that earlier candidate has the larger closing endpoint. Keeping the
2136
+ * largest earlier closing endpoint therefore computes the cover relation
2137
+ * in O(n^2), without joining an outer pair to a deeper pair through an
2138
+ * intervening base-pair column.
2139
+ *
2140
+ * @param {Array<[number, number]>} basepairs
2141
+ * @returns {Array<{outer:[number,number],children:Array<[number,number]>}>}
2142
+ */
2143
+ function listDirectNestedPairChildren(basepairs) {
2144
+ const pairs = normaliseBasepairList(basepairs);
2145
+
2146
+ return pairs.map((outer, outerIndex) => {
2147
+ const children = [];
2148
+ let largestEarlierClose = -Infinity;
2149
+
2150
+ for (let innerIndex = outerIndex + 1; innerIndex < pairs.length; innerIndex++) {
2151
+ const inner = pairs[innerIndex];
2152
+ if (inner[0] >= outer[1]) break;
2153
+ if (inner[1] >= outer[1]) continue;
2154
+
2155
+ if (largestEarlierClose <= inner[1]) children.push(inner);
2156
+ largestEarlierClose = Math.max(largestEarlierClose, inner[1]);
2157
+ }
2158
+
2159
+ return { outer, children };
2160
+ });
2161
+ }
2162
+
2163
+ function pairEquals(left, right) {
2164
+ return left[0] === right[0] && left[1] === right[1];
2165
+ }
2166
+
2167
+ function pairsCross(left, right) {
2168
+ return (
2169
+ left[0] < right[0] && right[0] < left[1] && left[1] < right[1]
2170
+ ) || (
2171
+ right[0] < left[0] && left[0] < right[1] && right[1] < left[1]
2172
+ );
2173
+ }
2174
+
2175
+ function createLoopBoundary(outer, inner, extra = {}) {
2176
+ const firstGap = inner[0] - outer[0] - 1;
2177
+ const secondGap = outer[1] - inner[1] - 1;
2178
+ return {
2179
+ outer: outer.slice(),
2180
+ inner: inner.slice(),
2181
+ gaps: [firstGap, secondGap],
2182
+ loopType: firstGap > 0 && secondGap > 0 ? 'interior' : 'bulge',
2183
+ ...extra,
2184
+ };
2185
+ }
2186
+
2187
+ /**
2188
+ * Identify intermolecular base-pair columns that directly bound RRI
2189
+ * bulges or interior loops.
2190
+ *
2191
+ * A result obeys the exact antiparallel cover relation from issue #59.
2192
+ * Fully stacked columns are excluded. A candidate touched by a crossing
2193
+ * RRI pair is also excluded because bulge/interior-loop decomposition is
2194
+ * not defined for that pseudoknotted region.
2195
+ *
2196
+ * @param {Object} v Validated parameter dictionary.
2197
+ * @returns {Array<{outer:[number,number],inner:[number,number],gaps:[number,number],loopType:string}>}
2198
+ */
2199
+ function listRriLoopBoundaryPairs(v) {
2200
+ const pairs = normaliseBasepairList(listIntermolPairs(v));
2201
+ const boundaries = [];
2202
+
2203
+ listDirectNestedPairChildren(pairs).forEach(({ outer, children }) => {
2204
+ children.forEach(inner => {
2205
+ const firstGap = inner[0] - outer[0] - 1;
2206
+ const secondGap = outer[1] - inner[1] - 1;
2207
+ if (firstGap === 0 && secondGap === 0) return;
2208
+
2209
+ const crossesBoundary = pairs.some(pair => {
2210
+ if (pairEquals(pair, outer) || pairEquals(pair, inner)) return false;
2211
+ if (pairsCross(pair, outer) || pairsCross(pair, inner)) return true;
2212
+ const firstInside = outer[0] < pair[0] && pair[0] < inner[0];
2213
+ const secondInside = inner[1] < pair[1] && pair[1] < outer[1];
2214
+ return firstInside !== secondInside;
2215
+ });
2216
+ if (!crossesBoundary) boundaries.push(createLoopBoundary(outer, inner));
2217
+ });
2218
+ });
2219
+
2220
+ return boundaries;
2221
+ }
2222
+
2223
+ /**
2224
+ * Group intramolecular base pairs by strand using Fornac node numbers.
2225
+ * Synthetic inter-molecule gap nodes are deliberately excluded.
2226
+ *
2227
+ * @param {Object} v Validated parameter dictionary.
2228
+ * @returns {{"1":Array<[number,number]>,"2":Array<[number,number]>}}
2229
+ */
2230
+ function listIntramolPairsBySequence(v) {
2231
+ const sequence1End = v.sequence1.length;
2232
+ const sequence2Start = sequence1End + GAP + 1;
2233
+ const sequence2End = sequence1End + GAP + v.sequence2.length;
2234
+ const grouped = { '1': [], '2': [] };
2235
+
2236
+ listBasepairs(v.structure_dict).forEach(pair => {
2237
+ if (pair[0] >= 1 && pair[1] <= sequence1End) {
2238
+ grouped['1'].push(pair);
2239
+ } else if (pair[0] >= sequence2Start && pair[1] <= sequence2End) {
2240
+ grouped['2'].push(pair);
2241
+ }
2242
+ });
2243
+
2244
+ return grouped;
2245
+ }
2246
+
2247
+ /**
2248
+ * Identify intramolecular bulges/interior loops independently per strand.
2249
+ * A true bulge/interior loop has one direct child stem; hairpins (zero),
2250
+ * multiloops (multiple), ordinary stacks, and crossing pairs are excluded.
2251
+ *
2252
+ * @param {Object} v Validated parameter dictionary.
2253
+ * @returns {Array<{sequence:"1"|"2",outer:[number,number],inner:[number,number],gaps:[number,number],loopType:string}>}
2254
+ */
2255
+ function listStructureLoopBoundaryPairs(v) {
2256
+ const boundaries = [];
2257
+ const grouped = listIntramolPairsBySequence(v);
2258
+
2259
+ for (const sequence of ['1', '2']) {
2260
+ const pairs = normaliseBasepairList(grouped[sequence]);
2261
+ listDirectNestedPairChildren(pairs).forEach(({ outer, children }) => {
2262
+ if (children.length !== 1) return;
2263
+ const inner = children[0];
2264
+ const firstGap = inner[0] - outer[0] - 1;
2265
+ const secondGap = outer[1] - inner[1] - 1;
2266
+ if (firstGap === 0 && secondGap === 0) return;
2267
+
2268
+ const touchesCrossing = pairs.some(pair =>
2269
+ !pairEquals(pair, outer) && !pairEquals(pair, inner) &&
2270
+ (pairsCross(pair, outer) || pairsCross(pair, inner))
2271
+ );
2272
+ if (!touchesCrossing) {
2273
+ boundaries.push(createLoopBoundary(outer, inner, { sequence }));
2274
+ }
2275
+ });
2276
+ }
2277
+
2278
+ return boundaries;
2279
+ }
2280
+
2281
+ function loopBoundariesToConstraintSpecs(boundaries, kind) {
2282
+ const constraints = [];
2283
+
2284
+ boundaries.forEach((boundary, index) => {
2285
+ const loopId = boundary.sequence
2286
+ ? kind + ':' + boundary.sequence + ':' + index
2287
+ : kind + ':' + index;
2288
+ const common = {
2289
+ kind,
2290
+ loopId,
2291
+ loopType: boundary.loopType,
2292
+ };
2293
+
2294
+ constraints.push({
2295
+ ...common,
2296
+ source: boundary.outer[0],
2297
+ target: boundary.inner[0],
2298
+ sequence: boundary.sequence || '1',
2299
+ });
2300
+ constraints.push({
2301
+ ...common,
2302
+ source: boundary.inner[1],
2303
+ target: boundary.outer[1],
2304
+ sequence: boundary.sequence || '2',
2305
+ });
2306
+ });
2307
+
2308
+ return constraints;
2309
+ }
2310
+
2311
+ /**
2312
+ * Build the two same-strand spring specifications for every RRI loop.
2313
+ * Rest lengths are intentionally absent here: they are measured from the
2314
+ * live Fornac coordinates when the springs are installed.
2315
+ *
2316
+ * @param {Object} v Validated parameter dictionary.
2317
+ * @returns {Array<{source:number,target:number,sequence:"1"|"2",kind:string,loopId:string,loopType:string}>}
2318
+ */
2319
+ function getLinearRriConstraintSpecs(v) {
2320
+ return loopBoundariesToConstraintSpecs(listRriLoopBoundaryPairs(v), 'rri');
2321
+ }
2322
+
2323
+ /**
2324
+ * Build the two same-strand spring specifications for every intramolecular
2325
+ * bulge/interior loop on either sequence.
2326
+ *
2327
+ * @param {Object} v Validated parameter dictionary.
2328
+ * @returns {Array<{source:number,target:number,sequence:"1"|"2",kind:string,loopId:string,loopType:string}>}
2329
+ */
2330
+ function getLinearStructureConstraintSpecs(v) {
2331
+ return loopBoundariesToConstraintSpecs(
2332
+ listStructureLoopBoundaryPairs(v),
2333
+ 'structure'
2334
+ );
2335
+ }
2336
+
2337
+ /** Resolve a nucleotide node by its 1-based Fornac node number. */
2338
+ function getGraphNucleotideByNumber(graph, nodeNumber) {
2339
+ if (!graph || !Array.isArray(graph.nodes)) return null;
2340
+ return graph.nodes.find(node =>
2341
+ node && node.nodeType === 'nucleotide' && node.num === nodeNumber
2342
+ ) || null;
2343
+ }
2344
+
2345
+ function getNodeDistance(first, second) {
2346
+ const coordinates = [first?.x, first?.y, second?.x, second?.y];
2347
+ if (!coordinates.every(value =>
2348
+ typeof value === 'number' && Number.isFinite(value)
2349
+ )) return null;
2350
+ const [firstX, firstY, secondX, secondY] = coordinates;
2351
+ return Math.hypot(secondX - firstX, secondY - firstY);
2352
+ }
2353
+
2354
+ function pairKey(pair) {
2355
+ return pair[0] + ':' + pair[1];
2356
+ }
2357
+
2358
+ /**
2359
+ * Return the one antiparallel RRI chain that can be represented by two
2360
+ * ordered rails. Crossing RRI pairs are deliberately left to the ordinary
2361
+ * force layout because a single two-rail ordering does not exist for them.
2362
+ */
2363
+ function listRriHelixPairGroups(v) {
2364
+ const pairs = normaliseBasepairList(listIntermolPairs(v));
2365
+ if (pairs.length < 2) return [];
2366
+
2367
+ for (let first = 0; first < pairs.length; first++) {
2368
+ for (let second = first + 1; second < pairs.length; second++) {
2369
+ if (pairsCross(pairs[first], pairs[second])) return [];
2370
+ }
2371
+ }
2372
+ for (let index = 1; index < pairs.length; index++) {
2373
+ if (pairs[index - 1][1] <= pairs[index][1]) return [];
2374
+ }
2375
+ return [{ kind: 'rri', sequence: null, pairs }];
2376
+ }
2377
+
2378
+ /**
2379
+ * Split intramolecular base pairs into maximal single-child stem paths.
2380
+ * Paths stop at multiloops and only paths containing a bulge/interior loop
2381
+ * need an additional linear constraint; uninterrupted stacks are already
2382
+ * linear in Fornac's native layout.
2383
+ */
2384
+ function listStructureHelixPairGroups(v) {
2385
+ const groups = [];
2386
+ const grouped = listIntramolPairsBySequence(v);
2387
+
2388
+ for (const sequence of ['1', '2']) {
2389
+ const pairs = normaliseBasepairList(grouped[sequence]);
2390
+ const crossingPairKeys = new Set();
2391
+ pairs.forEach((pair, index) => {
2392
+ pairs.slice(index + 1).forEach(other => {
2393
+ if (!pairsCross(pair, other)) return;
2394
+ crossingPairKeys.add(pairKey(pair));
2395
+ crossingPairKeys.add(pairKey(other));
2396
+ });
2397
+ });
2398
+
2399
+ const nextPair = new Map();
2400
+ const hasIncoming = new Set();
2401
+ listDirectNestedPairChildren(pairs).forEach(({ outer, children }) => {
2402
+ if (children.length !== 1) return;
2403
+ const inner = children[0];
2404
+ if (crossingPairKeys.has(pairKey(outer)) ||
2405
+ crossingPairKeys.has(pairKey(inner))) return;
2406
+ nextPair.set(pairKey(outer), inner);
2407
+ hasIncoming.add(pairKey(inner));
2408
+ });
2409
+
2410
+ pairs.filter(pair =>
2411
+ !crossingPairKeys.has(pairKey(pair)) &&
2412
+ !hasIncoming.has(pairKey(pair))
2413
+ ).forEach(root => {
2414
+ const path = [];
2415
+ const visited = new Set();
2416
+ let pair = root;
2417
+ while (pair && !visited.has(pairKey(pair))) {
2418
+ path.push(pair);
2419
+ visited.add(pairKey(pair));
2420
+ pair = nextPair.get(pairKey(pair));
2421
+ }
2422
+ const containsLoop = path.slice(1).some((inner, index) => {
2423
+ const outer = path[index];
2424
+ return inner[0] - outer[0] > 1 || outer[1] - inner[1] > 1;
2425
+ });
2426
+ if (path.length >= 2 && containsLoop) {
2427
+ groups.push({ kind: 'structure', sequence, pairs: path });
2428
+ }
2429
+ });
2430
+ }
2431
+ return groups;
2432
+ }
2433
+
2434
+ /**
2435
+ * Measure the two issue-59 loop spans without adding them to Fornac's
2436
+ * render graph. Keeping constraint metadata outside graph.links makes the
2437
+ * constraints unconditionally invisible, including after container.update().
2438
+ */
2439
+ function collectLinearHelixSpanConstraints(container, specs, linkType) {
2440
+ const graph = container && container.graph;
2441
+ if (!graph || !Array.isArray(graph.nodes)) return [];
2442
+
2443
+ const rawMultiplier = Number(container.options?.linkDistanceMultiplier);
2444
+ const multiplier = Number.isFinite(rawMultiplier) && rawMultiplier > 0
2445
+ ? rawMultiplier
2446
+ : 15;
2447
+ const byLoop = new Map();
2448
+ specs.forEach(spec => {
2449
+ if (!byLoop.has(spec.loopId)) byLoop.set(spec.loopId, []);
2450
+ byLoop.get(spec.loopId).push(spec);
2451
+ });
2452
+
2453
+ const constraints = [];
2454
+ byLoop.forEach(loopSpecs => {
2455
+ if (loopSpecs.length !== 2) return;
2456
+ const resolved = loopSpecs.map(spec => ({
2457
+ spec,
2458
+ source: getGraphNucleotideByNumber(graph, spec.source),
2459
+ target: getGraphNucleotideByNumber(graph, spec.target),
2460
+ }));
2461
+ if (resolved.some(link => !link.source || !link.target)) return;
2462
+
2463
+ const distances = resolved.map(link => getNodeDistance(link.source, link.target));
2464
+ if (distances.some(distance => distance === null)) return;
2465
+ const loopSpan = Math.max(...distances);
2466
+ if (!Number.isFinite(loopSpan) || loopSpan <= 0) return;
2467
+
2468
+ resolved.forEach(({ spec, source, target }) => {
2469
+ constraints.push({
2470
+ source,
2471
+ target,
2472
+ value: loopSpan / multiplier,
2473
+ linkType,
2474
+ extraLinkType: 'constraint',
2475
+ varriLinearHelix: true,
2476
+ varriLinearHelixKind: spec.kind,
2477
+ varriLinearHelixLoop: spec.loopId,
2478
+ varriTargetDistance: loopSpan,
2479
+ });
2480
+ });
2481
+ });
2482
+ return constraints;
2483
+ }
2484
+
2485
+ /**
2486
+ * Build a straight two-rail template from the current live geometry.
2487
+ * Loop-to-loop increments use max(d1,d2), exactly as requested in issue
2488
+ * #59; uninterrupted stack increments use Fornac's backbone rest length.
2489
+ * The template itself is centered at the origin so it can subsequently be
2490
+ * fitted to the freely translating and rotating force-layout component.
2491
+ */
2492
+ function createLinearHelixRailTemplate(container, group) {
2493
+ const graph = container && container.graph;
2494
+ if (!graph || !Array.isArray(graph.nodes) || !group?.pairs?.length) return null;
2495
+
2496
+ const rawMultiplier = Number(container.options?.linkDistanceMultiplier);
2497
+ const multiplier = Number.isFinite(rawMultiplier) && rawMultiplier > 0
2498
+ ? rawMultiplier
2499
+ : 15;
2500
+ const pairNodes = group.pairs.map(pair => ({
2501
+ pair,
2502
+ first: getGraphNucleotideByNumber(graph, pair[0]),
2503
+ second: getGraphNucleotideByNumber(graph, pair[1]),
2504
+ }));
2505
+ if (pairNodes.some(column =>
2506
+ !column.first || !column.second ||
2507
+ column.first.fixed || column.second.fixed ||
2508
+ getNodeDistance(column.first, column.second) === null
2509
+ )) return null;
2510
+
2511
+ const offsets = [0];
2512
+ const intervals = [];
2513
+ for (let index = 1; index < pairNodes.length; index++) {
2514
+ const previous = pairNodes[index - 1];
2515
+ const current = pairNodes[index];
2516
+ const firstGap = current.pair[0] - previous.pair[0] - 1;
2517
+ const secondGap = previous.pair[1] - current.pair[1] - 1;
2518
+ const isLoop = firstGap > 0 || secondGap > 0;
2519
+ const measured = Math.max(
2520
+ getNodeDistance(previous.first, current.first),
2521
+ getNodeDistance(previous.second, current.second)
2522
+ );
2523
+ const span = isLoop && Number.isFinite(measured) && measured > 0
2524
+ ? measured
2525
+ : multiplier;
2526
+ intervals.push({ span, isLoop, gaps: [firstGap, secondGap] });
2527
+ offsets.push(offsets[offsets.length - 1] + span);
2528
+ }
2529
+
2530
+ const meanOffset = offsets.reduce((sum, value) => sum + value, 0) /
2531
+ offsets.length;
2532
+ const points = [];
2533
+ pairNodes.forEach((column, index) => {
2534
+ const along = offsets[index] - meanOffset;
2535
+ points.push({ node: column.first, x: along, y: -multiplier / 2 });
2536
+ points.push({ node: column.second, x: along, y: multiplier / 2 });
2537
+ });
2538
+
2539
+ const template = {
2540
+ kind: group.kind,
2541
+ sequence: group.sequence,
2542
+ pairs: group.pairs.map(pair => pair.slice()),
2543
+ points,
2544
+ intervals,
2545
+ railGap: multiplier,
2546
+ };
2547
+ const ordinaryFit = fitLinearHelixRailTemplate(template, 'x', 'y', 1);
2548
+ const reflectedFit = fitLinearHelixRailTemplate(template, 'x', 'y', -1);
2549
+ template.reflection = reflectedFit &&
2550
+ (!ordinaryFit || reflectedFit.error < ordinaryFit.error)
2551
+ ? -1
2552
+ : 1;
2553
+ return template;
2554
+ }
2555
+
2556
+ /**
2557
+ * Fit a translated/rotated copy of one possibly reflected rail template
2558
+ * to a requested pair of live node-coordinate fields.
2559
+ */
2560
+ function fitLinearHelixRailTemplate(template, xField, yField, reflection) {
2561
+ if (!template || !Array.isArray(template.points) || template.points.length < 4) {
2562
+ return null;
2563
+ }
2564
+ const live = template.points.map(point => point.node);
2565
+ if (live.some(node =>
2566
+ !node || ![node[xField], node[yField]].every(Number.isFinite)
2567
+ )) {
2568
+ return null;
2569
+ }
2570
+
2571
+ const center = live.reduce((sum, node) => ({
2572
+ x: sum.x + node[xField],
2573
+ y: sum.y + node[yField],
2574
+ }), { x: 0, y: 0 });
2575
+ center.x /= live.length;
2576
+ center.y /= live.length;
2577
+
2578
+ let dot = 0;
2579
+ let cross = 0;
2580
+ template.points.forEach(point => {
2581
+ const templateY = reflection * point.y;
2582
+ const liveX = point.node[xField] - center.x;
2583
+ const liveY = point.node[yField] - center.y;
2584
+ dot += point.x * liveX + templateY * liveY;
2585
+ cross += point.x * liveY - templateY * liveX;
2586
+ });
2587
+ const angle = Math.atan2(cross, dot);
2588
+ const cosine = Math.cos(angle);
2589
+ const sine = Math.sin(angle);
2590
+ let error = 0;
2591
+ const targets = template.points.map(point => {
2592
+ const templateY = reflection * point.y;
2593
+ const target = {
2594
+ x: center.x + cosine * point.x - sine * templateY,
2595
+ y: center.y + sine * point.x + cosine * templateY,
2596
+ };
2597
+ const deltaX = point.node[xField] - target.x;
2598
+ const deltaY = point.node[yField] - target.y;
2599
+ error += deltaX * deltaX + deltaY * deltaY;
2600
+ return target;
2601
+ });
2602
+ return { angle, center, error, targets };
2603
+ }
2604
+
2605
+ /**
2606
+ * Project one live helix onto the closest translated/rotated copy of its
2607
+ * straight template (2-D orthogonal Procrustes fit). Current and previous
2608
+ * coordinate clouds are projected separately, preserving the rigid body's
2609
+ * translational and rotational velocity instead of zeroing it each tick.
2610
+ */
2611
+ function projectLinearHelixRailTemplate(template) {
2612
+ if (!template || !Array.isArray(template.points)) return false;
2613
+ const live = template.points.map(point => point.node);
2614
+ if (live.some(node => !node || node.fixed)) return false;
2615
+
2616
+ const reflection = template.reflection === -1 ? -1 : 1;
2617
+ const currentFit = fitLinearHelixRailTemplate(template, 'x', 'y', reflection);
2618
+ if (!currentFit) return false;
2619
+ const previousFit = fitLinearHelixRailTemplate(template, 'px', 'py', reflection) ||
2620
+ currentFit;
2621
+
2622
+ template.points.forEach((point, index) => {
2623
+ point.node.x = currentFit.targets[index].x;
2624
+ point.node.y = currentFit.targets[index].y;
2625
+ point.node.px = previousFit.targets[index].x;
2626
+ point.node.py = previousFit.targets[index].y;
2627
+ point.node.varriLinearHelix = true;
2628
+ point.node.varriLinearHelixKind = template.kind;
2629
+ });
2630
+ template.angle = currentFit.angle;
2631
+ template.center = currentFit.center;
2632
+ template.previousAngle = previousFit.angle;
2633
+ template.previousCenter = previousFit.center;
2634
+ return true;
2635
+ }
2636
+
2637
+ function normaliseRotationRadians(radians) {
2638
+ return Math.atan2(Math.sin(radians), Math.cos(radians));
2639
+ }
2640
+
2641
+ function rotateCoordinateCloud(nodes, xField, yField, center, radians) {
2642
+ if (!center || ![center.x, center.y, radians].every(Number.isFinite)) return false;
2643
+ const cosine = Math.cos(radians);
2644
+ const sine = Math.sin(radians);
2645
+ nodes.forEach(node => {
2646
+ const offsetX = node[xField] - center.x;
2647
+ const offsetY = node[yField] - center.y;
2648
+ node[xField] = center.x + cosine * offsetX - sine * offsetY;
2649
+ node[yField] = center.y + sine * offsetX + cosine * offsetY;
2650
+ });
2651
+ return true;
2652
+ }
2653
+
2654
+ function rotateTemplateFitState(template, centerField, angleField, pivot, radians) {
2655
+ const center = template?.[centerField];
2656
+ const angle = template?.[angleField];
2657
+ if (!center || ![center.x, center.y, angle].every(Number.isFinite)) return;
2658
+ const cosine = Math.cos(radians);
2659
+ const sine = Math.sin(radians);
2660
+ const offsetX = center.x - pivot.x;
2661
+ const offsetY = center.y - pivot.y;
2662
+ template[centerField] = {
2663
+ x: pivot.x + cosine * offsetX - sine * offsetY,
2664
+ y: pivot.y + sine * offsetX + cosine * offsetY,
2665
+ };
2666
+ template[angleField] = normaliseRotationRadians(angle + radians);
2667
+ }
2668
+
2669
+ /**
2670
+ * Remove the global angular degree of freedom from an RRI layout by
2671
+ * rotating the complete graph until the paired-column centreline is
2672
+ * horizontal. Current and previous coordinate clouds are rotated
2673
+ * independently so translation is preserved without angular drift.
2674
+ */
2675
+ function orientLinearRriInteractionHorizontally(graph, rriTemplate, templates) {
2676
+ if (!graph || !Array.isArray(graph.nodes) || !rriTemplate) return false;
2677
+ const nodes = graph.nodes.filter(Boolean);
2678
+ if (nodes.length === 0 || nodes.some(node =>
2679
+ node.fixed || ![node.x, node.y, node.px, node.py].every(Number.isFinite)
2680
+ )) return false;
2681
+ if (![rriTemplate.angle, rriTemplate.previousAngle].every(Number.isFinite) ||
2682
+ !rriTemplate.center || !rriTemplate.previousCenter) return false;
2683
+
2684
+ if (rriTemplate.horizontalDirection !== 1 &&
2685
+ rriTemplate.horizontalDirection !== -1) {
2686
+ rriTemplate.horizontalDirection = Math.cos(rriTemplate.angle) >= 0 ? 1 : -1;
2687
+ }
2688
+ const targetAngle = rriTemplate.horizontalDirection === 1 ? 0 : Math.PI;
2689
+ const currentRotation = normaliseRotationRadians(
2690
+ targetAngle - rriTemplate.angle
2691
+ );
2692
+ const previousRotation = normaliseRotationRadians(
2693
+ targetAngle - rriTemplate.previousAngle
2694
+ );
2695
+ const currentPivot = { ...rriTemplate.center };
2696
+ const previousPivot = { ...rriTemplate.previousCenter };
2697
+
2698
+ rotateCoordinateCloud(nodes, 'x', 'y', currentPivot, currentRotation);
2699
+ rotateCoordinateCloud(nodes, 'px', 'py', previousPivot, previousRotation);
2700
+ templates.forEach(template => {
2701
+ rotateTemplateFitState(
2702
+ template,
2703
+ 'center',
2704
+ 'angle',
2705
+ currentPivot,
2706
+ currentRotation
2707
+ );
2708
+ rotateTemplateFitState(
2709
+ template,
2710
+ 'previousCenter',
2711
+ 'previousAngle',
2712
+ previousPivot,
2713
+ previousRotation
2714
+ );
2715
+ });
2716
+ rriTemplate.lastHorizontalRotation = currentRotation;
2717
+ return true;
2718
+ }
2719
+
2720
+ function listVisibleIndexLabelPositions(v) {
2721
+ const positions = new Set(
2722
+ Object.entries(getIndexLabelValues(v))
2723
+ .filter(([, value]) => value !== 0)
2724
+ .map(([position]) => Number(position))
2725
+ );
2726
+ (Array.isArray(v.pointMutations) ? v.pointMutations : []).forEach(mutation => {
2727
+ const nodeId = Number(mutation.nodeId);
2728
+ if (Number.isInteger(nodeId)) positions.add(nodeId);
2729
+ });
2730
+ return positions;
2731
+ }
2732
+
2733
+ function resolveGraphLinkNode(graph, endpoint) {
2734
+ if (endpoint && typeof endpoint === 'object') return endpoint;
2735
+ const index = Number(endpoint);
2736
+ return Number.isInteger(index) ? graph.nodes[index] || null : null;
2737
+ }
2738
+
2739
+ /**
2740
+ * Match retained number labels to paired rail nodes and their mates.
2741
+ * Fornac's label link fixes distance but not which side of the rail wins,
2742
+ * so these records provide a transient, direction-only settling hint.
2743
+ */
2744
+ function collectLinearHelixIndexLabelBiases(container, v, templates) {
2745
+ const graph = container && container.graph;
2746
+ if (!graph || !Array.isArray(graph.nodes) || !Array.isArray(graph.links)) return [];
2747
+
2748
+ const visiblePositions = listVisibleIndexLabelPositions(v);
2749
+ const partnerByNode = new Map();
2750
+ templates.forEach(template => {
2751
+ for (let index = 0; index + 1 < template.points.length; index += 2) {
2752
+ const first = template.points[index].node;
2753
+ const second = template.points[index + 1].node;
2754
+ partnerByNode.set(first, second);
2755
+ partnerByNode.set(second, first);
2756
+ }
2757
+ });
2758
+
2759
+ const rawMultiplier = Number(container.options?.linkDistanceMultiplier);
2760
+ const multiplier = Number.isFinite(rawMultiplier) && rawMultiplier > 0
2761
+ ? rawMultiplier
2762
+ : 15;
2763
+ const seenLabels = new Set();
2764
+ const biases = [];
2765
+
2766
+ graph.links.forEach(link => {
2767
+ if (link?.linkType !== 'label_link') return;
2768
+ const source = resolveGraphLinkNode(graph, link.source);
2769
+ const target = resolveGraphLinkNode(graph, link.target);
2770
+ const label = source?.nodeType === 'label'
2771
+ ? source
2772
+ : target?.nodeType === 'label' ? target : null;
2773
+ const anchor = source?.nodeType === 'nucleotide'
2774
+ ? source
2775
+ : target?.nodeType === 'nucleotide' ? target : null;
2776
+ const partner = partnerByNode.get(anchor);
2777
+ if (!label || !anchor || !partner || seenLabels.has(label) ||
2778
+ !visiblePositions.has(Number(anchor.num))) return;
2779
+
2780
+ const rawValue = Number(link.value);
2781
+ const linkDistance = multiplier * (
2782
+ Number.isFinite(rawValue) && rawValue > 0 ? rawValue : 1
2783
+ );
2784
+ seenLabels.add(label);
2785
+ biases.push({ label, anchor, partner, linkDistance });
2786
+ });
2787
+ return biases;
2788
+ }
2789
+
2790
+ /**
2791
+ * Gently move wrong-side number labels across the rail centreline.
2792
+ * Once a label reaches its exterior half-plane this becomes a no-op and
2793
+ * Fornac's native label link completes the ordinary spacing.
2794
+ */
2795
+ function nudgeLinearHelixIndexLabels(biases) {
2796
+ let moved = 0;
2797
+ biases.forEach(bias => {
2798
+ const { label, anchor, partner, linkDistance } = bias;
2799
+ if (!label || !anchor || !partner ||
2800
+ label.fixed || anchor.fixed || partner.fixed) return;
2801
+ if (![label.x, label.y, label.px, label.py,
2802
+ anchor.x, anchor.y, partner.x, partner.y,
2803
+ linkDistance].every(Number.isFinite)) return;
2804
+
2805
+ const outwardX = anchor.x - partner.x;
2806
+ const outwardY = anchor.y - partner.y;
2807
+ const outwardLength = Math.hypot(outwardX, outwardY);
2808
+ if (!(outwardLength > 0)) return;
2809
+ const unitX = outwardX / outwardLength;
2810
+ const unitY = outwardY / outwardLength;
2811
+ const side = (label.x - anchor.x) * unitX +
2812
+ (label.y - anchor.y) * unitY;
2813
+ const target = LINEAR_HELIX_LABEL_BIAS_TARGET * linkDistance;
2814
+ if (!(side < target)) return;
2815
+
2816
+ // Use the same bounded correction on every lifecycle event. The
2817
+ // end handler must never turn this settling hint into a late snap.
2818
+ const distance = Math.min(
2819
+ (target - side) * LINEAR_HELIX_LABEL_BIAS_GAIN,
2820
+ LINEAR_HELIX_LABEL_BIAS_MAX_STEP * linkDistance
2821
+ );
2822
+ const deltaX = distance * unitX;
2823
+ const deltaY = distance * unitY;
2824
+ label.x += deltaX;
2825
+ label.y += deltaY;
2826
+ label.px += deltaX;
2827
+ label.py += deltaY;
2828
+ moved += 1;
2829
+ });
2830
+ return moved;
2831
+ }
2832
+
2833
+ /** Cache projected nodes, gently biased labels, and their visible links. */
2834
+ function createLinearHelixDomCache(
2835
+ graph,
2836
+ templates,
2837
+ labelBiases = [],
2838
+ syncWholeGraph = false
2839
+ ) {
2840
+ if (typeof document === 'undefined') return { nodes: [], links: [] };
2841
+ const projectedNodes = syncWholeGraph
2842
+ ? new Set(graph.nodes)
2843
+ : new Set([
2844
+ ...templates.flatMap(template => template.points.map(point => point.node)),
2845
+ ...labelBiases.map(bias => bias.label),
2846
+ ]);
2847
+ const graphLinks = Array.isArray(graph.links) ? graph.links : [];
2848
+ const incidentLinks = syncWholeGraph
2849
+ ? new Set(graphLinks)
2850
+ : new Set(graphLinks.filter(link =>
2851
+ projectedNodes.has(link?.source) || projectedNodes.has(link?.target)
2852
+ ));
2853
+ return {
2854
+ nodes: Array.from(document.querySelectorAll('g.gnode'))
2855
+ .filter(element => projectedNodes.has(element.__data__)),
2856
+ links: Array.from(document.querySelectorAll('line.link'))
2857
+ .filter(element => incidentLinks.has(element.__data__)),
2858
+ };
2859
+ }
2860
+
2861
+ function syncFornacDirectionArrow(element, node) {
2862
+ const arrow = element.querySelector?.('path.fornac-directionArrow');
2863
+ const previous = node?.prevNode;
2864
+ if (!arrow || !previous || !node.linked ||
2865
+ ![node.x, node.y, previous.x, previous.y, node.radius].every(Number.isFinite)) {
2866
+ return;
2867
+ }
2868
+ let directionX = previous.x - node.x;
2869
+ let directionY = previous.y - node.y;
2870
+ const length = Math.hypot(directionX, directionY);
2871
+ if (!(length > 0)) return;
2872
+ directionX /= length;
2873
+ directionY /= length;
2874
+ const normalX = -directionY;
2875
+ const normalY = directionX;
2876
+ const tipX = (node.radius + 0.4) * directionX;
2877
+ const tipY = (node.radius + 0.4) * directionY;
2878
+ const size = 6;
2879
+ const width = 0.7;
2880
+ arrow.setAttribute('d',
2881
+ `M${tipX + size * (directionX / 2 + normalX * width / 2)},` +
2882
+ `${tipY + size * (directionY / 2 + normalY * width / 2)}` +
2883
+ `L${tipX},${tipY}` +
2884
+ `L${tipX + size * (directionX / 2 - normalX * width / 2)},` +
2885
+ `${tipY + size * (directionY / 2 - normalY * width / 2)}`
2886
+ );
2887
+ }
2888
+
2889
+ /** Keep Fornac's already-created SVG in sync with post-tick projection. */
2890
+ function syncLinearHelixDom(cache) {
2891
+ cache.nodes.forEach(element => {
2892
+ const node = element.__data__;
2893
+ if (!node || ![node.x, node.y].every(Number.isFinite)) return;
2894
+ element.setAttribute('transform', `translate(${node.x},${node.y})`);
2895
+ syncFornacDirectionArrow(element, node);
2896
+ });
2897
+ cache.links.forEach(element => {
2898
+ const link = element.__data__;
2899
+ if (!link?.source || !link?.target) return;
2900
+ element.setAttribute('x1', String(link.source.x));
2901
+ element.setAttribute('y1', String(link.source.y));
2902
+ element.setAttribute('x2', String(link.target.x));
2903
+ element.setAttribute('y2', String(link.target.y));
2904
+ });
2905
+ }
2906
+
2907
+ function clearLinearHelixConstraintState(container) {
2908
+ const hadConstraintState = !!container && (
2909
+ Object.prototype.hasOwnProperty.call(container, 'varriLinearHelixConstraints') ||
2910
+ Object.prototype.hasOwnProperty.call(container, 'varriLinearHelixTemplates') ||
2911
+ Object.prototype.hasOwnProperty.call(container, 'varriLinearHelixLabelBiases')
2912
+ );
2913
+ if (hadConstraintState && container.force && typeof container.force.on === 'function') {
2914
+ container.force.on('tick.varriLinearHelix', null);
2915
+ container.force.on('end.varriLinearHelix', null);
2916
+ }
2917
+ delete container?.varriLinearHelixConstraints;
2918
+ delete container?.varriLinearHelixTemplates;
2919
+ delete container?.varriLinearHelixLabelBiases;
2920
+ }
2921
+
2922
+ /**
2923
+ * Apply rigid, invisible two-rail constraints for the requested RRI and/or
2924
+ * intramolecular helices, then restart the live D3 force once.
2925
+ *
2926
+ * @param {Object} container Live Fornac container.
2927
+ * @param {Object} v Validated parameter dictionary.
2928
+ * @param {{rri?:boolean,structure?:boolean}} [options]
2929
+ * @returns {number} Number of measured same-strand loop-span constraints.
2930
+ */
2931
+ function applyLinearHelixSprings(container, v, options = {}) {
2932
+ clearLinearHelixConstraintState(container);
2933
+ const graph = container && container.graph;
2934
+ if (!graph || !Array.isArray(graph.nodes) || !Array.isArray(graph.links)) return 0;
2935
+
2936
+ const constraints = [];
2937
+ const groups = [];
2938
+ if (options.rri && v.molecules === '2') {
2939
+ constraints.push(...collectLinearHelixSpanConstraints(
2940
+ container,
2941
+ getLinearRriConstraintSpecs(v),
2942
+ LINEAR_RRI_LINK_TYPE
2943
+ ));
2944
+ groups.push(...listRriHelixPairGroups(v));
2945
+ }
2946
+ if (options.structure) {
2947
+ constraints.push(...collectLinearHelixSpanConstraints(
2948
+ container,
2949
+ getLinearStructureConstraintSpecs(v),
2950
+ LINEAR_STRUCTURE_LINK_TYPE
2951
+ ));
2952
+ groups.push(...listStructureHelixPairGroups(v));
2953
+ }
2954
+
2955
+ const templates = groups
2956
+ .map(group => createLinearHelixRailTemplate(container, group))
2957
+ .filter(Boolean);
2958
+ if (templates.length === 0) return 0;
2959
+
2960
+ const constrainedNodes = new Set(
2961
+ templates.flatMap(template => template.points.map(point => point.node))
2962
+ );
2963
+ const activeConstraints = constraints.filter(constraint =>
2964
+ constrainedNodes.has(constraint.source) &&
2965
+ constrainedNodes.has(constraint.target)
2966
+ );
2967
+ container.varriLinearHelixConstraints = activeConstraints;
2968
+ container.varriLinearHelixTemplates = templates;
2969
+ const labelBiases = collectLinearHelixIndexLabelBiases(container, v, templates);
2970
+ container.varriLinearHelixLabelBiases = labelBiases;
2971
+ const rriTemplate = options.rri
2972
+ ? templates.find(template => template.kind === 'rri') || null
2973
+ : null;
2974
+ const domCache = createLinearHelixDomCache(
2975
+ graph,
2976
+ templates,
2977
+ labelBiases,
2978
+ !!rriTemplate
2979
+ );
2980
+ const enforceAndSync = () => {
2981
+ templates.forEach(projectLinearHelixRailTemplate);
2982
+ if (rriTemplate) {
2983
+ orientLinearRriInteractionHorizontally(graph, rriTemplate, templates);
2984
+ }
2985
+ nudgeLinearHelixIndexLabels(labelBiases);
2986
+ syncLinearHelixDom(domCache);
2987
+ };
2988
+ let hasRefittedAtRest = false;
2989
+ const enforceSyncAndRefit = () => {
2990
+ enforceAndSync();
2991
+ if (!hasRefittedAtRest && typeof container.centerView === 'function') {
2992
+ hasRefittedAtRest = true;
2993
+ container.centerView();
2994
+ }
2995
+ };
2996
+ enforceAndSync();
2997
+
2998
+ if (container.force) {
2999
+ if (typeof container.force.on === 'function') {
3000
+ container.force.on('tick.varriLinearHelix', () => enforceAndSync());
3001
+ // The final projection can extend beyond the bounds measured by
3002
+ // applyModifications while the force is still moving. Refit once
3003
+ // at rest so asymmetric bulges are not clipped at the viewport.
3004
+ container.force.on('end.varriLinearHelix', enforceSyncAndRefit);
3005
+ }
3006
+ if (typeof container.force.start === 'function') {
3007
+ container.force.start();
3008
+ }
3009
+ }
3010
+ return activeConstraints.length;
3011
+ }
3012
+
3013
+ /**
3014
+ * Add background highlighting for intermolecular basepair stacks.
3015
+ *
3016
+ * @param {Object} v Validated parameter dictionary.
3017
+ */
3018
+ function backgroundhighlightBasepairs(v) {
3019
+ const intermolPairs = listIntermolPairs(v);
3020
+ if (intermolPairs.length === 0) {
3021
+ clearGeneratedRegionHighlights();
3022
+ return;
3023
+ }
3024
+
3025
+ let stack = [intermolPairs.shift()];
3026
+ const highlightAreas = [];
3027
+
3028
+ for (const [open, close] of intermolPairs) {
3029
+ const [stackOpen, stackClose] = stack[stack.length - 1];
3030
+ if (open - 1 === stackOpen && close + 1 === stackClose) {
3031
+ stack.push([open, close]);
3032
+ continue;
3033
+ }
3034
+ const area = stack.flatMap(([a, b]) => [a, b]).sort((a, b) => a - b);
3035
+ highlightAreas.push(area);
3036
+ stack = [[open, close]];
3037
+ }
3038
+ const area = stack.flatMap(([a, b]) => [a, b]).sort((a, b) => a - b);
3039
+ highlightAreas.push(area);
3040
+
3041
+ clearGeneratedRegionHighlights();
3042
+ highlightAreas.forEach(region => {
3043
+ const seq1Range = getBackgroundRangeForPositions(v, region, '1');
3044
+ const seq2Range = getBackgroundRangeForPositions(v, region, '2');
3045
+ if (seq1Range && seq2Range) {
3046
+ registerGeneratedRegionHighlight(v, {
3047
+ sequence1Range: seq1Range,
3048
+ sequence2Range: seq2Range,
3049
+ color: COLORS.backgroundHighlight,
3050
+ });
3051
+ }
3052
+ });
3053
+ }
3054
+
3055
+ /**
3056
+ * Add background highlighting for the entire intermolecular region.
3057
+ *
3058
+ * @param {Object} v Validated parameter dictionary.
3059
+ */
3060
+ function backgroundhighlightRegion(v) {
3061
+ const ranges = computeBackgroundRegionRanges(v);
3062
+ if (!ranges) {
3063
+ return;
3064
+ }
3065
+
3066
+ registerGeneratedRegionHighlight(v, {
3067
+ sequence1Range: ranges.sequence1Range,
3068
+ sequence2Range: ranges.sequence2Range,
3069
+ color: COLORS.backgroundHighlight,
3070
+ });
3071
+ }
3072
+
3073
+ /**
3074
+ * Add an accessibility-overlay circle on top of an existing node.
3075
+ *
3076
+ * @param {number} id Node ID.
3077
+ * @param {string} style CSS style for the overlay.
3078
+ * @param {string} tooltip Extra tooltip text to append.
3079
+ */
3080
+ function addAccessibilityOverlay(id, style, tooltip) {
3081
+ document.querySelectorAll(`circle[node_num="${id}"]`).forEach(node => {
3082
+ const overlay = node.cloneNode(true);
3083
+ overlay.setAttribute('node_num', `o${id}`);
3084
+ overlay.setAttribute('style', style);
3085
+ if (overlay.firstChild) {
3086
+ overlay.firstChild.innerHTML += tooltip;
3087
+ }
3088
+ node.after(overlay);
3089
+ });
3090
+ }
3091
+
3092
+ /**
3093
+ * Map a probability value to an opacity (higher probability → lower opacity).
3094
+ *
3095
+ * @param {number} prb Value in [0, 1].
3096
+ * @returns {number}
3097
+ */
3098
+ function mapProbabilityToOpacity(prb, representsOne) {
3099
+ return representsOne ? prb : (1 - prb);
3100
+ }
3101
+
3102
+ /**
3103
+ * Visualise nucleotide accessibility data as overlaid coloured circles.
3104
+ *
3105
+ * @param {Object.<number, number>} accessData Map of node ID → accessibility probability.
3106
+ * @param {number} lenSeq Length of sequence 1 (used to distinguish colour by molecule).
3107
+ * @param {{sequence1?: string, sequence2?: string}|null} accessColors Optional colors for sequence 1/2 overlays.
3108
+ * @param {{sequence1RepresentsOne?: boolean, sequence2RepresentsOne?: boolean}|null} accessColorMode
3109
+ * Optional per-sequence mapping flags. If true, probability 1 maps to full color.
3110
+ */
3111
+ function visualiseAccessibility(accessData, lenSeq, accessColors = null, accessColorMode = null) {
3112
+ const seq1Color = accessColors?.sequence1 || COLORS.seq1profileColor;
3113
+ const seq2Color = accessColors?.sequence2 || COLORS.seq2profileColor;
3114
+ const seq1RepresentsOne = !!accessColorMode?.sequence1RepresentsOne;
3115
+ const seq2RepresentsOne = !!accessColorMode?.sequence2RepresentsOne;
3116
+ for (const [indexStr, prb] of Object.entries(accessData)) {
3117
+ const index = parseInt(indexStr, 10);
3118
+ const isSeq1 = index <= lenSeq;
3119
+ const color = isSeq1 ? seq1Color : seq2Color;
3120
+ const representsOne = isSeq1 ? seq1RepresentsOne : seq2RepresentsOne;
3121
+ const style = `fill: ${color};opacity: ${mapProbabilityToOpacity(prb, representsOne)}; stroke-width: 0;`;
3122
+ const prbTooltip = '\n' + prb.toExponential(2);
3123
+ addAccessibilityOverlay(index, style, prbTooltip);
3124
+ }
3125
+ }
3126
+
3127
+ /**
3128
+ * Resolve a force-graph link endpoint to a node object when possible.
3129
+ *
3130
+ * @param {Object} graph
3131
+ * @param {Object|number|string|null|undefined} endpoint
3132
+ * @returns {Object|null}
3133
+ */
3134
+ function resolveGraphNodeFromEndpoint(graph, endpoint) {
3135
+ if (endpoint && typeof endpoint === 'object') return endpoint;
3136
+
3137
+ const idx = parseInt(String(endpoint), 10);
3138
+ if (!Number.isFinite(idx)) return null;
3139
+
3140
+ if (Array.isArray(graph?.nodes) && graph.nodes[idx]) return graph.nodes[idx];
3141
+ if (Array.isArray(graph?.nodes)) {
3142
+ const byNumber = graph.nodes.find(node => node && node.num === idx);
3143
+ if (byNumber) return byNumber;
3144
+ }
3145
+
3146
+ return null;
3147
+ }
3148
+
3149
+ /**
3150
+ * Identify the force-graph nodes that implement Fornac's "free-form"
3151
+ * loop circularisation: the two synthetic closure nodes plus every hub
3152
+ * whose loop is exterior-flavoured, along with the full member set of
3153
+ * each such hub.
3154
+ *
3155
+ * Fornac's `reinforceLoops()` gives every loop of the structure (stems
3156
+ * excluded) its own fake "middle" hub node via `addFakeNode()`, which
3157
+ * pulls that loop's member nucleotides toward one shared point (keeping
3158
+ * the loop visually rounded). For the true top-level external loop
3159
+ * specifically — and only when Fornac's `circularizeExternal` option is
3160
+ * enabled, which is the default — two extra synthetic "closure" middle
3161
+ * nodes (`num: -2` and `num: -3`), positioned at the RNA's very first
3162
+ * and very last nucleotide, are additionally appended to that loop's
3163
+ * member list before its hub is created. This is exactly the
3164
+ * constraint that pulls the two sequence ends together.
3165
+ *
3166
+ * That hub cannot be found reliably via link adjacency: `addFakeNode()`
3167
+ * skips creating any link (hub spoke *and* the two "chord" links to
3168
+ * nearby members — see below) for member-list entries whose index
3169
+ * exceeds the sequence length, which is exactly what the closure nodes'
3170
+ * synthetic indices are. So the closure nodes are never linked to the
3171
+ * hub directly, only incidentally chord-linked to a couple of nearby
3172
+ * real nucleotide members. Instead, each hub's own `nucs` array — a
3173
+ * snapshot of the 1-based `graph.nodes` array indices of every member
3174
+ * of that loop, recorded when the hub was created — is used: for the
3175
+ * true external loop only, it includes the closure nodes' own array
3176
+ * indices, which identifies that hub precisely.
3177
+ *
3178
+ * vaRRI additionally inserts extra unpaired "gap" characters between two
3179
+ * molecules to work around a Fornac rendering bug. Fornac's own
3180
+ * `breakNodesToFakeNodes()` marks every member of *any* loop that
3181
+ * touches that gap as `elemType: "e"` (the same label used for the true
3182
+ * exterior loop), regardless of that loop's real type — this is exactly
3183
+ * the "trailing ends around the & spacer" that should also be freed.
3184
+ * Any hub whose resolved members include an `elemType: "e"` nucleotide
3185
+ * is therefore treated the same way as the true external-loop hub.
3186
+ *
3187
+ * Each qualifying hub's `nucs` array also lists every other member of
3188
+ * its loop (real nucleotides, and closure nodes for the true external
3189
+ * hub). Those member sets are returned too, because `addFakeNode()`
3190
+ * additionally links members directly to each other with two kinds of
3191
+ * "chord" links (skipping the hub entirely) to keep the loop's ring
3192
+ * shape from collapsing — e.g. a member is linked straight to the
3193
+ * member roughly opposite it in the loop. Those direct member-to-member
3194
+ * links must also be removed to fully free the loop's nucleotides;
3195
+ * removing only the hub and closure nodes leaves them in place, which
3196
+ * still visibly pulls opposite sides of the loop together. Loops that
3197
+ * don't qualify (i.e. every other stem/hairpin/interior/multi loop) are
3198
+ * left completely untouched.
3199
+ *
3200
+ * @param {Object} graph
3201
+ * @returns {{closureUids: Set<string>, hubUids: Set<string>, memberUids: Set<string>}|null}
3202
+ */
3203
+ function getFreeableLoopScaffoldUids(graph) {
3204
+ if (!graph || !Array.isArray(graph.nodes)) return null;
3205
+
3206
+ const closureNodes = graph.nodes.filter(node =>
3207
+ node && node.nodeType === 'middle' && (node.num === -2 || node.num === -3)
3208
+ );
3209
+ const closureUids = new Set(closureNodes.map(node => node.uid).filter(Boolean));
3210
+ const closureIndices = new Set(closureNodes.map(node => graph.nodes.indexOf(node) + 1));
3211
+
3212
+ const hubs = graph.nodes.filter(node =>
3213
+ node && node.nodeType === 'middle' && node.num === -1 && Array.isArray(node.nucs)
3214
+ );
3215
+
3216
+ const hubUids = new Set();
3217
+ const memberUids = new Set(closureUids);
3218
+
3219
+ hubs.forEach(hub => {
3220
+ const members = hub.nucs.map(idx => graph.nodes[idx - 1]).filter(Boolean);
3221
+ const touchesClosure = hub.nucs.some(idx => closureIndices.has(idx));
3222
+ const touchesExternalElemType = members.some(member => member.elemType === 'e');
3223
+
3224
+ if (!touchesClosure && !touchesExternalElemType) return;
3225
+
3226
+ hubUids.add(hub.uid);
3227
+ members.forEach(member => {
3228
+ if (member.uid) memberUids.add(member.uid);
3229
+ });
3230
+ });
3231
+
3232
+ if (closureUids.size === 0 && hubUids.size === 0) return null;
3233
+
3234
+ return { closureUids, hubUids, memberUids };
3235
+ }
3236
+
3237
+ /**
3238
+ * Remove Fornac's exterior-flavoured loop circularisation scaffolds —
3239
+ * the closure nodes and every hub whose loop is exterior-flavoured
3240
+ * (the true top-level external loop, plus any loop touching vaRRI's
3241
+ * inter-molecule gap) — from the force graph and rerun the layout.
3242
+ * Every other loop's own hub and circular constraint is left untouched.
3243
+ *
3244
+ * @param {Object} container
3245
+ * @param {Object} v
3246
+ * @returns {boolean}
3247
+ */
3248
+ function relaxForceGraphScaffold(container, v) {
3249
+ const graph = container && container.graph;
3250
+ const scaffold = getFreeableLoopScaffoldUids(graph);
3251
+ if (!scaffold) return false;
3252
+
3253
+ const removableNodeUids = new Set(scaffold.closureUids);
3254
+ scaffold.hubUids.forEach(uid => removableNodeUids.add(uid));
3255
+
3256
+ graph.links = graph.links.filter(link => {
3257
+ const linkType = String(link && link.linkType);
3258
+ if (linkType !== 'fake' && linkType !== 'fake_fake') return true;
3259
+
3260
+ const sourceNode = resolveGraphNodeFromEndpoint(graph, link.source);
3261
+ const targetNode = resolveGraphNodeFromEndpoint(graph, link.target);
3262
+ const sourceUid = sourceNode && sourceNode.uid;
3263
+ const targetUid = targetNode && targetNode.uid;
3264
+
3265
+ // Drop anything touching a freed hub or the closure nodes themselves.
3266
+ if ((sourceUid && removableNodeUids.has(sourceUid)) || (targetUid && removableNodeUids.has(targetUid))) {
3267
+ return false;
3268
+ }
3269
+
3270
+ // Drop direct member-to-member "chord" links that bypass the hub
3271
+ // entirely but still connect two nucleotides of the external loop.
3272
+ if (sourceUid && targetUid && scaffold.memberUids.has(sourceUid) && scaffold.memberUids.has(targetUid)) {
3273
+ return false;
3274
+ }
3275
+
3276
+ return true;
3277
+ });
3278
+
3279
+ graph.nodes = graph.nodes.filter(node => !(node && node.uid && removableNodeUids.has(node.uid)));
3280
+
3281
+ if (typeof container.update === 'function') {
3282
+ container.update();
3283
+ }
3284
+
3285
+ if (container.force && typeof container.force.resume === 'function') {
3286
+ container.force.resume();
3287
+ } else if (container.force && typeof container.force.start === 'function') {
3288
+ container.force.start();
3289
+ }
3290
+
3291
+ return true;
3292
+ }
3293
+
3294
+ /**
3295
+ * Override Fornac's "pseudoknot" link force strength on a live container.
3296
+ *
3297
+ * Fornac's `FornaContainer` sets `container.linkStrengths.pseudoknot = 0`
3298
+ * by default, meaning pseudoknot basepair links exert no pull in the
3299
+ * force simulation. `container.linkStrengths` is read by the link-force
3300
+ * accessor function on every `force.start()` call (which rebuilds the
3301
+ * internal per-link strength array), but *not* by `force.resume()`
3302
+ * (which only restarts ticking without rebuilding that array). So the
3303
+ * new strength must be set before calling `force.start()` specifically.
3304
+ *
3305
+ * @param {Object} container
3306
+ * @param {boolean} enabled When true, sets pseudoknot strength to 10.
3307
+ */
3308
+ function applyPseudoknotLinkStrength(container, enabled) {
3309
+ if (!container || !container.linkStrengths) return;
3310
+
3311
+ container.linkStrengths.pseudoknot = enabled ? 10 : 0;
3312
+
3313
+ if (container.force && typeof container.force.start === 'function') {
3314
+ container.force.start();
3315
+ }
3316
+ }
3317
+
3318
+
3319
+ // -----------------------------------------------------------------------
3320
+ // Main render function
3321
+ // -----------------------------------------------------------------------
3322
+
3323
+ /**
3324
+ * Stop the active Fornac force and cancel delayed/animation-frame work.
3325
+ * Pending render promises resolve as cancelled.
3326
+ */
3327
+ function cancelActiveRender() {
3328
+ if (_animFrameId !== null) {
3329
+ cancelAnimationFrame(_animFrameId);
3330
+ _animFrameId = null;
3331
+ }
3332
+
3333
+ if (_renderTimeoutId !== null) {
3334
+ clearTimeout(_renderTimeoutId);
3335
+ _renderTimeoutId = null;
3336
+ if (_pendingRenderResolve) {
3337
+ const resolvePendingRender = _pendingRenderResolve;
3338
+ queueMicrotask(() => resolvePendingRender({ cancelled: true }));
3339
+ _pendingRenderResolve = null;
3340
+ }
3341
+ }
3342
+
3343
+ // D3 v3 dispatches `end` synchronously from force.stop(). Remove the
3344
+ // helix lifecycle listeners first so a cancelled render cannot refit
3345
+ // a detached/cleared SVG container.
3346
+ if (_activeContainer) {
3347
+ clearLinearHelixConstraintState(_activeContainer);
3348
+ }
3349
+ if (_activeContainer?.force && typeof _activeContainer.force.stop === 'function') {
3350
+ _activeContainer.force.stop();
3351
+ }
3352
+ _activeContainer = null;
3353
+ }
3354
+
3355
+ /**
3356
+ * Build the Fornac RNA visualisation inside `containerId` and apply all
3357
+ * vaRRI modifications.
3358
+ *
3359
+ * This is the main entry point. Call `validate()` first to produce `v`.
3360
+ *
3361
+ * @param {string} containerId CSS selector or element ID of the Fornac container.
3362
+ * @param {Object} v Validated parameter dictionary (from `validate()`).
3363
+ * @param {Object} [options]
3364
+ * @param {boolean} [options.forceLayout=false] Enable Fornac force-layout animation.
3365
+ * @param {boolean} [options.forceLayoutLinearRRI=false] Enforce a rigid two-rail RRI layout and orient the complete interaction horizontally.
3366
+ * @param {boolean} [options.forceLayoutLinearStructure=false] Enforce the same two-rail geometry within intramolecular helices.
3367
+ * @param {boolean} [options.freeTrailingEnds=false] Remove Fornac's external-loop circularisation constraint (the "closure" scaffold linking the sequence ends) from the force graph, leaving all other loop constraints intact.
3368
+ * @param {boolean} [options.pullPseudoknotBasepairs=false] Set Fornac's pseudoknot link force strength to 10 (default 0), pulling pseudoknot basepairs together in the force layout.
3369
+ * @param {Object.<number,number>|null} [options.accessData=null] Accessibility data map.
3370
+ * @param {{sequence1?: string, sequence2?: string}|null} [options.accessColors=null] Optional accessibility-overlay colors.
3371
+ * @param {{sequence1RepresentsOne?: boolean, sequence2RepresentsOne?: boolean}|null} [options.accessColorMode=null]
3372
+ * Optional per-sequence mapping flags; true means probability 1 maps to full color.
3373
+ */
3374
+ function render(containerId, v, options = {}) {
3375
+ cancelActiveRender();
3376
+
3377
+ const {
3378
+ forceLayout = false,
3379
+ forceLayoutLinearRRI = false,
3380
+ forceLayoutLinearStructure = false,
3381
+ freeTrailingEnds = false,
3382
+ pullPseudoknotBasepairs = false,
3383
+ accessData = null,
3384
+ accessColors = null,
3385
+ accessColorMode = null,
3386
+ } = options;
3387
+
3388
+ // Build molecules via Fornac
3389
+ const container = new fornac.FornaContainer(
3390
+ `#${containerId}`,
3391
+ {
3392
+ animation: forceLayout,
3393
+ labelInterval: 1
3394
+ }
3395
+ );
3396
+ _activeContainer = container;
3397
+ container.addRNA(v.structure, { structure: v.structure, sequence: v.sequence });
3398
+
3399
+ if (forceLayout && freeTrailingEnds) {
3400
+ relaxForceGraphScaffold(container, v);
3401
+ }
3402
+
3403
+ if (forceLayout && pullPseudoknotBasepairs) {
3404
+ applyPseudoknotLinkStrength(container, true);
3405
+ }
3406
+
3407
+ if (forceLayout && (forceLayoutLinearRRI || forceLayoutLinearStructure)) {
3408
+ applyLinearHelixSprings(container, v, {
3409
+ rri: forceLayoutLinearRRI,
3410
+ structure: forceLayoutLinearStructure,
3411
+ });
3412
+ }
3413
+
3414
+ function applyModifications() {
3415
+ // Set IDs for DOM querying
3416
+ setLinksId();
3417
+ setLabelsId();
3418
+
3419
+ // Remove gap nodes
3420
+ removeDummyNodes(v.sequence);
3421
+
3422
+ // Remove duplicate intermolecular links
3423
+ if (v.molecules === '2') {
3424
+ removeSecondLink();
3425
+ }
3426
+
3427
+ // Strand coloring
3428
+ if (v.coloring === 'strand') {
3429
+ changeBackgroundColor(v);
3430
+ }
3431
+
3432
+ // Tooltips and labels
3433
+ updateNodeToolTips(v);
3434
+ updateLinkTooltips(v);
3435
+ setIndexLabels(v);
3436
+
3437
+ // Highlighting (only for 2-molecule input)
3438
+ clearGeneratedRegionHighlights();
3439
+ if (v.molecules === '2') {
3440
+ if (v.highlighting === 'region') highlightRegion(v);
3441
+ if (v.highlighting === 'basepairs') highlightBasepairs(v);
3442
+ if (v.backgroundhighlighting === 'region') backgroundhighlightRegion(v);
3443
+ if (v.backgroundhighlighting === 'basepairs') backgroundhighlightBasepairs(v);
3444
+ }
3445
+
3446
+ // Basepair styling (colour + optional G-U dashing)
3447
+ styleBasepairs(v);
3448
+
3449
+ // Region highlights
3450
+ applyRegionHighlights(v);
3451
+
3452
+ // Subsequence highlights
3453
+ applySubsequenceHighlights(v);
3454
+
3455
+ // Point mutations
3456
+ applyPointMutations(v);
3457
+
3458
+ // Accessibility overlay
3459
+ if (accessData) {
3460
+ visualiseAccessibility(accessData, v.sequence1.length, accessColors, accessColorMode);
3461
+ }
3462
+
3463
+ // Linear-helix constraints may extend the initial bounds. Refit
3464
+ // after the first force ticks and all hidden nodes are removed.
3465
+ if (forceLayout && (forceLayoutLinearRRI || forceLayoutLinearStructure) &&
3466
+ typeof container.centerView === 'function') {
3467
+ container.centerView();
3468
+ }
3469
+
3470
+ // When animation is on, keep the background-highlight polygon in sync
3471
+ // with the force-layout by redrawing it on every animation frame.
3472
+ if (forceLayout) {
3473
+ function highlightSyncLoop() {
3474
+ document.querySelectorAll('[data-varri-region]').forEach(el => el.remove());
3475
+ document.querySelectorAll('[data-varri-subseq]').forEach(el => el.remove());
3476
+
3477
+ applyRegionHighlights(v);
3478
+ applySubsequenceHighlights(v);
3479
+
3480
+ _animFrameId = requestAnimationFrame(highlightSyncLoop);
3481
+ }
3482
+ _animFrameId = requestAnimationFrame(highlightSyncLoop);
3483
+ }
3484
+ }
3485
+
3486
+ return new Promise((resolve, reject) => {
3487
+ _pendingRenderResolve = resolve;
3488
+ _renderTimeoutId = setTimeout(() => {
3489
+ _renderTimeoutId = null;
3490
+ _pendingRenderResolve = null;
3491
+
3492
+ try {
3493
+ applyModifications();
3494
+ resolve({ cancelled: false });
3495
+ } catch (err) {
3496
+ reject(err);
3497
+ }
3498
+ }, 200);
3499
+ });
3500
+ }
3501
+
3502
+ // -----------------------------------------------------------------------
3503
+ // Rotation helpers
3504
+ // -----------------------------------------------------------------------
3505
+
3506
+ /**
3507
+ * Normalise a rotation angle to the range [-180, 180].
3508
+ *
3509
+ * @param {number} degrees
3510
+ * @returns {number}
3511
+ */
3512
+ function normaliseRotationDegrees(degrees) {
3513
+ if (!Number.isFinite(degrees)) {
3514
+ throw new Error('Rotation degrees must be a finite number');
3515
+ }
3516
+ let value = degrees % 360;
3517
+ if (value > 180) value -= 360;
3518
+ if (value < -180) value += 360;
3519
+ return value;
3520
+ }
3521
+
3522
+ /**
3523
+ * Resolve the element that should host the rotation layer.
3524
+ *
3525
+ * If Fornac's plot group exists, rotate inside that group so that
3526
+ * pan/zoom transforms stay in screen-space and dragging keeps expected
3527
+ * directions after rotation.
3528
+ *
3529
+ * @param {SVGSVGElement} svgEl
3530
+ * @returns {SVGElement}
3531
+ */
3532
+ function getRotationHost(svgEl) {
3533
+ const fornacPlot = svgEl.querySelector('.fornac-plot');
3534
+ return fornacPlot || svgEl;
3535
+ }
3536
+
3537
+ /**
3538
+ * Ensure a host element has a dedicated layer that can be rotated.
3539
+ *
3540
+ * @param {SVGElement} hostEl
3541
+ * @returns {SVGGElement}
3542
+ */
3543
+ function ensureRotationLayer(hostEl) {
3544
+ let layer = Array.from(hostEl.children).find(child =>
3545
+ child.tagName && child.tagName.toLowerCase() === 'g' &&
3546
+ child.getAttribute('data-varri-rotation-layer') === 'true'
3547
+ );
3548
+
3549
+ if (!layer) {
3550
+ layer = document.createElementNS('http://www.w3.org/2000/svg', 'g');
3551
+ layer.setAttribute('data-varri-rotation-layer', 'true');
3552
+ hostEl.appendChild(layer);
3553
+ }
3554
+
3555
+ const nodesToMove = Array.from(hostEl.childNodes).filter(node => {
3556
+ if (node === layer) return false;
3557
+ if (hostEl.tagName && hostEl.tagName.toLowerCase() === 'svg' &&
3558
+ node.nodeType === Node.ELEMENT_NODE && node.tagName &&
3559
+ node.tagName.toLowerCase() === 'defs') {
3560
+ return false;
3561
+ }
3562
+ return true;
3563
+ });
3564
+ nodesToMove.forEach(node => layer.appendChild(node));
3565
+
3566
+ return layer;
3567
+ }
3568
+
3569
+ /**
3570
+ * Compute the centre of an SVG element's bounding box.
3571
+ *
3572
+ * @param {SVGGraphicsElement} el
3573
+ * @returns {{x:number, y:number}|null}
3574
+ */
3575
+ function getBBoxCenter(el) {
3576
+ try {
3577
+ const bbox = el.getBBox();
3578
+ if (!Number.isFinite(bbox.x) || !Number.isFinite(bbox.y) ||
3579
+ !Number.isFinite(bbox.width) || !Number.isFinite(bbox.height)) {
3580
+ return null;
3581
+ }
3582
+ return {
3583
+ x: bbox.x + (bbox.width / 2),
3584
+ y: bbox.y + (bbox.height / 2),
3585
+ };
3586
+ } catch (err) {
3587
+ return null;
3588
+ }
3589
+ }
3590
+
3591
+ /**
3592
+ * Rotate the current visualisation around its bounding-box centre while
3593
+ * keeping text labels horizontally aligned.
3594
+ *
3595
+ * @param {string} containerId ID of the container element.
3596
+ * @param {number} degrees Rotation amount.
3597
+ * @param {Object} [options]
3598
+ * @param {'delta'|'absolute'} [options.mode='delta']
3599
+ * @returns {number} Applied absolute angle in degrees (normalised).
3600
+ */
3601
+ function rotateVisualization(containerId, degrees, options = {}) {
3602
+ const container = document.getElementById(containerId);
3603
+ const svgEl = container && container.querySelector('svg');
3604
+ if (!svgEl) throw new Error('No SVG found in container');
3605
+
3606
+ const amount = Number(degrees);
3607
+ if (!Number.isFinite(amount)) {
3608
+ throw new Error('Rotation degrees must be a finite number');
3609
+ }
3610
+
3611
+ const mode = options.mode === 'absolute' ? 'absolute' : 'delta';
3612
+ const current = Number(svgEl.getAttribute('data-varri-rotation') || 0);
3613
+ const target = normaliseRotationDegrees(mode === 'absolute' ? amount : current + amount);
3614
+
3615
+ const hostEl = getRotationHost(svgEl);
3616
+ const layer = ensureRotationLayer(hostEl);
3617
+ const center = getBBoxCenter(layer);
3618
+ if (!center) return current;
3619
+
3620
+ layer.setAttribute('transform', `rotate(${target} ${center.x} ${center.y})`);
3621
+ svgEl.setAttribute('data-varri-rotation', String(target));
3622
+
3623
+ layer.querySelectorAll('text').forEach(textEl => {
3624
+ if (!textEl.hasAttribute('data-varri-base-transform')) {
3625
+ textEl.setAttribute('data-varri-base-transform', textEl.getAttribute('transform') || '');
3626
+ }
3627
+ const baseTransform = textEl.getAttribute('data-varri-base-transform') || '';
3628
+ if (target === 0) {
3629
+ if (baseTransform) {
3630
+ textEl.setAttribute('transform', baseTransform);
3631
+ } else {
3632
+ textEl.removeAttribute('transform');
3633
+ }
3634
+ return;
3635
+ }
3636
+
3637
+ const textCenter = getBBoxCenter(textEl) || center;
3638
+ const transformParts = [];
3639
+ if (baseTransform) transformParts.push(baseTransform);
3640
+ transformParts.push(`rotate(${-target} ${textCenter.x} ${textCenter.y})`);
3641
+ textEl.setAttribute('transform', transformParts.join(' '));
3642
+ });
3643
+
3644
+ return target;
3645
+ }
3646
+
3647
+ // -----------------------------------------------------------------------
3648
+ // SVG / PNG export
3649
+ // -----------------------------------------------------------------------
3650
+
3651
+ /**
3652
+ * SVG presentation properties to inline when exporting.
3653
+ *
3654
+ * These cover every visual property used by Fornac and vaRRI: fill/stroke
3655
+ * paint, font, text alignment, and element visibility. Using this fixed
3656
+ * list avoids dumping hundreds of irrelevant properties from
3657
+ * `getComputedStyle` (e.g. layout-only CSS that SVG viewers ignore).
3658
+ */
3659
+ const SVG_STYLE_PROPS = [
3660
+ 'fill', 'fill-opacity', 'fill-rule',
3661
+ 'stroke', 'stroke-width', 'stroke-opacity',
3662
+ 'stroke-dasharray', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit',
3663
+ 'font-family', 'font-size', 'font-weight', 'font-style',
3664
+ 'text-anchor', 'dominant-baseline', 'alignment-baseline',
3665
+ 'opacity', 'visibility', 'display',
3666
+ 'marker-start', 'marker-end', 'marker-mid',
3667
+ 'color',
3668
+ ];
3669
+
3670
+ /**
3671
+ * Walk `originalEl` and `cloneEl` in parallel, reading computed styles
3672
+ * from `originalEl` (which has all browser CSS applied) and writing them
3673
+ * as an inline `style` attribute on `cloneEl`.
3674
+ *
3675
+ * This makes every element carry its own fully-resolved presentation
3676
+ * values so the exported SVG is self-contained — no external stylesheet
3677
+ * is required. In particular:
3678
+ * - class-based rules (`.fornac-node`, `.fornac-link`, etc.) are baked in
3679
+ * - relative units (`0.4em` font-size) are resolved to absolute pixels
3680
+ * - inline `style` overrides from vaRRI (strand colours, highlights) are
3681
+ * already included in the computed value, so nothing is lost
3682
+ *
3683
+ * @param {Element} originalEl Live DOM element (inside the visible SVG).
3684
+ * @param {Element} cloneEl Corresponding cloned element.
3685
+ */
3686
+ function inlineComputedStyles(originalEl, cloneEl) {
3687
+ if (!originalEl || originalEl.nodeType !== Node.ELEMENT_NODE) return;
3688
+
3689
+ // Leave <style> and <defs> subtrees alone — they hold definitions, not
3690
+ // rendered shapes, and rewriting their style attributes would break them.
3691
+ const tag = (originalEl.tagName || '').toLowerCase();
3692
+ if (tag === 'style' || tag === 'defs') return;
3693
+
3694
+ const computed = window.getComputedStyle(originalEl);
3695
+ let inlined = '';
3696
+ for (const prop of SVG_STYLE_PROPS) {
3697
+ const val = computed.getPropertyValue(prop);
3698
+ if (val) inlined += `${prop}:${val};`;
3699
+ }
3700
+ if (inlined) cloneEl.setAttribute('style', inlined);
3701
+
3702
+ // Recurse into child elements in lock-step.
3703
+ const origKids = originalEl.children;
3704
+ const cloneKids = cloneEl.children;
3705
+ for (let i = 0; i < origKids.length; i++) {
3706
+ if (cloneKids[i]) inlineComputedStyles(origKids[i], cloneKids[i]);
3707
+ }
3708
+ }
3709
+
3710
+ /**
3711
+ * Build a self-contained SVG string from the current Fornac visualisation.
3712
+ *
3713
+ * Strategy:
3714
+ * 1. Clone the live SVG element (preserves all D3 transforms and vaRRI
3715
+ * DOM modifications).
3716
+ * 2. Walk original + clone in parallel and inline every computed
3717
+ * presentation property so the file is fully self-contained.
3718
+ * 3. Set explicit pixel width/height on the root so viewers render at
3719
+ * the same size as the browser display.
3720
+ * 4. Prepend a white background rect to match the container's background.
3721
+ * 5. Serialise with XMLSerializer (namespace-aware).
3722
+ *
3723
+ * @param {string} containerId ID of the container element.
3724
+ * @returns {string} Full SVG markup.
3725
+ */
3726
+ function buildSVGString(containerId) {
3727
+ const container = document.getElementById(containerId);
3728
+ const svgEl = container && container.querySelector('svg');
3729
+ if (!svgEl) throw new Error('No SVG found in container');
3730
+
3731
+ // Clone the live SVG so we can annotate it without touching the DOM.
3732
+ const clone = svgEl.cloneNode(true);
3733
+
3734
+ // Inline all computed presentation styles before any other annotation
3735
+ // so that class-based CSS rules, relative units, and inherited values
3736
+ // are all baked into the clone as plain inline style attributes.
3737
+ inlineComputedStyles(svgEl, clone);
3738
+
3739
+ // Required namespace declarations for a standalone SVG file.
3740
+ clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
3741
+ clone.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
3742
+
3743
+ // Derive pixel dimensions from the rendered element so the exported
3744
+ // file renders at the same size as what the user sees in the browser.
3745
+ const w = svgEl.clientWidth || container.clientWidth || 800;
3746
+ const h = svgEl.clientHeight || container.clientHeight || 600;
3747
+ clone.setAttribute('width', w);
3748
+ clone.setAttribute('height', h);
3749
+
3750
+ // Keep (or synthesise) the viewBox so the internal coordinate space
3751
+ // that Fornac uses maps 1:1 to the exported pixel dimensions.
3752
+ if (!clone.getAttribute('viewBox')) {
3753
+ clone.setAttribute('viewBox', `0 0 ${w} ${h}`);
3754
+ }
3755
+
3756
+ // White background rect — matches the container's background: #fff
3757
+ // so the exported image looks identical to the on-screen visualisation.
3758
+ const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
3759
+ bg.setAttribute('width', '100%');
3760
+ bg.setAttribute('height', '100%');
3761
+ bg.setAttribute('fill', 'white');
3762
+ clone.insertBefore(bg, clone.firstChild);
3763
+
3764
+ return new XMLSerializer().serializeToString(clone);
3765
+ }
3766
+
3767
+ /**
3768
+ * Trigger a browser download of the current visualisation as an SVG file.
3769
+ *
3770
+ * @param {string} containerId ID of the container element.
3771
+ * @param {string} [filename="vaRRI_output.svg"]
3772
+ */
3773
+ function downloadSVG(containerId, filename = 'vaRRI_output.svg') {
3774
+ const svgStr = buildSVGString(containerId);
3775
+ const blob = new Blob([svgStr], { type: 'image/svg+xml' });
3776
+ triggerDownload(URL.createObjectURL(blob), filename);
3777
+ }
3778
+
3779
+ /**
3780
+ * Trigger a browser download of the current visualisation as a PNG image.
3781
+ *
3782
+ * Rasterises the SVG to a canvas at `scale` × the rendered size and
3783
+ * converts it to a PNG data URL. A white background is painted on the
3784
+ * canvas before the image is drawn so the result matches the on-screen
3785
+ * appearance.
3786
+ *
3787
+ * @param {string} containerId ID of the container element.
3788
+ * @param {string} [filename="vaRRI_output.png"]
3789
+ * @param {number} [scale=2] Resolution multiplier (2 = retina quality).
3790
+ */
3791
+ function downloadPNG(containerId, filename = 'vaRRI_output.png', scale = 2) {
3792
+ const svgStr = buildSVGString(containerId);
3793
+ const blob = new Blob([svgStr], { type: 'image/svg+xml' });
3794
+ const url = URL.createObjectURL(blob);
3795
+
3796
+ // Determine the rendered pixel size from the live container so that
3797
+ // canvas dimensions are correct regardless of the SVG's naturalWidth.
3798
+ const container = document.getElementById(containerId);
3799
+ const svgEl = container && container.querySelector('svg');
3800
+ const w = (svgEl && svgEl.clientWidth) || (container && container.clientWidth) || 800;
3801
+ const h = (svgEl && svgEl.clientHeight) || (container && container.clientHeight) || 600;
3802
+
3803
+ function rasterise(imgEl, canvasW, canvasH) {
3804
+ const canvas = document.createElement('canvas');
3805
+ canvas.width = canvasW;
3806
+ canvas.height = canvasH;
3807
+ const ctx = canvas.getContext('2d');
3808
+ // White background to match the container's CSS background colour.
3809
+ ctx.fillStyle = 'white';
3810
+ ctx.fillRect(0, 0, canvasW, canvasH);
3811
+ ctx.drawImage(imgEl, 0, 0, canvasW, canvasH);
3812
+ return canvas.toDataURL('image/png');
3813
+ }
3814
+
3815
+ const img = new Image();
3816
+ img.onload = () => {
3817
+ const dataUrl = rasterise(img, w * scale, h * scale);
3818
+ URL.revokeObjectURL(url);
3819
+ triggerDownload(dataUrl, filename);
3820
+ };
3821
+ img.onerror = () => {
3822
+ // Fallback: load the SVG via a data URI instead of a blob URL.
3823
+ URL.revokeObjectURL(url);
3824
+ const dataUri = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgStr);
3825
+ const imgFallback = new Image();
3826
+ imgFallback.onload = () => {
3827
+ triggerDownload(rasterise(imgFallback, w * scale, h * scale), filename);
3828
+ };
3829
+ imgFallback.src = dataUri;
3830
+ };
3831
+ img.src = url;
3832
+ }
3833
+
3834
+ /**
3835
+ * Create a hidden `<a>` element and programmatically click it to download.
3836
+ *
3837
+ * @param {string} href URL or data URI.
3838
+ * @param {string} filename
3839
+ */
3840
+ function triggerDownload(href, filename) {
3841
+ const a = document.createElement('a');
3842
+ a.href = href;
3843
+ a.download = filename;
3844
+ a.style.display = 'none';
3845
+ document.body.appendChild(a);
3846
+ a.click();
3847
+ document.body.removeChild(a);
3848
+ }
3849
+
3850
+ // -----------------------------------------------------------------------
3851
+ // Public API
3852
+ // -----------------------------------------------------------------------
3853
+
3854
+ const vaRRI = {
3855
+ // Core
3856
+ cancelActiveRender,
3857
+ normaliseRotationDegrees,
3858
+ render,
3859
+ rotateVisualization,
3860
+ validate,
3861
+
3862
+ // Colors
3863
+ getColors,
3864
+ setColors,
3865
+
3866
+ // Annotation registries
3867
+ clearPointMutations,
3868
+ clearRegionHighlights,
3869
+ clearSubsequenceHighlights,
3870
+ computeBackgroundRegionRanges,
3871
+ createPointMutation,
3872
+ createRegionHighlight,
3873
+ createSubsequenceHighlight,
3874
+ getPointMutations,
3875
+ getRegionHighlightNodePath,
3876
+ getRegionHighlights,
3877
+ getSubsequenceHighlights,
3878
+ registerGeneratedRegionHighlight,
3879
+ registerPointMutation,
3880
+ registerRegionHighlight,
3881
+ registerSubsequenceHighlight,
3882
+ removePointMutation,
3883
+ removeRegionHighlight,
3884
+ removeSubsequenceHighlight,
3885
+ updatePointMutation,
3886
+ updateRegionHighlight,
3887
+ updateSubsequenceHighlight,
3888
+
3889
+ // Validation and formatting
3890
+ checkStructureInputSimple,
3891
+ findBasePairs,
3892
+ formatSequence,
3893
+ formatStructure,
3894
+ getIndexDictionary,
3895
+ getMolecules,
3896
+ getSequenceIndices,
3897
+ parseSubsequences,
3898
+ splitAtAmpersand,
3899
+ validateBackgroundhighlighting,
3900
+ validateCroppingInput,
3901
+ validateHighlighting,
3902
+ validateOffset,
3903
+ normaliseMutationPosition,
3904
+ validateSequenceInput,
3905
+ validateStructureInput,
3906
+
3907
+ // Base-pair utilities
3908
+ getIntermolBasepairRegion,
3909
+ getLinearRriConstraintSpecs,
3910
+ getLinearStructureConstraintSpecs,
3911
+ listBasepairs,
3912
+ listIntermolNodes,
3913
+ listIntermolPairs,
3914
+ listRriLoopBoundaryPairs,
3915
+ listStructureLoopBoundaryPairs,
3916
+ sequenceColoring,
3917
+
3918
+ // DOM modifications (advanced use)
3919
+ addElement,
3920
+ addStyleToNodes,
3921
+ applyLinearHelixSprings,
3922
+ applyPointMutations,
3923
+ applyRegionHighlights,
3924
+ applySubsequenceHighlights,
3925
+ backgroundhighlightBasepairs,
3926
+ backgroundhighlightRegion,
3927
+ changeBackgroundColor,
3928
+ closePolygonPoints,
3929
+ getPositionOfNode,
3930
+ highlightBasepairs,
3931
+ highlightRegion,
3932
+ highlightSubsequence,
3933
+ polyline,
3934
+ removeDummyNodes,
3935
+ removeSecondLink,
3936
+ setAttributeForElements,
3937
+ setIndexLabels,
3938
+ setLabelsId,
3939
+ setLinksId,
3940
+ styleBasepairs,
3941
+ updateLinkTooltips,
3942
+ updateNodeToolTips,
3943
+ visualiseAccessibility,
3944
+
3945
+ // Export
3946
+ buildSVGString,
3947
+ downloadPNG,
3948
+ downloadSVG,
3949
+ };
3950
+
3951
+ // Export
3952
+ global.vaRRI = vaRRI;
3953
+ if (typeof module !== 'undefined' && module.exports) {
3954
+ module.exports = vaRRI;
3955
+ }
3956
+
3957
+ }(typeof window !== 'undefined' ? window : this));