@teselagen/ove 0.8.40 → 0.8.41

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.
@@ -0,0 +1,208 @@
1
+ /* Copyright (C) 2018 TeselaGen Biotechnology, Inc. */
2
+ import * as chai from "chai";
3
+ import {
4
+ findAlignmentDifferences,
5
+ groupConsecutiveDifferences
6
+ } from "./findAlignmentDifferences";
7
+
8
+ chai.should();
9
+ const { expect } = chai;
10
+
11
+ describe("findAlignmentDifferences", () => {
12
+ it("returns empty array when fewer than 2 tracks", () => {
13
+ expect(findAlignmentDifferences([])).to.deep.equal([]);
14
+ expect(findAlignmentDifferences(["ACGT"])).to.deep.equal([]);
15
+ });
16
+
17
+ it("returns empty array when template is empty", () => {
18
+ expect(findAlignmentDifferences(["", ""])).to.deep.equal([]);
19
+ });
20
+
21
+ it("returns empty array when sequences are identical", () => {
22
+ expect(findAlignmentDifferences(["ACGT", "ACGT"])).to.deep.equal([]);
23
+ });
24
+
25
+ it("detects a single mismatch", () => {
26
+ // pos 1: A vs T
27
+ const diffs = findAlignmentDifferences(["ACGT", "ATGT"]);
28
+ expect(diffs).to.have.lengthOf(1);
29
+ expect(diffs[0]).to.deep.include({ position: 1, type: "mismatch" });
30
+ });
31
+
32
+ it("detects multiple mismatches", () => {
33
+ const diffs = findAlignmentDifferences(["AAAA", "TATA"]);
34
+ expect(diffs).to.have.lengthOf(2);
35
+ expect(diffs.map(d => d.position)).to.deep.equal([0, 2]);
36
+ diffs.forEach(d => expect(d.type).to.equal("mismatch"));
37
+ });
38
+
39
+ it("detects mismatches in protein sequences (amino acids)", () => {
40
+ const diffs = findAlignmentDifferences(["MAST", "MAST"]);
41
+ expect(diffs).to.have.lengthOf(0);
42
+
43
+ const diffs2 = findAlignmentDifferences(["MAST", "MVST"]);
44
+ expect(diffs2).to.have.lengthOf(1);
45
+ expect(diffs2[0]).to.deep.include({ position: 1, type: "mismatch" });
46
+ });
47
+
48
+ it("is case-insensitive", () => {
49
+ const diffs = findAlignmentDifferences(["ACGT", "acgt"]);
50
+ expect(diffs).to.have.lengthOf(0);
51
+ });
52
+
53
+ it("detects a single-base insertion (gap in template)", () => {
54
+ // template: A-GT, query: ACGT → position 1 is an insertion
55
+ const diffs = findAlignmentDifferences(["A-GT", "ACGT"]);
56
+ const insertions = diffs.filter(d => d.type === "insertion");
57
+ expect(insertions).to.have.lengthOf(1);
58
+ expect(insertions[0].position).to.equal(1);
59
+ });
60
+
61
+ it("detects a multi-base insertion", () => {
62
+ // template: A---GT, query: ACCCGT
63
+ const diffs = findAlignmentDifferences(["A---GT", "ACCCGT"]);
64
+ const insertions = diffs.filter(d => d.type === "insertion");
65
+ expect(insertions).to.have.lengthOf(3);
66
+ expect(insertions.map(d => d.position)).to.deep.equal([1, 2, 3]);
67
+ });
68
+
69
+ it("detects a single-base deletion (gap in non-template)", () => {
70
+ // template: ACGT, query: A-GT → position 1 is a deletion
71
+ const diffs = findAlignmentDifferences(["ACGT", "A-GT"]);
72
+ const deletions = diffs.filter(d => d.type === "deletion");
73
+ expect(deletions).to.have.lengthOf(1);
74
+ expect(deletions[0].position).to.equal(1);
75
+ });
76
+
77
+ it("detects a multi-base deletion", () => {
78
+ // template: ACCCGT, query: A---GT
79
+ const diffs = findAlignmentDifferences(["ACCCGT", "A---GT"]);
80
+ const deletions = diffs.filter(d => d.type === "deletion");
81
+ expect(deletions).to.have.lengthOf(3);
82
+ expect(deletions.map(d => d.position)).to.deep.equal([1, 2, 3]);
83
+ });
84
+
85
+ it("detects leading non-aligned region", () => {
86
+ // query hasn't started yet at position 0-1
87
+ const diffs = findAlignmentDifferences(["ACGT", "--GT"]);
88
+ const gaps = diffs.filter(d => d.type === "gap");
89
+ expect(gaps).to.have.lengthOf(2);
90
+ expect(gaps.map(d => d.position)).to.deep.equal([0, 1]);
91
+ });
92
+
93
+ it("detects trailing non-aligned region", () => {
94
+ // query has ended after position 1
95
+ const diffs = findAlignmentDifferences(["ACGT", "AC--"]);
96
+ const gaps = diffs.filter(d => d.type === "gap");
97
+ expect(gaps).to.have.lengthOf(2);
98
+ expect(gaps.map(d => d.position)).to.deep.equal([2, 3]);
99
+ });
100
+
101
+ it("detects both leading and trailing non-aligned regions", () => {
102
+ const diffs = findAlignmentDifferences(["ACGTAC", "-CGTA-"]);
103
+ const gaps = diffs.filter(d => d.type === "gap");
104
+ expect(gaps.map(d => d.position)).to.deep.equal([0, 5]);
105
+ });
106
+
107
+ it("fully-gapped query returns all positions as gap", () => {
108
+ const diffs = findAlignmentDifferences(["ACGT", "----"]);
109
+ expect(diffs).to.have.lengthOf(4);
110
+ diffs.forEach(d => expect(d.type).to.equal("gap"));
111
+ });
112
+
113
+ it("handles mixed difference types in one alignment", () => {
114
+ // template: AACGT
115
+ // query: -ACGG
116
+ // pos 0: gap (leading '-' in query), pos 4: mismatch (T vs G)
117
+ const diffs = findAlignmentDifferences(["AACGT", "-ACGG"]);
118
+ expect(diffs.find(d => d.position === 0)?.type).to.equal("gap");
119
+ expect(diffs.find(d => d.position === 4)?.type).to.equal("mismatch");
120
+ });
121
+
122
+ it("handles multiple non-template tracks", () => {
123
+ // template: ACGT
124
+ // track1: ATGT → mismatch at pos 1
125
+ // track2: AC-T → deletion at pos 2
126
+ const diffs = findAlignmentDifferences(["ACGT", "ATGT", "AC-T"]);
127
+ expect(diffs.find(d => d.position === 1)?.type).to.equal("mismatch");
128
+ expect(diffs.find(d => d.position === 2)?.type).to.equal("deletion");
129
+ });
130
+
131
+ it("includes all track bases in returned diff object", () => {
132
+ // mismatch at position 1: template 'C' vs non-template 'T'
133
+ const diffs = findAlignmentDifferences(["ACGT", "ATGT"]);
134
+ expect(diffs[0].bases).to.have.lengthOf(2);
135
+ expect(diffs[0].bases[0]).to.equal("c"); // template at pos 1 (lowercased)
136
+ expect(diffs[0].bases[1]).to.equal("t"); // non-template at pos 1 (lowercased)
137
+ });
138
+ });
139
+
140
+ describe("groupConsecutiveDifferences", () => {
141
+ it("returns empty array for empty input", () => {
142
+ expect(groupConsecutiveDifferences([])).to.deep.equal([]);
143
+ });
144
+
145
+ it("does not group mismatches — each stays individual", () => {
146
+ // 4 consecutive mismatches → 4 separate entries
147
+ const diffs = findAlignmentDifferences(["AAAA", "TTTT"]);
148
+ const grouped = groupConsecutiveDifferences(diffs);
149
+ expect(grouped).to.have.lengthOf(4);
150
+ grouped.forEach((g, i) => {
151
+ expect(g.type).to.equal("mismatch");
152
+ expect(g.start).to.equal(i);
153
+ expect(g.end).to.equal(i);
154
+ });
155
+ });
156
+
157
+ it("groups consecutive deletions into one region", () => {
158
+ // template: ACCCGT, query: A---GT → 3 consecutive deletions
159
+ const diffs = findAlignmentDifferences(["ACCCGT", "A---GT"]);
160
+ const grouped = groupConsecutiveDifferences(diffs);
161
+ expect(grouped).to.have.lengthOf(1);
162
+ expect(grouped[0]).to.deep.include({ type: "deletion", start: 1, end: 3 });
163
+ });
164
+
165
+ it("groups consecutive insertions into one region", () => {
166
+ // template: A---GT, query: ACCCGT → 3 consecutive insertions
167
+ const diffs = findAlignmentDifferences(["A---GT", "ACCCGT"]);
168
+ const grouped = groupConsecutiveDifferences(diffs);
169
+ expect(grouped).to.have.lengthOf(1);
170
+ expect(grouped[0]).to.deep.include({ type: "insertion", start: 1, end: 3 });
171
+ });
172
+
173
+ it("groups consecutive gaps into one region", () => {
174
+ // leading 2-base gap + trailing 2-base gap
175
+ const diffs = findAlignmentDifferences(["ACGTAC", "--GT--"]);
176
+ const grouped = groupConsecutiveDifferences(diffs);
177
+ expect(grouped).to.have.lengthOf(2);
178
+ expect(grouped[0]).to.deep.include({ type: "gap", start: 0, end: 1 });
179
+ expect(grouped[1]).to.deep.include({ type: "gap", start: 4, end: 5 });
180
+ });
181
+
182
+ it("keeps non-consecutive same-type differences separate", () => {
183
+ // two deletions separated by a match; ends with base so trailing '-' is still aligned
184
+ // template: ACACA, query: A-A-A → deletions at pos 1 and 3, matches elsewhere
185
+ const diffs = findAlignmentDifferences(["ACACA", "A-A-A"]);
186
+ const grouped = groupConsecutiveDifferences(diffs);
187
+ expect(grouped).to.have.lengthOf(2);
188
+ expect(grouped[0]).to.deep.include({ type: "deletion", start: 1, end: 1 });
189
+ expect(grouped[1]).to.deep.include({ type: "deletion", start: 3, end: 3 });
190
+ });
191
+
192
+ it("handles mixed types — groups each type's runs independently", () => {
193
+ // template: A--CGT, query: ACCC-T
194
+ // pos 0: A vs A → match
195
+ // pos 1,2: template '-', query 'C','C' → insertions
196
+ // pos 3: C vs C → match
197
+ // pos 4: G vs '-' → deletion
198
+ // pos 5: T vs T → match
199
+ const diffs = findAlignmentDifferences(["A--CGT", "ACCC-T"]);
200
+ const grouped = groupConsecutiveDifferences(diffs);
201
+ const insertionGroups = grouped.filter(g => g.type === "insertion");
202
+ const deletionGroups = grouped.filter(g => g.type === "deletion");
203
+ expect(insertionGroups).to.have.lengthOf(1);
204
+ expect(insertionGroups[0]).to.deep.include({ start: 1, end: 2 });
205
+ expect(deletionGroups).to.have.lengthOf(1);
206
+ expect(deletionGroups[0]).to.deep.include({ start: 4, end: 4 });
207
+ });
208
+ });
@@ -163,6 +163,11 @@ export const AlignmentView = props => {
163
163
  const [tempTrimAfter, setTempTrimAfter] = useState({});
164
164
  const [tempTrimmingCaret, setTempTrimmingCaret] = useState({});
165
165
  const [searchMatchLayers, setSearchMatchLayers] = React.useState([]);
166
+ const [activeFilterType, setActiveFilterType] = useState("all");
167
+
168
+ const handleFilterChange = useCallback(({ activeFilter }) => {
169
+ setActiveFilterType(activeFilter);
170
+ }, []);
166
171
  const bindOutsideChangeHelper = useRef({});
167
172
  const alignmentHolder = useRef(null);
168
173
  const alignmentHolderTop = useRef(null);
@@ -1018,7 +1023,13 @@ export const AlignmentView = props => {
1018
1023
  })
