Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions plugins/alignments/src/PileupRenderer/layoutFeature.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { PileupLayout } from '@jbrowse/core/util/layouts'

import { getPileupLayoutSpan, layoutFeature } from './layoutFeature.ts'

import type { Mismatch } from '../shared/types.ts'
import type { Feature } from '@jbrowse/core/util'

function makeFeature(
id: string,
start: number,
end: number,
mismatches: Mismatch[],
): Feature {
return {
id: () => id,
get(name: string) {
if (name === 'start') {
return start
}
if (name === 'end') {
return end
}
if (name === 'mismatches') {
return mismatches
}
return undefined
},
} as Feature
}

test('soft clip layout expansion is capped by maxClippingSize', () => {
const layout = new PileupLayout({
featureHeight: 7,
spacing: 0,
maxHeight: 1200,
})
const feature = makeFeature('r1', 100_000, 100_100, [
{ type: 'softclip', start: 0, cliplen: 50_000 },
{ type: 'softclip', start: 99, cliplen: 40_000 },
])

layoutFeature({
feature,
layout,
showSoftClip: true,
heightPx: 7,
displayMode: 'normal',
maxClippingSize: 10_000,
})

const tuple = layout.getRectangles().get('r1')
expect(tuple).toBeDefined()
const [left, , right] = tuple!
expect(left).toBe(90_000)
expect(right).toBe(110_100)
})

test('soft clip sums per side then applies cap', () => {
const layout = new PileupLayout({
featureHeight: 7,
spacing: 0,
maxHeight: 1200,
})
const feature = makeFeature('r1', 1000, 1100, [
{ type: 'softclip', start: 0, cliplen: 3000 },
{ type: 'softclip', start: 0, cliplen: 4000 },
])

layoutFeature({
feature,
layout,
showSoftClip: true,
heightPx: 7,
displayMode: 'normal',
maxClippingSize: 5000,
})

const tuple = layout.getRectangles().get('r1')
expect(tuple).toBeDefined()
const [left, , right] = tuple!
expect(left).toBe(-4000)
expect(right).toBe(1100)
})

test('getPileupLayoutSpan left edge can be left of a read with lower genomic start (#4671)', () => {
const laterStart = makeFeature('a', 5000, 5100, [
{ type: 'softclip', start: 0, cliplen: 6000 },
])
const earlierStart = makeFeature('b', 1000, 2000, [])
const { s: sa } = getPileupLayoutSpan(laterStart, true, 10_000)
const { s: sb } = getPileupLayoutSpan(earlierStart, true, 10_000)
expect(laterStart.get('start')).toBeGreaterThan(earlierStart.get('start'))
expect(sa).toBeLessThan(sb)
})
59 changes: 41 additions & 18 deletions plugins/alignments/src/PileupRenderer/layoutFeature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,42 +3,65 @@ import type { Mismatch } from '../shared/types.ts'
import type { Feature } from '@jbrowse/core/util'
import type { BaseLayout } from '@jbrowse/core/util/layouts'

export function layoutFeature({
feature,
layout,
showSoftClip,
heightPx,
displayMode,
}: {
feature: Feature
layout: BaseLayout<Feature>
showSoftClip?: boolean
heightPx: number
displayMode: string
}): LayoutFeature | null {
// Cache start/end to avoid multiple get() calls
/**
* Genomic interval used for pileup collision detection. When soft clipping is
* on, left/right edges follow mismatch clips (capped by maxClippingSize).
* Must match the order used when laying out features — see layoutFeats sort
* (#4671).
*/
export function getPileupLayoutSpan(
feature: Feature,
showSoftClip: boolean,
maxClippingSize: number,
): { s: number; e: number } {
const featureStart = feature.get('start')
const featureEnd = feature.get('end')

let s = featureStart
let e = featureEnd

// Expand the start and end of feature when softclipping enabled
if (showSoftClip) {
const mismatches = feature.get('mismatches') as Mismatch[] | undefined
if (mismatches) {
let leftExtra = 0
let rightExtra = 0
for (const mismatch of mismatches) {
if (mismatch.type === 'softclip') {
const cliplen = mismatch.cliplen
if (mismatch.start === 0) {
s -= cliplen
leftExtra += cliplen
} else {
e += cliplen
rightExtra += cliplen
}
}
}
s -= Math.min(leftExtra, maxClippingSize)
e += Math.min(rightExtra, maxClippingSize)
}
}
return { s, e }
}

export function layoutFeature({
feature,
layout,
showSoftClip,
heightPx,
displayMode,
maxClippingSize,
}: {
feature: Feature
layout: BaseLayout<Feature>
showSoftClip?: boolean
heightPx: number
displayMode: string
/** Caps soft-clip expansion for layout; matches PileupRenderer region expansion (#3471). */
maxClippingSize: number
}): LayoutFeature | null {
const { s, e } = getPileupLayoutSpan(
feature,
!!showSoftClip,
maxClippingSize,
)

if (displayMode === 'compact') {
heightPx /= 3
Expand Down
18 changes: 14 additions & 4 deletions plugins/alignments/src/PileupRenderer/layoutFeatures.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { readConfObject } from '@jbrowse/core/configuration'

import { layoutFeature } from './layoutFeature.ts'
import { getPileupLayoutSpan, layoutFeature } from './layoutFeature.ts'
import { sortFeature } from './sortUtil.ts'

import type { LayoutFeature, PreProcessedRenderArgs } from './types.ts'
Expand All @@ -14,12 +14,21 @@ export function layoutFeats(props: PreProcessedRenderArgs) {

const heightPx = readConfObject(config, 'height')
const displayMode = readConfObject(config, 'displayMode')
const maxClippingSize = readConfObject(config, 'maxClippingSize') as number

// Sort features by start position for PileupLayout's built-in hint optimization,
// but only when not using explicit sorting (which has its own order)
// Sort by layout left edge (expanded when soft clipping) so iteration order
// matches PileupLayout's row-hint optimization (#4671). Plain start order
// disagrees with collision intervals when clips extend reads leftward.
const featureArr = hasSortedBy
? [...featureMap.values()]
: [...featureMap.values()].sort((a, b) => a.get('start') - b.get('start'))
: [...featureMap.values()].sort((a, b) => {
const { s: as } = getPileupLayoutSpan(a, !!showSoftClip, maxClippingSize)
const { s: bs } = getPileupLayoutSpan(b, !!showSoftClip, maxClippingSize)
if (as !== bs) {
return as - bs
}
return a.get('start') - b.get('start')
})

const layoutRecords: LayoutFeature[] = []

Expand All @@ -30,6 +39,7 @@ export function layoutFeats(props: PreProcessedRenderArgs) {
showSoftClip,
heightPx,
displayMode,
maxClippingSize,
})

if (result) {
Expand Down