diff --git a/plugins/alignments/src/PileupRenderer/layoutFeature.test.ts b/plugins/alignments/src/PileupRenderer/layoutFeature.test.ts new file mode 100644 index 0000000000..d6fb8f5a7d --- /dev/null +++ b/plugins/alignments/src/PileupRenderer/layoutFeature.test.ts @@ -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) +}) diff --git a/plugins/alignments/src/PileupRenderer/layoutFeature.ts b/plugins/alignments/src/PileupRenderer/layoutFeature.ts index 79d2df385a..ffa06af287 100644 --- a/plugins/alignments/src/PileupRenderer/layoutFeature.ts +++ b/plugins/alignments/src/PileupRenderer/layoutFeature.ts @@ -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 - 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 + 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 diff --git a/plugins/alignments/src/PileupRenderer/layoutFeatures.ts b/plugins/alignments/src/PileupRenderer/layoutFeatures.ts index 50dbebc634..d3237e805a 100644 --- a/plugins/alignments/src/PileupRenderer/layoutFeatures.ts +++ b/plugins/alignments/src/PileupRenderer/layoutFeatures.ts @@ -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' @@ -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[] = [] @@ -30,6 +39,7 @@ export function layoutFeats(props: PreProcessedRenderArgs) { showSoftClip, heightPx, displayMode, + maxClippingSize, }) if (result) {