@teselagen/ove 0.8.39 → 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.
@@ -2,7 +2,6 @@ import {
2
2
  Button,
3
3
  Popover,
4
4
  Intent,
5
- Tooltip,
6
5
  Tag,
7
6
  Menu,
8
7
  MenuItem
@@ -21,16 +20,15 @@ export default pureNoFunc(function AlignmentVisibilityTool(props) {
21
20
  position="bottom"
22
21
  content={<VisibilityOptions {...props} />}
23
22
  target={
24
- <Tooltip content="Visibility Options">
25
- <Button
26
- className="tg-alignment-visibility-toggle"
27
- small
28
- rightIcon="caret-down"
29
- intent={Intent.PRIMARY}
30
- minimal
31
- icon="eye-open"
32
- />
33
- </Tooltip>
23
+ <Button
24
+ className="tg-alignment-visibility-toggle"
25
+ small
26
+ data-tip="Visibility Options"
27
+ rightIcon="caret-down"
28
+ intent={Intent.PRIMARY}
29
+ minimal
30
+ icon="eye-open"
31
+ />
34
32
  }
35
33
  />
36
34
  );
@@ -23,7 +23,8 @@ export default class Minimap extends React.Component {
23
23
  "scrollAlignmentView",
24
24
  "laneHeight",
25
25
  "laneSpacing",
26
- "isTrackSelected"
26
+ "isTrackSelected",
27
+ "activeFilterType"
27
28
  ].some(key => props[key] !== newProps[key])
28
29
  )
29
30
  return true;
@@ -185,12 +186,14 @@ export default class Minimap extends React.Component {
185
186
  dimensions: { width = 200 },
186
187
  laneHeight,
187
188
  laneSpacing = 1,
188
- isTrackSelected = []
189
+ isTrackSelected = [],
190
+ activeFilterType = "all"
189
191
  } = this.props;
190
192
  const charWidth = this.getCharWidth();
191
193
 
192
194
  const {
193
195
  matchHighlightRanges: _matchHighlightRanges,
196
+ gapRanges = [],
194
197
  alignmentData: { trimmedRange } = {}
195
198
  } = alignmentTracks[i];
196
199
  const matchHighlightRanges = !trimmedRange
@@ -228,10 +231,25 @@ export default class Minimap extends React.Component {
228
231
  const toAdd = `M${xStart},${y} L${xStart + width},${y} L${
229
232
  xStart + width
230
233
  },${y + height} L${xStart},${y + height}`;
231
- if (!range.isMatch) {
234
+ if (
235
+ !range.isMatch &&
236
+ (activeFilterType === "all" ||
237
+ range.differenceType === activeFilterType)
238
+ ) {
232
239
  redPath += toAdd;
233
240
  }
234
241
  });
242
+ if (activeFilterType === "gap") {
243
+ gapRanges.forEach(range => {
244
+ const { xStart, width } = getXStartAndWidthFromNonCircularRange(
245
+ range,
246
+ charWidth
247
+ );
248
+ redPath += `M${xStart},${y} L${xStart + width},${y} L${
249
+ xStart + width
250
+ },${y + height} L${xStart},${y + height}`;
251
+ });
252
+ }
235
253
  return (
236
254
  <div
237
255
  key={i + "-lane"}
@@ -1,134 +1,212 @@
1
- import React from "react";
2
- import { DataTable, withSelectedEntities } from "@teselagen/ui";
3
-
4
- class Mismatches extends React.Component {
5
- UNSAFE_componentWillMount() {
6
- const { alignmentData, mismatches } = this.props;
7
- // const { alignmentId, alignments } = this.props;
8
- const mismatchList = this.getMismatchList(alignmentData, mismatches);
9
- // const mismatchListAll = this.getMismatchList(alignmentId, alignments);
10
- const schema = {
11
- fields: [{ path: "mismatches", type: "number" }]
12
- };
13
- this.setState({ mismatchList, schema });
14
- }
15
-
16
- getGapMap = sequence => {
17
- const gapMap = [0]; //a map of position to how many gaps come before that position [0,0,0,5,5,5,5,17,17,17, ]
18
- sequence.split("").forEach(char => {
19
- if (char === "-") {
20
- gapMap[Math.max(0, gapMap.length - 1)] =
21
- (gapMap[Math.max(0, gapMap.length - 1)] || 0) + 1;
22
- } else {
23
- gapMap.push(gapMap[gapMap.length - 1] || 0);
24
- }
25
- });
26
- return gapMap;
27
- };
1
+ /* Copyright (C) 2018 TeselaGen Biotechnology, Inc. */
2
+
3
+ import {
4
+ Button,
5
+ Intent,
6
+ Menu,
7
+ MenuItem,
8
+ Popover,
9
+ Position,
10
+ Tag
11
+ } from "@blueprintjs/core";
12
+ import React, { useEffect, useMemo } from "react";
13
+ import { useSelector } from "react-redux";
14
+ import {
15
+ findAlignmentDifferences,
16
+ groupConsecutiveDifferences
17
+ } from "./findAlignmentDifferences";
18
+ import { scrollToAlignmentSelection, updateCaretPosition } from "./utils";
19
+
20
+ const FILTER_OPTIONS = [
21
+ { value: "all", label: "All" },
22
+ { value: "mismatch", label: "Mismatches" },
23
+ { value: "insertion", label: "Insertions" },
24
+ { value: "deletion", label: "Deletions" },
25
+ { value: "gap", label: "Gaps" }
26
+ ];
27
+
28
+ export function FindMismatches(props) {
29
+ const { alignmentJson, id, onFilterChange } = props;
30
+ const alignedSeqs = useMemo(
31
+ () => alignmentJson.map(t => t.alignmentData?.sequence || ""),
32
+ [alignmentJson]
33
+ );
34
+
35
+ const [activeFilter, setActiveFilter] = React.useState("all");
28
36
 
29
- getMismatchList = (alignmentData, mismatches) => {
30
- // getMismatchList = (alignmentId, alignments) => {
31
- // let mismatchListAll = [];
32
- // skip first sequence/ref seq, since there will be no mismatches
33
- // for (let trackI = 1; trackI < alignments[alignmentId].alignmentTracks.length; trackI++) {
34
- const mismatchList = [];
35
- // const trackName = alignmentData.name;
36
- // const editedTrackName = trackName.slice(trackName.indexOf("_") + 1);
37
-
38
- let getGaps = () => ({
39
- gapsBefore: 0,
40
- gapsInside: 0
37
+ const allDifferences = useMemo(
38
+ () => groupConsecutiveDifferences(findAlignmentDifferences(alignedSeqs)),
39
+ [alignedSeqs]
40
+ );
41
+
42
+ const countsByType = useMemo(() => {
43
+ const counts = { all: 0, mismatch: 0, insertion: 0, deletion: 0, gap: 0 };
44
+ allDifferences.forEach(d => {
45
+ counts[d.type] = (counts[d.type] || 0) + 1;
46
+ counts.all++;
41
47
  });
42
- const gapMap = this.getGapMap(alignmentData.sequence);
43
- getGaps = rangeOrCaretPosition => {
44
- if (typeof rangeOrCaretPosition !== "object") {
45
- return {
46
- gapsBefore: gapMap[Math.min(rangeOrCaretPosition, gapMap.length - 1)]
47
- };
48
- }
49
- const { start, end } = rangeOrCaretPosition;
50
- const toReturn = {
51
- gapsBefore: gapMap[start],
52
- gapsInside:
53
- gapMap[Math.min(end, gapMap.length - 1)] -
54
- gapMap[Math.min(start, gapMap.length - 1)]
55
- };
56
- return toReturn;
57
- };
58
-
59
- const gapsBeforeSequence = getGaps(0).gapsBefore;
60
- for (let mismatchI = 0; mismatchI < mismatches.length; mismatchI++) {
61
- const mismatchEnd = mismatches[mismatchI].end;
62
- const mismatchStart = mismatches[mismatchI].start;
63
- const mismatchDifference = mismatchEnd - mismatchStart;
64
- // display 'position' as 1-based but store 'start' & 'end' as 0-based
65
- if (mismatchDifference === 0) {
66
- mismatchList.push({
67
- mismatches: mismatchStart + 1 - gapsBeforeSequence,
68
- start: mismatchStart - gapsBeforeSequence,
69
- end: mismatchStart - gapsBeforeSequence
70
- });
71
- } else {
72
- for (let innerI = 0; innerI <= mismatchDifference; innerI++) {
73
- mismatchList.push({
74
- mismatches: mismatchStart + innerI + 1 - gapsBeforeSequence,
75
- start: mismatchStart + innerI - gapsBeforeSequence,
76
- end: mismatchStart + innerI - gapsBeforeSequence
77
- });
78
- }
48
+ return counts;
49
+ }, [allDifferences]);
50
+
51
+ const differences = useMemo(() => {
52
+ const filtered =
53
+ activeFilter === "all"
54
+ ? allDifferences
55
+ : allDifferences.filter(d => d.type === activeFilter);
56
+ return [{ position: -1, start: -1, end: -1, bases: [""] }, ...filtered];
57
+ }, [allDifferences, activeFilter]);
58
+
59
+ const currentCaretPosition = useSelector(
60
+ state =>
61
+ state.VectorEditor.__allEditorsOptions.alignments[id]?.caretPosition
62
+ );
63
+
64
+ const [currentIdx, setCurrentIdx] = React.useState(0);
65
+
66
+ const currentDiff = differences[currentIdx];
67
+ const disablePrev = currentIdx <= 1;
68
+ const disableNext = currentIdx >= differences.length - 1;
69
+
70
+ useEffect(() => {
71
+ setCurrentIdx(0);
72
+ }, [activeFilter]);
73
+
74
+ useEffect(() => {
75
+ onFilterChange?.({ activeFilter });
76
+ }, [activeFilter, onFilterChange]);
77
+
78
+ useEffect(() => {
79
+ if (currentCaretPosition !== -1) {
80
+ const diffIdx = differences.findIndex(
81
+ (d, i) =>
82
+ i > 0 &&
83
+ currentCaretPosition >= d.start &&
84
+ currentCaretPosition <= d.end + 1
85
+ );
86
+ if (diffIdx !== -1 && diffIdx !== currentIdx) {
87
+ setCurrentIdx(diffIdx);
79
88
  }
80
89
  }
81
- return mismatchList;
90
+ }, [currentCaretPosition, differences, currentIdx]);
91
+
92
+ const updateView = diff => {
93
+ const idx = differences.indexOf(diff);
94
+ const { start, end } = diff;
95
+ setCurrentIdx(idx);
96
+ updateCaretPosition({ start, end });
97
+ setTimeout(() => {
98
+ scrollToAlignmentSelection();
99
+ }, 0);
82
100
  };
83
101
 
84
- render() {
85
- const { mismatchList, schema } = this.state;
86
- let tableOfMismatches;
87
- if (mismatchList.length === 0) {
88
- tableOfMismatches = null;
89
- } else {
90
- tableOfMismatches = (
91
- <DataTable
92
- maxHeight={168}
93
- formName={"mismatchesTable"}
94
- isSimple
95
- compact
96
- noRouter
97
- // onRowSelect={this.handleMismatchClick}
98
- schema={schema}
99
- entities={mismatchList}
100
- />
101
- );
102
- }
102
+ const prevDifference = () => {
103
+ const pivot =
104
+ currentCaretPosition >= 0
105
+ ? currentCaretPosition
106
+ : (differences[currentIdx]?.start ?? 0);
107
+ const prev = [...differences]
108
+ .reverse()
109
+ .find(d => d.start >= 0 && d.start < pivot);
110
+ if (prev) updateView(prev);
111
+ };
112
+
113
+ const nextDifference = () => {
114
+ const pivot =
115
+ currentCaretPosition >= 0
116
+ ? currentCaretPosition
117
+ : (differences[currentIdx]?.start ?? -1);
118
+ const next = differences.find(d => d.start > pivot && d.start >= 0);
119
+ if (next) updateView(next);
120
+ };
121
+
122
+ const noDifferences = differences.length <= 1;
123
+ const activeOption = FILTER_OPTIONS.find(o => o.value === activeFilter);
124
+ const activeLabel = activeOption?.label ?? "Differences";
125
+
126
+ const filterMenu = (
127
+ <Menu>
128
+ {FILTER_OPTIONS.map(({ value, label }) => {
129
+ const count = countsByType[value] ?? 0;
130
+ const isActive = activeFilter === value;
131
+ return (
132
+ <MenuItem
133
+ key={value}
134
+ active={isActive}
135
+ onClick={() => setActiveFilter(value)}
136
+ text={
137
+ <span className="veDiffMenuItem-inner">
138
+ {label}
139
+ <Tag round minimal style={{ marginLeft: 6 }}>
140
+ {count}
141
+ </Tag>
142
+ </span>
143
+ }
144
+ />
145
+ );
146
+ })}
147
+ </Menu>
148
+ );
149
+
150
+ return (
151
+ <div className="veDiffNavigator">
152
+ {/* Filter dropdown — single button replacing 5 filter chips */}
153
+ <Popover
154
+ minimal
155
+ position={Position.BOTTOM_LEFT}
156
+ content={filterMenu}
157
+ target={
158
+ <Button
159
+ minimal
160
+ data-tip="Filter Difference Type"
161
+ small
162
+ rightIcon="caret-down"
163
+ className="veDiffFilter-trigger"
164
+ >
165
+ {activeLabel}
166
+ </Button>
167
+ }
168
+ />
103
169
 
104
- return (
105
- <div style={{ maxHeight: 180.8, overflowY: "scroll" }}>
106
- {/* <div style={{ fontSize: 15, textAlign: "center" }}><b>Positions of Mismatches</b></div> */}
107
- <div
108
- style={{
109
- // margin: 10,
110
- display: "flex",
111
- flexDirection: "column",
112
- alignItems: "center"
113
- }}
114
- >
115
- <div style={{ width: 100, margin: 4 }}>
116
- {/* <div style={{
117
- paddingBottom: 10,
118
- textOverflow: "ellipsis",
119
- overflowY: "auto",
120
- whiteSpace: "nowrap",
121
- fontSize: 13,
122
- textAlign: "center"
123
- }}>
124
- <b>{mismatchList[0].name}</b>
125
- </div> */}
126
- {tableOfMismatches}
170
+ {/* Navigation control */}
171
+ {noDifferences ? (
172
+ <span className="veDiffNav-empty">
173
+ no{" "}
174
+ {activeFilter === "all" ? "differences" : activeLabel.toLowerCase()}
175
+ </span>
176
+ ) : (
177
+ <div className="veDiffNav">
178
+ <Button
179
+ minimal
180
+ small
181
+ data-tip="Previous Difference"
182
+ icon="arrow-left"
183
+ intent={Intent.PRIMARY}
184
+ onClick={prevDifference}
185
+ disabled={disablePrev}
186
+ />
187
+ <div className="veDiffNav-center">
188
+ <span className="veDiffNav-fraction">
189
+ {currentIdx}
190
+ <span className="veDiffNav-sep">/</span>
191
+ {differences.length - 1}
192
+ </span>
193
+ {currentDiff?.start > -1 && (
194
+ <span className="veDiffNav-pos">:{currentDiff.start + 1}</span>
195
+ )}
127
196
  </div>
197
+ <Button
198
+ minimal
199
+ small
200
+ data-tip="Next Difference"
201
+ icon="arrow-right"
202
+ intent={Intent.PRIMARY}
203
+ onClick={nextDifference}
204
+ disabled={disableNext}
205
+ />
128
206
  </div>
129
- </div>
130
- );
131
- }
207
+ )}
208
+ </div>
209
+ );
132
210
  }
133
211
 
134
- export default withSelectedEntities("mismatchesTable")(Mismatches);
212
+ export default FindMismatches;
@@ -0,0 +1,116 @@
1
+ /* Copyright (C) 2018 TeselaGen Biotechnology, Inc. */
2
+
3
+ /**
4
+ * @typedef {"mismatch"|"insertion"|"deletion"|"gap"} DifferenceType
5
+ *
6
+ * @typedef {Object} AlignmentDifference
7
+ * @property {number} position - 0-based column index in the aligned sequence
8
+ * @property {DifferenceType} type
9
+ * @property {string[]} bases - bases at this column for each track (template first)
10
+ */
11
+
12
+ /**
13
+ * Group consecutive same-type differences into regions.
14
+ * Mismatches are never grouped — each is its own entry.
15
+ * Insertions, deletions, and gaps that are side-by-side are collapsed into
16
+ * one entry with a `start` and `end` (both inclusive, 0-based).
17
+ *
18
+ * @param {AlignmentDifference[]} differences
19
+ * @returns {Array<AlignmentDifference & { start: number, end: number }>}
20
+ */
21
+ export function groupConsecutiveDifferences(differences) {
22
+ const grouped = [];
23
+
24
+ for (const diff of differences) {
25
+ if (diff.type === "mismatch") {
26
+ grouped.push({ ...diff, start: diff.position, end: diff.position });
27
+ continue;
28
+ }
29
+
30
+ const last = grouped[grouped.length - 1];
31
+ if (last && last.type === diff.type && last.end === diff.position - 1) {
32
+ grouped[grouped.length - 1] = { ...last, end: diff.position };
33
+ } else {
34
+ grouped.push({ ...diff, start: diff.position, end: diff.position });
35
+ }
36
+ }
37
+
38
+ return grouped;
39
+ }
40
+
41
+ /**
42
+ * Classify alignment columns into difference types relative to the template track.
43
+ *
44
+ * Template is alignedSeqs[0]. Non-template tracks are alignedSeqs[1+].
45
+ *
46
+ * Classification rules (per column):
47
+ * - "gap" : no non-template track is in its aligned region at this position
48
+ * - "insertion" : template has '-', at least one aligned non-template has a non-gap base
49
+ * - "deletion" : template has a non-gap base, at least one aligned non-template has '-'
50
+ * - "mismatch" : no gaps among aligned tracks, unique base set has more than one member
51
+ *
52
+ * Only tracks whose aligned region covers position i participate in classification.
53
+ * This correctly handles multi-read alignments (e.g. Sanger) where reads cover
54
+ * different sub-ranges of the full alignment.
55
+ *
56
+ * @param {string[]} alignedSeqs - Aligned sequence strings, all same length
57
+ * @returns {AlignmentDifference[]}
58
+ */
59
+ export function findAlignmentDifferences(alignedSeqs) {
60
+ if (alignedSeqs.length < 2 || !alignedSeqs[0]?.length) return [];
61
+
62
+ const template = alignedSeqs[0].toLowerCase();
63
+ const nonTemplates = alignedSeqs.slice(1).map(s => s.toLowerCase());
64
+
65
+ // Compute non-aligned region boundaries for each non-template track.
66
+ // Positions in [0, start) and [end, length) are non-aligned ("gap").
67
+ const trackBounds = nonTemplates.map(seq => {
68
+ const withoutLeading = seq.replace(/^-+/, "");
69
+ const withoutTrailing = seq.replace(/-+$/, "");
70
+ const start = seq.length - withoutLeading.length;
71
+ const end = seq.length - (seq.length - withoutTrailing.length);
72
+ return { start, end };
73
+ });
74
+
75
+ const differences = [];
76
+
77
+ for (let i = 0; i < template.length; i++) {
78
+ const templateBase = template[i];
79
+ const allNonTemplateBases = nonTemplates.map(seq => seq[i]);
80
+ const bases = [templateBase, ...allNonTemplateBases];
81
+
82
+ // Only consider tracks whose aligned region covers position i.
83
+ // Using `some` here would classify a position as a gap whenever any single
84
+ // track hasn't started or has ended, swamping real differences from other
85
+ // tracks that ARE aligned (critical for multi-read Sanger alignments).
86
+ const alignedIndices = trackBounds.reduce((acc, { start, end }, idx) => {
87
+ if (i >= start && i < end) acc.push(idx);
88
+ return acc;
89
+ }, []);
90
+
91
+ if (alignedIndices.length === 0) {
92
+ differences.push({ position: i, type: "gap", bases });
93
+ continue;
94
+ }
95
+
96
+ const alignedBases = alignedIndices.map(idx => allNonTemplateBases[idx]);
97
+ const templateIsGap = templateBase === "-";
98
+ const nonTemplateHasBase = alignedBases.some(b => b !== "-");
99
+ const nonTemplateHasGap = alignedBases.some(b => b === "-");
100
+
101
+ if (templateIsGap && nonTemplateHasBase) {
102
+ differences.push({ position: i, type: "insertion", bases });
103
+ } else if (!templateIsGap && nonTemplateHasGap) {
104
+ differences.push({ position: i, type: "deletion", bases });
105
+ } else if (!templateIsGap) {
106
+ const uniqueBases = new Set([templateBase, ...alignedBases]);
107
+ if (uniqueBases.size > 1) {
108
+ differences.push({ position: i, type: "mismatch", bases });
109
+ }
110
+ }
111
+ }
112
+
113
+ return differences;
114
+ }
115
+
116
+ export default findAlignmentDifferences;