diff --git a/packages/core/util/offscreenCanvasPonyfill.js b/packages/core/util/offscreenCanvasPonyfill.js index f59a21ac39..2eda10e74a 100644 --- a/packages/core/util/offscreenCanvasPonyfill.js +++ b/packages/core/util/offscreenCanvasPonyfill.js @@ -26,11 +26,17 @@ class PonyfillOffscreenContext { // setters (no getters working) set strokeStyle(style) { - this.commands.push({ type: 'strokeStyle', style }) + if (style !== this.currentStrokeStyle) { + this.commands.push({ type: 'strokeStyle', style }) + this.currentStrokeStyle = style + } } set fillStyle(style) { - this.commands.push({ type: 'fillStyle', style }) + if (style !== this.currentFillStyle) { + this.commands.push({ type: 'fillStyle', style }) + this.currentFillStyle = style + } } set font(style) { diff --git a/plugins/alignments/src/LinearPileupDisplay/model.ts b/plugins/alignments/src/LinearPileupDisplay/model.ts index c071eab04c..0c7a0cb45d 100644 --- a/plugins/alignments/src/LinearPileupDisplay/model.ts +++ b/plugins/alignments/src/LinearPileupDisplay/model.ts @@ -452,6 +452,12 @@ const stateModelFactory = ( self.setColorScheme({ type: 'pairOrientation' }) }, }, + { + label: 'Per-base quality', + onClick: () => { + self.setColorScheme({ type: 'perBaseQuality' }) + }, + }, { label: 'Insert size', onClick: () => { diff --git a/plugins/alignments/src/PileupRenderer/PileupLayoutSession.ts b/plugins/alignments/src/PileupRenderer/PileupLayoutSession.ts new file mode 100644 index 0000000000..f1c8c8856b --- /dev/null +++ b/plugins/alignments/src/PileupRenderer/PileupLayoutSession.ts @@ -0,0 +1,59 @@ +import deepEqual from 'deep-equal' +import { LayoutSession } from '@jbrowse/core/pluggableElementTypes/renderers/BoxRendererType' +import { AnyConfigurationModel } from '@jbrowse/core/configuration/configurationSchema' +import SerializableFilterChain from '@jbrowse/core/pluggableElementTypes/renderers/util/serializableFilterChain' +import GranularRectLayout from '@jbrowse/core/util/layouts/GranularRectLayout' +import MultiLayout from '@jbrowse/core/util/layouts/MultiLayout' +import { readConfObject } from '@jbrowse/core/configuration' + +export interface PileupLayoutSessionProps { + config: AnyConfigurationModel + bpPerPx: number + filters: SerializableFilterChain + sortedBy: unknown + showSoftClip: unknown +} + +type MyMultiLayout = MultiLayout, unknown> +interface CachedPileupLayout { + layout: MyMultiLayout + config: AnyConfigurationModel + filters: SerializableFilterChain + sortedBy: unknown + showSoftClip: boolean +} +// Sorting and revealing soft clip changes the layout of Pileup renderer +// Adds extra conditions to see if cached layout is valid +export class PileupLayoutSession extends LayoutSession { + sortedBy: unknown + + showSoftClip = false + + constructor(args: PileupLayoutSessionProps) { + super(args) + this.config = args.config + } + + cachedLayoutIsValid(cachedLayout: CachedPileupLayout) { + return ( + super.cachedLayoutIsValid(cachedLayout) && + this.showSoftClip === cachedLayout.showSoftClip && + deepEqual(this.sortedBy, cachedLayout.sortedBy) + ) + } + + cachedLayout: CachedPileupLayout | undefined + + get layout(): MyMultiLayout { + if (!this.cachedLayout || !this.cachedLayoutIsValid(this.cachedLayout)) { + this.cachedLayout = { + layout: this.makeLayout(), + config: readConfObject(this.config), + filters: this.filters, + sortedBy: this.sortedBy, + showSoftClip: this.showSoftClip, + } + } + return this.cachedLayout.layout + } +} diff --git a/plugins/alignments/src/PileupRenderer/PileupRenderer.ts b/plugins/alignments/src/PileupRenderer/PileupRenderer.ts index 9d5cbb7b66..e307f9c472 100644 --- a/plugins/alignments/src/PileupRenderer/PileupRenderer.ts +++ b/plugins/alignments/src/PileupRenderer/PileupRenderer.ts @@ -1,13 +1,7 @@ /* eslint-disable no-bitwise */ -import deepEqual from 'deep-equal' import { AnyConfigurationModel } from '@jbrowse/core/configuration/configurationSchema' -import BoxRendererType, { - LayoutSession, -} from '@jbrowse/core/pluggableElementTypes/renderers/BoxRendererType' +import BoxRendererType from '@jbrowse/core/pluggableElementTypes/renderers/BoxRendererType' import { createJBrowseTheme } from '@jbrowse/core/ui' -import GranularRectLayout from '@jbrowse/core/util/layouts/GranularRectLayout' -import MultiLayout from '@jbrowse/core/util/layouts/MultiLayout' -import SerializableFilterChain from '@jbrowse/core/pluggableElementTypes/renderers/util/serializableFilterChain' import { Feature } from '@jbrowse/core/util/simpleFeature' import { bpSpanPx, iterMap } from '@jbrowse/core/util' import { Region } from '@jbrowse/core/util/types' @@ -21,8 +15,13 @@ import { BaseLayout } from '@jbrowse/core/util/layouts/BaseLayout' import { readConfObject } from '@jbrowse/core/configuration' import { RenderArgsDeserialized } from '@jbrowse/core/pluggableElementTypes/renderers/ServerSideRendererType' import { ThemeOptions } from '@material-ui/core' -import { Mismatch } from '../BamAdapter/MismatchParser' +import { Mismatch, parseCigar } from '../BamAdapter/MismatchParser' import { sortFeature } from './sortUtil' +import { orientationTypes } from './util' +import { + PileupLayoutSession, + PileupLayoutSessionProps, +} from './PileupLayoutSession' export interface PileupRenderProps { features: Map @@ -30,11 +29,11 @@ export interface PileupRenderProps { config: AnyConfigurationModel regions: Region[] bpPerPx: number - colorBy: { + colorBy?: { type: string tag?: string } - colorTagMap: { [key: string]: string } + colorTagMap?: { [key: string]: string } height: number width: number highResolutionScaling: number @@ -59,68 +58,6 @@ interface RenderArgsAugmented extends RenderArgsDeserialized { showSoftClip?: boolean } -interface PileupLayoutSessionProps { - config: AnyConfigurationModel - bpPerPx: number - filters: SerializableFilterChain - sortedBy: unknown - showSoftClip: unknown -} - -type MyMultiLayout = MultiLayout, unknown> -interface CachedPileupLayout { - layout: MyMultiLayout - config: AnyConfigurationModel - filters: SerializableFilterChain - sortedBy: unknown - showSoftClip: boolean -} - -// orientation definitions from igv.js, see also https://software.broadinstitute.org/software/igv/interpreting_pair_orientations -const orientationTypes = { - fr: { - F1R2: 'LR', - F2R1: 'LR', - - F1F2: 'LL', - F2F1: 'LL', - - R1R2: 'RR', - R2R1: 'RR', - - R1F2: 'RL', - R2F1: 'RL', - } as { [key: string]: string }, - - rf: { - R1F2: 'LR', - R2F1: 'LR', - - R1R2: 'LL', - R2R1: 'LL', - - F1F2: 'RR', - F2F1: 'RR', - - F1R2: 'RL', - F2R1: 'RL', - } as { [key: string]: string }, - - ff: { - F2F1: 'LR', - R1R2: 'LR', - - F2R1: 'LL', - R1F2: 'LL', - - R2F1: 'RR', - F1R2: 'RR', - - R2R1: 'RL', - F1F2: 'RL', - } as { [key: string]: string }, -} - const alignmentColoring: { [key: string]: string } = { color_fwd_strand_not_proper: '#ECC8C8', color_rev_strand_not_proper: '#BEBED8', @@ -140,42 +77,21 @@ const alignmentColoring: { [key: string]: string } = { color_shortinsert: 'pink', } -// Sorting and revealing soft clip changes the layout of Pileup renderer -// Adds extra conditions to see if cached layout is valid -class PileupLayoutSession extends LayoutSession { - sortedBy: unknown - - showSoftClip = false - - constructor(args: PileupLayoutSessionProps) { - super(args) - this.config = args.config - } +interface LayoutFeature { + heightPx: number + topPx: number + feature: Feature +} - cachedLayoutIsValid(cachedLayout: CachedPileupLayout) { - return ( - super.cachedLayoutIsValid(cachedLayout) && - this.showSoftClip === cachedLayout.showSoftClip && - deepEqual(this.sortedBy, cachedLayout.sortedBy) - ) +export default class PileupRenderer extends BoxRendererType { + // get width and height of chars the height is an approximation: width + // letter M is approximately the height + getCharWidthHeight(ctx: CanvasRenderingContext2D) { + const charWidth = ctx.measureText('A').width + const charHeight = ctx.measureText('M').width + return { charWidth, charHeight } } - cachedLayout: CachedPileupLayout | undefined - - get layout(): MyMultiLayout { - if (!this.cachedLayout || !this.cachedLayoutIsValid(this.cachedLayout)) { - this.cachedLayout = { - layout: this.makeLayout(), - config: readConfObject(this.config), - filters: this.filters, - sortedBy: this.sortedBy, - showSoftClip: this.showSoftClip, - } - } - return this.cachedLayout.layout - } -} -export default class PileupRenderer extends BoxRendererType { layoutFeature( feature: Feature, layout: BaseLayout, @@ -239,9 +155,9 @@ export default class PileupRenderer extends BoxRendererType { } } - // expands region for clipping to use - // In future when stats are improved, look for average read size in renderArg stats - // and set that as the maxClippingSize/expand region by average read size + // expands region for clipping to use. possible improvement: use average read + // size to set the heuristic maxClippingSize expansion (e.g. short reads + // don't have to expand a softclipping size a lot, but long reads might) getExpandedRegion(region: Region, renderArgs: RenderArgsAugmented) { const { config, showSoftClip } = renderArgs @@ -316,26 +232,100 @@ export default class PileupRenderer extends BoxRendererType { return strand === 1 ? 'color_fwd_strand' : 'color_rev_strand' } + colorByPerBaseQuality( + ctx: CanvasRenderingContext2D, + feat: LayoutFeature, + _config: AnyConfigurationModel, + region: Region, + bpPerPx: number, + ) { + const { feature, topPx, heightPx } = feat + const qual = feature.get('qual') as string + const scores = (qual || '').split(' ').map(val => +val) + const cigarOps = parseCigar(feature.get('CIGAR')) + const width = 1 / bpPerPx + const [leftPx] = bpSpanPx( + feature.get('start'), + feature.get('end'), + region, + bpPerPx, + ) + + for (let i = 0, j = 0, k = 0; k < scores.length; i += 2, k++) { + const len = +cigarOps[i] + const op = cigarOps[i + 1] + if (op === 'S' || op === 'I') { + k += len + } else if (op === 'D' || op === 'N') { + j += len + } else if (op === 'M' || op === 'X' || op === '=') { + for (let m = 0; m < len; m++) { + ctx.fillStyle = `hsl(${scores[k + m]},55%,50%)` + ctx.fillRect(leftPx + (j + m) * width, topPx, width + 0.5, heightPx) + } + j += len + } + } + } + drawRect( ctx: CanvasRenderingContext2D, - feat: { - heightPx: number - topPx: number - feature: Feature - }, + feat: LayoutFeature, + props: PileupRenderProps, + ) { + const { regions, bpPerPx } = props + const { heightPx, topPx, feature } = feat + const [region] = regions + const [leftPx, rightPx] = bpSpanPx( + feature.get('start'), + feature.get('end'), + region, + bpPerPx, + ) + const flip = region.reversed ? -1 : 1 + const strand = feature.get('strand') * flip + if (bpPerPx < 10) { + if (strand === -1) { + ctx.beginPath() + ctx.moveTo(leftPx - 5, topPx + heightPx / 2) + ctx.lineTo(leftPx, topPx + heightPx) + ctx.lineTo(rightPx, topPx + heightPx) + ctx.lineTo(rightPx, topPx) + ctx.lineTo(leftPx, topPx) + ctx.closePath() + ctx.fill() + } else { + ctx.beginPath() + ctx.moveTo(leftPx, topPx) + ctx.lineTo(leftPx, topPx + heightPx) + ctx.lineTo(rightPx, topPx + heightPx) + ctx.lineTo(rightPx + 5, topPx + heightPx / 2) + ctx.lineTo(rightPx, topPx) + ctx.closePath() + ctx.fill() + } + } else { + ctx.fillRect(leftPx, topPx, rightPx - leftPx, heightPx) + } + } + + drawAlignmentRect( + ctx: CanvasRenderingContext2D, + feat: LayoutFeature, props: PileupRenderProps, ) { const { config, bpPerPx, regions, - colorBy = { type: '' }, - colorTagMap, + colorBy: { tag = '', type: colorType = '' } = {}, + colorTagMap = {}, } = props - const { heightPx, topPx, feature } = feat - const { type: colorType } = colorBy + const { feature } = feat const region = regions[0] + // first pass for simple color changes that change the color of the + // alignment switch (colorType) { case 'insertSize': ctx.fillStyle = this.colorByInsertSize(feature, config) @@ -346,6 +336,7 @@ export default class PileupRenderer extends BoxRendererType { case 'mappingQuality': ctx.fillStyle = `hsl(${feature.get('mq')},50%,50%)` break + case 'pairOrientation': ctx.fillStyle = this.colorByOrientation(feature, config) break @@ -354,7 +345,6 @@ export default class PileupRenderer extends BoxRendererType { break case 'xs': case 'tag': { - const tag = colorBy.tag as string const tags = feature.get('tags') const val = tags ? tags[tag] : feature.get(tag) @@ -382,7 +372,8 @@ export default class PileupRenderer extends BoxRendererType { ctx.fillStyle = alignmentColoring[map[val] || 'color_nostrand'] } - // tag is not one of the autofilled tags, has color-value pairs from fetchValues + // tag is not one of the autofilled tags, has color-value pairs from + // fetchValues else { const foundValue = colorTagMap[val] ctx.fillStyle = foundValue || 'color_nostrand' @@ -391,50 +382,27 @@ export default class PileupRenderer extends BoxRendererType { } case 'insertSizeAndPairOrientation': break + case 'normal': default: ctx.fillStyle = readConfObject(config, 'color', [feature]) break } - const [leftPx, rightPx] = bpSpanPx( - feature.get('start'), - feature.get('end'), - region, - bpPerPx, - ) - const flip = region.reversed ? -1 : 1 - const strand = feature.get('strand') * flip - if (strand === -1) { - ctx.beginPath() - ctx.moveTo(leftPx - 5, topPx + heightPx / 2) - ctx.lineTo(leftPx, topPx + heightPx) - ctx.lineTo(rightPx, topPx + heightPx) - ctx.lineTo(rightPx, topPx) - ctx.lineTo(leftPx, topPx) - ctx.closePath() - ctx.fill() - } else { - ctx.beginPath() - ctx.moveTo(leftPx, topPx) - ctx.lineTo(leftPx, topPx + heightPx) - ctx.lineTo(rightPx, topPx + heightPx) - ctx.lineTo(rightPx + 5, topPx + heightPx / 2) - ctx.lineTo(rightPx, topPx) - ctx.closePath() - ctx.fill() + this.drawRect(ctx, feat, props) + + // second pass for color types that render per-base things that go over the + // existing drawing + switch (colorType) { + case 'perBaseQuality': + this.colorByPerBaseQuality(ctx, feat, config, region, bpPerPx) + break } - // ctx.fillRect(leftPx, topPx, Math.max(rightPx - leftPx, 1.5), heightPx) } drawMismatches( ctx: CanvasRenderingContext2D, - feat: { - heightPx: number - topPx: number - feature: Feature - }, - mismatches: Mismatch[], + feat: LayoutFeature, props: PileupRenderProps, colorForBase: { [key: string]: string }, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -443,19 +411,20 @@ export default class PileupRenderer extends BoxRendererType { const { config, bpPerPx, regions } = props const [region] = regions const { heightPx, topPx, feature } = feat + const start = feature.get('start') const minFeatWidth = readConfObject(config, 'minSubfeatureWidth') - const charWidth = ctx.measureText('A').width - const charHeight = ctx.measureText('M').width + const { charWidth, charHeight } = this.getCharWidthHeight(ctx) const pxPerBp = Math.min(1 / bpPerPx, 2) const w = Math.max(minFeatWidth, pxPerBp) + const mismatches: Mismatch[] = feature.get('mismatches') // two pass rendering: first pass, draw all the mismatches except wide // insertion markers for (let i = 0; i < mismatches.length; i += 1) { const mismatch = mismatches[i] const [mismatchLeftPx, mismatchRightPx] = bpSpanPx( - feature.get('start') + mismatch.start, - feature.get('start') + mismatch.start + mismatch.length, + start + mismatch.start, + start + mismatch.start + mismatch.length, region, bpPerPx, ) @@ -473,6 +442,7 @@ export default class PileupRenderer extends BoxRendererType { ctx.fillRect(mismatchLeftPx, topPx, mismatchWidthPx, heightPx) if (mismatchWidthPx >= charWidth && heightPx >= charHeight - 5) { + // normal SNP coloring ctx.fillStyle = theme.palette.getContrastText(baseColor) ctx.fillText( mismatch.base, @@ -550,6 +520,76 @@ export default class PileupRenderer extends BoxRendererType { } } + drawSoftClipping( + ctx: CanvasRenderingContext2D, + feat: LayoutFeature, + props: PileupRenderProps, + config: AnyConfigurationModel, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + theme: any, + ) { + const { feature, topPx, heightPx } = feat + const { regions, bpPerPx } = props + const [region] = regions + const minFeatWidth = readConfObject(config, 'minSubfeatureWidth') + const mismatches: Mismatch[] = feature.get('mismatches') + const seq = feature.get('seq') + const { charWidth, charHeight } = this.getCharWidthHeight(ctx) + const colorForBase: { [key: string]: string } = { + A: theme.palette.bases.A.main, + C: theme.palette.bases.C.main, + G: theme.palette.bases.G.main, + T: theme.palette.bases.T.main, + deletion: '#808080', // gray + } + + // Display all bases softclipped off in lightened colors + if (seq) { + mismatches + .filter(mismatch => mismatch.type === 'softclip') + .forEach(mismatch => { + const softClipLength = mismatch.cliplen || 0 + const softClipStart = + mismatch.start === 0 + ? feature.get('start') - softClipLength + : feature.get('start') + mismatch.start + + for (let k = 0; k < softClipLength; k += 1) { + const base = seq.charAt(k + mismatch.start) + + // If softclip length+start is longer than sequence, no need to continue showing base + if (!base) return + + const [softClipLeftPx, softClipRightPx] = bpSpanPx( + softClipStart + k, + softClipStart + k + 1, + region, + bpPerPx, + ) + const softClipWidthPx = Math.max( + minFeatWidth, + Math.abs(softClipLeftPx - softClipRightPx), + ) + + // Black accounts for IUPAC ambiguity code bases such as N that + // show in soft clipping + const baseColor = colorForBase[base] || '#000000' + ctx.fillStyle = baseColor + ctx.fillRect(softClipLeftPx, topPx, softClipWidthPx, heightPx) + + if (softClipWidthPx >= charWidth && heightPx >= charHeight - 5) { + ctx.fillStyle = theme.palette.getContrastText(baseColor) + ctx.fillText( + base, + softClipLeftPx + (softClipWidthPx - charWidth) / 2 + 1, + topPx + heightPx, + ) + } + } + }) + } + } + async makeImageData(props: PileupRenderProps) { const { features, @@ -577,7 +617,6 @@ export default class PileupRenderer extends BoxRendererType { if (!layout.addRect) { throw new Error('invalid layout object') } - const minFeatWidth = readConfObject(config, 'minSubfeatureWidth') const sortedFeatures = sortedBy && sortedBy.type && region.start === sortedBy.pos @@ -608,8 +647,6 @@ export default class PileupRenderer extends BoxRendererType { const ctx = canvas.getContext('2d') ctx.scale(highResolutionScaling, highResolutionScaling) ctx.font = 'bold 10px Courier New,monospace' - const charWidth = ctx.measureText('A').width - const charHeight = ctx.measureText('M').width layoutRecords.forEach(feat => { if (feat === null) { return @@ -618,60 +655,10 @@ export default class PileupRenderer extends BoxRendererType { const { feature, topPx, heightPx } = feat ctx.fillStyle = readConfObject(config, 'color', [feature]) - this.drawRect(ctx, { feature, topPx, heightPx }, props) - const mismatches: Mismatch[] = feature.get('mismatches') - const seq = feature.get('seq') - - if (mismatches) { - this.drawMismatches(ctx, feat, mismatches, props, colorForBase, theme) - // Display all bases softclipped off in lightened colors - if (showSoftClip && seq) { - mismatches - .filter(mismatch => mismatch.type === 'softclip') - .forEach(mismatch => { - const softClipLength = mismatch.cliplen || 0 - const softClipStart = - mismatch.start === 0 - ? feature.get('start') - softClipLength - : feature.get('start') + mismatch.start - - for (let k = 0; k < softClipLength; k += 1) { - const base = seq.charAt(k + mismatch.start) - - // If softclip length+start is longer than sequence, no need to continue showing base - if (!base) return - - const [softClipLeftPx, softClipRightPx] = bpSpanPx( - softClipStart + k, - softClipStart + k + 1, - region, - bpPerPx, - ) - const softClipWidthPx = Math.max( - minFeatWidth, - Math.abs(softClipLeftPx - softClipRightPx), - ) - - // Black accounts for IUPAC ambiguity code bases such as N that - // show in soft clipping - const baseColor = colorForBase[base] || '#000000' - ctx.fillStyle = baseColor - ctx.fillRect(softClipLeftPx, topPx, softClipWidthPx, heightPx) - - if ( - softClipWidthPx >= charWidth && - heightPx >= charHeight - 5 - ) { - ctx.fillStyle = theme.palette.getContrastText(baseColor) - ctx.fillText( - base, - softClipLeftPx + (softClipWidthPx - charWidth) / 2 + 1, - topPx + heightPx, - ) - } - } - }) - } + this.drawAlignmentRect(ctx, { feature, topPx, heightPx }, props) + this.drawMismatches(ctx, feat, props, colorForBase, theme) + if (showSoftClip) { + this.drawSoftClipping(ctx, feat, props, config, theme) } }) diff --git a/plugins/alignments/src/PileupRenderer/util.ts b/plugins/alignments/src/PileupRenderer/util.ts new file mode 100644 index 0000000000..b0ffb722f3 --- /dev/null +++ b/plugins/alignments/src/PileupRenderer/util.ts @@ -0,0 +1,45 @@ +// orientation definitions from igv.js, see also +// https://software.broadinstitute.org/software/igv/interpreting_pair_orientations +export const orientationTypes = { + fr: { + F1R2: 'LR', + F2R1: 'LR', + + F1F2: 'LL', + F2F1: 'LL', + + R1R2: 'RR', + R2R1: 'RR', + + R1F2: 'RL', + R2F1: 'RL', + } as { [key: string]: string }, + + rf: { + R1F2: 'LR', + R2F1: 'LR', + + R1R2: 'LL', + R2R1: 'LL', + + F1F2: 'RR', + F2F1: 'RR', + + F1R2: 'RL', + F2R1: 'RL', + } as { [key: string]: string }, + + ff: { + F2F1: 'LR', + R1R2: 'LR', + + F2R1: 'LL', + R1F2: 'LL', + + R2F1: 'RR', + F1R2: 'RR', + + R2R1: 'RL', + F1F2: 'RL', + } as { [key: string]: string }, +}