1019
1024
  : linearViewOptions))}
1020
1025
  additionalSelectionLayers={[
1021
- ...(additionalSelectionLayers || []),
1026
+ ...(i !== 0
1027
+ ? (additionalSelectionLayers || []).filter(layer =>
1028
+ activeFilterType === "all"
1029
+ ? layer.differenceType !== "gap"
1030
+ : layer.differenceType === activeFilterType
1031
+ )
1032
+ : additionalSelectionLayers || []),
1022
1033
  ...(searchMatchLayers || [])
1023
1034
  ]}
1024
1035
  dimensions={{
@@ -1758,7 +1769,11 @@ export const AlignmentView = props => {
1758
1769
  id={id}
1759
1770
  setSearchMatchLayers={setSearchMatchLayers}
1760
1771
  />
1761
- <FindMismatches alignmentJson={alignmentTracks} id={id} />
1772
+ <FindMismatches
1773
+ alignmentJson={alignmentTracks}
1774
+ id={id}
1775
+ onFilterChange={handleFilterChange}
1776
+ />
1762
1777
  {additionalTopEl}
1763
1778
  {saveMessage && (
1764
1779
  <div
@@ -1869,6 +1884,7 @@ export const AlignmentView = props => {
1869
1884
  </>
1870
1885
  }
1871
1886
  alignmentTracks={alignmentTracks}
1887
+ activeFilterType={activeFilterType}
1872
1888
  dimensions={{
1873
1889
  width: Math.max(width, 10) || 10
1874
1890
  }}
@@ -64,6 +64,10 @@
64
64
  /* .alignmentViewTrackContainer:hover .alignmentTrackNameDiv {
65
65
  opacity: 1 !important;
66
66
  } */
67
+ .ve-alignment-top-bar {
68
+ align-items: center;
69
+ }
70
+
67
71
  .ve-alignment-top-bar > * {
68
72
  overflow-wrap: normal;
69
73
  flex: 0 0 auto;
@@ -80,6 +84,82 @@
80
84
  .veAlignmentMismatch {
81
85
  opacity: 0.9;
82
86
  }
87
+
88
+ .veDiffNavigator {
89
+ display: flex;
90
+ align-items: center;
91
+ gap: 4px;
92
+ }
93
+
94
+ /* Menu item content layout */
95
+ .veDiffMenuItem-inner {
96
+ display: flex;
97
+ align-items: center;
98
+ gap: 6px;
99
+ }
100
+
101
+ /* Navigation pill — groups prev/counter/next into one unit */
102
+ .veDiffNav {
103
+ display: flex;
104
+ align-items: center;
105
+ border-radius: 3px;
106
+ background: rgba(92, 112, 128, 0.06);
107
+ border: 1px solid rgba(92, 112, 128, 0.18);
108
+ }
109
+
110
+ .veDiffNav-center {
111
+ display: flex;
112
+ align-items: baseline;
113
+ gap: 3px;
114
+ padding: 0 6px;
115
+ min-width: 64px;
116
+ justify-content: center;
117
+ }
118
+
119
+ .veDiffNav-fraction {
120
+ font-size: 11px;
121
+ font-variant-numeric: tabular-nums;
122
+ color: #5c7080;
123
+ line-height: 1;
124
+ }
125
+
126
+ .veDiffNav-sep {
127
+ margin: 0 1px;
128
+ opacity: 0.45;
129
+ }
130
+
131
+ /* Position number — monospace fits sequence coordinates */
132
+ .veDiffNav-pos {
133
+ font-size: 10px;
134
+ font-family: monospace;
135
+ font-variant-numeric: tabular-nums;
136
+ color: #a7b6c2;
137
+ line-height: 1;
138
+ }
139
+
140
+ .veDiffNav-empty {
141
+ font-size: 11px;
142
+ font-style: italic;
143
+ color: #a7b6c2;
144
+ padding: 0 4px;
145
+ }
146
+
147
+ .bp3-dark .veDiffNav {
148
+ background: rgba(167, 182, 194, 0.05);
149
+ border-color: rgba(167, 182, 194, 0.14);
150
+ }
151
+
152
+ .bp3-dark .veDiffNav-fraction {
153
+ color: #5c7080;
154
+ }
155
+
156
+ .bp3-dark .veDiffNav-pos {
157
+ color: #4f6272;
158
+ }
159
+
160
+ .bp3-dark .veDiffNav-empty {
161
+ color: #5c7080;
162
+ }
83
163
  .veRowItem:has(.rowViewTextContainer) .veAlignmentMismatch {
84
164
  opacity: 0.5;
85
165
  }
@@ -88,19 +88,44 @@ function addHighlightedDifferences(alignmentTracks) {
88
88
  );
89
89
  // .filter by the user-specified mismatch overrides (initially [])
90
90
  const mismatches = matchHighlightRanges.filter(({ isMatch }) => !isMatch);
91
+
92
+ // Compute non-aligned (gap) regions from leading/trailing dashes
93
+ const alignedSeq = track.alignmentData.sequence;
94
+ const seqLen = alignedSeq.length;
95
+ const startIndex = seqLen - alignedSeq.replace(/^-+/, "").length;
96
+ const endIndex = alignedSeq.replace(/-+$/, "").length;
97
+ const gapRanges = [
98
+ startIndex > 0 && {
99
+ start: 0,
100
+ end: startIndex - 1,
101
+ differenceType: "gap"
102
+ },
103
+ endIndex < seqLen && {
104
+ start: endIndex,
105
+ end: seqLen - 1,
106
+ differenceType: "gap"
107
+ }
108
+ ].filter(Boolean);
109
+
91
110
  return {
92
111
  ...track,
93
112
  sequenceData,
94
113
  matchHighlightRanges,
95
- additionalSelectionLayers: matchHighlightRanges
96
- .filter(({ isMatch }) => !isMatch)
97
- .map(range => {
98
- return {
114
+ additionalSelectionLayers: [
115
+ ...matchHighlightRanges
116
+ .filter(({ isMatch }) => !isMatch)
117
+ .map(range => ({
99
118
  ...range,
100
119
  ...highlightRangeProps,
101
120
  className: "veAlignmentMismatch"
102
- };
103
- }),
121
+ })),
122
+ ...gapRanges.map(range => ({
123
+ ...range,
124
+ ...highlightRangeProps,
125
+ className: "veAlignmentMismatch"
126
+ }))
127
+ ],
128
+ gapRanges,
104
129
  mismatches
105
130
  };
106
131
  });
@@ -271,39 +296,52 @@ export default (state = {}, { payload = {}, type }) => {
271
296
  return state;
272
297
  };
273
298
 
274
- //returns an array like so: [{start: 0, end: 4, isMatch: false}, {start,end,isMatch} ... etc]
299
+ //returns an array like so: [{start: 0, end: 4, isMatch: false, differenceType: "mismatch"}, ...]
300
+ // differenceType is one of: "mismatch" | "insertion" | "deletion" | null (for match ranges)
275
301
  function getRangeMatchesBetweenTemplateAndNonTemplate(tempSeq, nonTempSeq) {
276
302
  //assume all sequences are the same length (with gap characters "-" in some places)
277
303
  //loop through all non template sequences and compare them with the template
278
304
 
279
305
  const seqLength = nonTempSeq.length;
280
306
  const ranges = [];
281
- // const startIndex = "".match/[-]/ Math.max(0, .indexOf("-"));
282
307
  const nonTempSeqWithoutLeadingDashes = nonTempSeq.replace(/^-+/g, "");
283
308
  const nonTempSeqWithoutTrailingDashes = nonTempSeq.replace(/-+$/g, "");
284
309
 
285
310
  const startIndex = seqLength - nonTempSeqWithoutLeadingDashes.length;
286
311
  const endIndex =
287
312
  seqLength - (seqLength - nonTempSeqWithoutTrailingDashes.length);
313
+
288
314
  for (let index = startIndex; index < endIndex; index++) {
289
- const isMatch =
290
- tempSeq[index].toLowerCase() === nonTempSeq[index].toLowerCase();
291
- const previousRange = ranges[ranges.length - 1];
292
- if (previousRange) {
293
- if (previousRange.isMatch === isMatch) {
294
- previousRange.end++;
315
+ const tempBase = tempSeq[index].toLowerCase();
316
+ const nonTempBase = nonTempSeq[index].toLowerCase();
317
+ const isMatch = tempBase === nonTempBase;
318
+
319
+ let differenceType = null;
320
+ if (!isMatch) {
321
+ if (tempBase === "-") {
322
+ differenceType = "insertion";
323
+ } else if (nonTempBase === "-") {
324
+ differenceType = "deletion";
295
325
  } else {
296
- ranges.push({
297
- start: index,
298
- end: index,
299
- isMatch
300
- });
326
+ differenceType = "mismatch";
301
327
  }
328
+ }
329
+
330
+ const previousRange = ranges[ranges.length - 1];
331
+ if (
332
+ previousRange &&
333
+ previousRange.isMatch === isMatch &&
334
+ previousRange.differenceType === differenceType
335
+ ) {
336
+ previousRange.end++;
337
+ } else if (previousRange) {
338
+ ranges.push({ start: index, end: index, isMatch, differenceType });
302
339
  } else {
303
340
  ranges.push({
304
341
  start: startIndex,
305
342
  end: startIndex,
306
- isMatch
343
+ isMatch,
344
+ differenceType
307
345
  });
308
346
  }
309
347
  }