Skip to content

Commit c2b95b8

Browse files
committed
Fix PIF and add some integration tests and volvox del pif
1 parent 3a9f4b4 commit c2b95b8

File tree

6 files changed

+327
-39
lines changed

6 files changed

+327
-39
lines changed

plugins/alignments/src/PileupRenderer/renderers/renderMismatchesCallback.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -216,16 +216,15 @@ export function renderMismatchesCallback({
216216
ctx.fillStyle = colorMap.deletion!
217217
ctx.fillRect(leftPx, topPx, w, heightPx)
218218
deletionDrawn++
219-
} else if (w < 0.3) {
220-
deletionSkipped++
221-
}
222-
if (bpPerPx < 3) {
219+
// Add deletion items for mouseover tooltip when visible
223220
items.push({
224221
type: 'deletion',
225222
length,
226223
start: mismatchStart,
227224
})
228225
coords.push(leftPx, topPx, rightPx, bottomPx)
226+
} else if (w < 0.3) {
227+
deletionSkipped++
229228
}
230229
const txt = String(length)
231230
const rwidth = measureTextSmallNumber(length, 10)
@@ -321,7 +320,8 @@ export function renderMismatchesCallback({
321320
if (bpPerPx < 3) {
322321
items.push({
323322
type: 'insertion',
324-
sequence: base || 'unknown',
323+
length: len,
324+
...(base ? { sequence: base } : {}),
325325
start: mstart,
326326
})
327327
coords.push(leftPx - 2, topPx, leftPx + insW + 2, bottomPx)
@@ -330,7 +330,8 @@ export function renderMismatchesCallback({
330330
} else if (!hideLargeIndels) {
331331
items.push({
332332
type: 'insertion',
333-
sequence: base || 'unknown',
333+
length: len,
334+
...(base ? { sequence: base } : {}),
334335
start: mstart,
335336
})
336337
const txt = `${len}`

plugins/alignments/src/PileupRenderer/types.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export interface LayoutFeature {
1515

1616
export type FlatbushItem =
1717
| { type: 'mismatch'; base: string; start: number }
18-
| { type: 'insertion'; sequence: string; start: number }
18+
| { type: 'insertion'; length: number; sequence?: string; start: number }
1919
| { type: 'deletion'; length: number; start: number }
2020
| { type: 'softclip'; length: number; start: number }
2121
| { type: 'hardclip'; length: number; start: number }
@@ -33,9 +33,12 @@ const LARGE_INSERTION_THRESHOLD = 10
3333
export function getFlatbushItemLabel(item: FlatbushItem): string {
3434
switch (item.type) {
3535
case 'insertion':
36-
return item.sequence.length > LARGE_INSERTION_THRESHOLD
37-
? `${item.sequence.length}bp insertion (click to see)`
38-
: `Insertion: ${item.sequence}`
36+
// If we have the actual sequence and it's small enough, show it
37+
if (item.sequence && item.sequence.length <= LARGE_INSERTION_THRESHOLD) {
38+
return `Insertion: ${item.sequence}`
39+
}
40+
// Otherwise just show the length
41+
return `Insertion: ${item.length}bp`
3942
case 'deletion':
4043
return `Deletion: ${item.length}bp`
4144
case 'softclip':
@@ -68,8 +71,8 @@ export function flatbushItemToFeatureData(
6871
...base,
6972
start: item.start,
7073
end: item.start + 1,
71-
sequence: item.sequence,
72-
insertionLength: item.sequence.length,
74+
...(item.sequence ? { sequence: item.sequence } : {}),
75+
insertionLength: item.length,
7376
}
7477
case 'deletion':
7578
return {
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import { firstValueFrom } from 'rxjs'
2+
import { toArray } from 'rxjs/operators'
3+
4+
import Adapter from './PairwiseIndexedPAFAdapter.ts'
5+
import MyConfigSchema from './configSchema.ts'
6+
7+
function makeAdapter(
8+
pifFile: string,
9+
assemblyNames: [string, string],
10+
indexType: 'TBI' | 'CSI' = 'TBI',
11+
) {
12+
return new Adapter(
13+
MyConfigSchema.create({
14+
pifGzLocation: {
15+
localPath: pifFile,
16+
locationType: 'LocalPathLocation',
17+
},
18+
index: {
19+
indexType,
20+
location: {
21+
localPath: `${pifFile}.tbi`,
22+
locationType: 'LocalPathLocation',
23+
},
24+
},
25+
assemblyNames,
26+
}),
27+
)
28+
}
29+
30+
const pifInsPath =
31+
require.resolve('../../../../test_data/volvox/volvox_ins.pif.gz')
32+
const pifDelPath =
33+
require.resolve('../../../../test_data/volvox/volvox_del.pif.gz')
34+
35+
describe('PairwiseIndexedPAFAdapter', () => {
36+
describe('coordinate extraction from PIF format', () => {
37+
it('fetches features from query assembly perspective (q-lines)', async () => {
38+
const adapter = makeAdapter(pifInsPath, ['volvox_ins', 'volvox'])
39+
40+
const features = adapter.getFeatures({
41+
refName: 'ctgA',
42+
start: 0,
43+
end: 60000,
44+
assemblyName: 'volvox_ins',
45+
})
46+
47+
const featuresArray = await firstValueFrom(features.pipe(toArray()))
48+
expect(featuresArray.length).toBe(1)
49+
50+
const feature = featuresArray[0]!
51+
expect(feature.get('refName')).toBe('ctgA')
52+
expect(feature.get('assemblyName')).toBe('volvox_ins')
53+
expect(feature.get('start')).toBe(0)
54+
expect(feature.get('end')).toBe(54801)
55+
56+
const mate = feature.get('mate')
57+
expect(mate.refName).toBe('ctgA')
58+
expect(mate.assemblyName).toBe('volvox')
59+
expect(mate.start).toBe(0)
60+
expect(mate.end).toBe(50001)
61+
})
62+
63+
it('fetches features from target assembly perspective (t-lines)', async () => {
64+
const adapter = makeAdapter(pifInsPath, ['volvox_ins', 'volvox'])
65+
66+
const features = adapter.getFeatures({
67+
refName: 'ctgA',
68+
start: 0,
69+
end: 60000,
70+
assemblyName: 'volvox',
71+
})
72+
73+
const featuresArray = await firstValueFrom(features.pipe(toArray()))
74+
expect(featuresArray.length).toBe(1)
75+
76+
const feature = featuresArray[0]!
77+
expect(feature.get('refName')).toBe('ctgA')
78+
expect(feature.get('assemblyName')).toBe('volvox')
79+
expect(feature.get('start')).toBe(0)
80+
expect(feature.get('end')).toBe(50001)
81+
82+
const mate = feature.get('mate')
83+
expect(mate.refName).toBe('ctgA')
84+
expect(mate.assemblyName).toBe('volvox_ins')
85+
expect(mate.start).toBe(0)
86+
expect(mate.end).toBe(54801)
87+
})
88+
89+
it('returns consistent coordinates regardless of query perspective', async () => {
90+
const adapter = makeAdapter(pifInsPath, ['volvox_ins', 'volvox'])
91+
92+
const queryFeatures = await firstValueFrom(
93+
adapter
94+
.getFeatures({
95+
refName: 'ctgA',
96+
start: 0,
97+
end: 60000,
98+
assemblyName: 'volvox_ins',
99+
})
100+
.pipe(toArray()),
101+
)
102+
103+
const targetFeatures = await firstValueFrom(
104+
adapter
105+
.getFeatures({
106+
refName: 'ctgA',
107+
start: 0,
108+
end: 60000,
109+
assemblyName: 'volvox',
110+
})
111+
.pipe(toArray()),
112+
)
113+
114+
const qFeature = queryFeatures[0]!
115+
const tFeature = targetFeatures[0]!
116+
117+
expect(qFeature.get('start')).toBe(tFeature.get('mate').start)
118+
expect(qFeature.get('end')).toBe(tFeature.get('mate').end)
119+
expect(qFeature.get('mate').start).toBe(tFeature.get('start'))
120+
expect(qFeature.get('mate').end).toBe(tFeature.get('end'))
121+
})
122+
})
123+
124+
describe('CIGAR handling', () => {
125+
it('uses pre-computed CIGAR from q-lines (D operation for query perspective)', async () => {
126+
const adapter = makeAdapter(pifInsPath, ['volvox_ins', 'volvox'])
127+
128+
const features = await firstValueFrom(
129+
adapter
130+
.getFeatures({
131+
refName: 'ctgA',
132+
start: 0,
133+
end: 60000,
134+
assemblyName: 'volvox_ins',
135+
})
136+
.pipe(toArray()),
137+
)
138+
139+
const cigar = features[0]!.get('CIGAR')
140+
expect(cigar).toBe('31198M4800D18803M')
141+
})
142+
143+
it('uses pre-computed CIGAR from t-lines (I operation for target perspective)', async () => {
144+
const adapter = makeAdapter(pifInsPath, ['volvox_ins', 'volvox'])
145+
146+
const features = await firstValueFrom(
147+
adapter
148+
.getFeatures({
149+
refName: 'ctgA',
150+
start: 0,
151+
end: 60000,
152+
assemblyName: 'volvox',
153+
})
154+
.pipe(toArray()),
155+
)
156+
157+
const cigar = features[0]!.get('CIGAR')
158+
expect(cigar).toBe('31198M4800I18803M')
159+
})
160+
})
161+
162+
describe('deletion test file', () => {
163+
it('fetches features with correct coordinates from del file', async () => {
164+
const adapter = makeAdapter(pifDelPath, ['volvox_del', 'volvox'])
165+
166+
const queryFeatures = await firstValueFrom(
167+
adapter
168+
.getFeatures({
169+
refName: 'ctgA',
170+
start: 0,
171+
end: 60000,
172+
assemblyName: 'volvox_del',
173+
})
174+
.pipe(toArray()),
175+
)
176+
177+
expect(queryFeatures.length).toBe(1)
178+
const feature = queryFeatures[0]!
179+
expect(feature.get('start')).toBe(0)
180+
expect(feature.get('end')).toBe(45141)
181+
expect(feature.get('mate').start).toBe(0)
182+
expect(feature.get('mate').end).toBe(50001)
183+
})
184+
})
185+
186+
describe('getRefNames', () => {
187+
it('returns query reference names for query assembly', async () => {
188+
const adapter = makeAdapter(pifInsPath, ['volvox_ins', 'volvox'])
189+
const refNames = await adapter.getRefNames({
190+
regions: [{ assemblyName: 'volvox_ins' }],
191+
})
192+
expect(refNames).toContain('ctgA')
193+
})
194+
195+
it('returns target reference names for target assembly', async () => {
196+
const adapter = makeAdapter(pifInsPath, ['volvox_ins', 'volvox'])
197+
const refNames = await adapter.getRefNames({
198+
regions: [{ assemblyName: 'volvox' }],
199+
})
200+
expect(refNames).toContain('ctgA')
201+
})
202+
})
203+
204+
describe('getAssemblyNames', () => {
205+
it('returns assembly names from config', () => {
206+
const adapter = makeAdapter(pifInsPath, ['volvox_ins', 'volvox'])
207+
expect(adapter.getAssemblyNames()).toEqual(['volvox_ins', 'volvox'])
208+
})
209+
210+
it('returns assembly names from queryAssembly/targetAssembly config', () => {
211+
const adapter = new Adapter(
212+
MyConfigSchema.create({
213+
pifGzLocation: {
214+
localPath: pifInsPath,
215+
locationType: 'LocalPathLocation',
216+
},
217+
index: {
218+
location: {
219+
localPath: `${pifInsPath}.tbi`,
220+
locationType: 'LocalPathLocation',
221+
},
222+
},
223+
queryAssembly: 'query_asm',
224+
targetAssembly: 'target_asm',
225+
}),
226+
)
227+
expect(adapter.getAssemblyNames()).toEqual(['query_asm', 'target_asm'])
228+
})
229+
})
230+
})

plugins/comparative-adapters/src/PairwiseIndexedPAFAdapter/PairwiseIndexedPAFAdapter.ts

Lines changed: 28 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -84,46 +84,47 @@ export default class PAFAdapter extends BaseFeatureDataAdapter {
8484
return ObservableCreate<Feature>(async observer => {
8585
const { assemblyName } = query
8686

87+
// assemblyNames = [queryAssembly, targetAssembly]
8788
const assemblyNames = this.getAssemblyNames()
8889
const index = assemblyNames.indexOf(assemblyName)
90+
91+
// flip=true when viewing from query assembly perspective
92+
// flip=false when viewing from target assembly perspective
8993
const flip = index === 0
94+
95+
// PIF format indexes lines by perspective:
96+
// - 'q' prefix lines are indexed by query coordinates
97+
// - 't' prefix lines are indexed by target coordinates
9098
const letter = flip ? 'q' : 't'
9199

100+
// The "other" assembly is the mate
101+
const mateAssemblyName = assemblyNames[flip ? 1 : 0]
102+
92103
await updateStatus('Downloading features', statusCallback, () =>
93104
this.pif.getLines(letter + query.refName, query.start, query.end, {
94105
lineCallback: (line, fileOffset) => {
95106
const r = parsePAFLine(line)
96107
const { extra, strand } = r
97108
const { numMatches = 0, blockLen = 1, cg, ...rest } = extra
98109

99-
// Strip 'q'/'t' prefix from first column only (tname has no prefix)
100-
const qname = r.qname.slice(1)
101-
const tname = r.tname
102-
103-
let start: number
104-
let end: number
105-
let refName: string
106-
let mateName: string
107-
let mateStart: number
108-
let mateEnd: number
109-
110-
if (flip) {
111-
start = r.qstart
112-
end = r.qend
113-
refName = qname
114-
mateName = tname
115-
mateStart = r.tstart
116-
mateEnd = r.tend
117-
} else {
118-
start = r.tstart
119-
end = r.tend
120-
refName = tname
121-
mateName = qname
122-
mateStart = r.qstart
123-
mateEnd = r.qend
124-
}
110+
// PIF format pre-orients each line from its perspective:
111+
// - When querying 'q' lines: columns 2-3 have query coords (the "main" feature)
112+
// - When querying 't' lines: columns 2-3 have target coords (the "main" feature)
113+
// The first column has the indexed refName (with q/t prefix to strip)
114+
// The 6th column (tname) has the mate's refName (no prefix)
115+
//
116+
// This means r.qstart/qend always represent the "main" feature coords
117+
// for whichever perspective we're viewing from, and r.tstart/tend
118+
// represent the mate coords
119+
const start = r.qstart
120+
const end = r.qend
121+
const refName = r.qname.slice(1) // Strip 'q'/'t' prefix
122+
const mateName = r.tname
123+
const mateStart = r.tstart
124+
const mateEnd = r.tend
125125

126126
// PIF format already has pre-computed CIGARs for each perspective
127+
// (q-lines have D↔I swapped relative to t-lines)
127128
const CIGAR = extra.cg
128129

129130
observer.next(
@@ -145,7 +146,7 @@ export default class PAFAdapter extends BaseFeatureDataAdapter {
145146
start: mateStart,
146147
end: mateEnd,
147148
refName: mateName,
148-
assemblyName: assemblyNames[+flip],
149+
assemblyName: mateAssemblyName,
149150
},
150151
}),
151152
)

0 commit comments

Comments
 (0)