Unverified Commit 1ffa721b authored by Andrey Azov's avatar Andrey Azov Committed by GitHub
Browse files

Update logic in UnsplicedTranscript.tsx (#565)



- Make sure that the segments connecting the exon boxes are always drawn correctly
   by deriving the coordinates and the widths of the connecting segments from the precalculated coordinates
   and widths of the exon boxes
- Make sure that the right border of the last exon box does not extend beyond the total transcript width
   by using a custom interpolation function that always interpolates to the smallest integer
   rather than to the nearest one.
Co-authored-by: Imran Salam's avatarImran Salam <imran@ebi.ac.uk>
parent 505f97e9
Pipeline #188354 passed with stages
in 4 minutes and 31 seconds
......@@ -84,6 +84,7 @@ const QUERY = gql`
location {
start
end
length
}
strand {
code
......
......@@ -18,7 +18,7 @@ import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { Pick2, Pick3 } from 'ts-multipick';
import { getFeatureCoordinates } from 'src/content/app/entity-viewer/shared/helpers/entity-helpers';
import { getFeatureLength } from 'src/content/app/entity-viewer/shared/helpers/entity-helpers';
import { getTranscriptSortingFunction } from 'src/content/app/entity-viewer/shared/helpers/transcripts-sorter';
import { filterTranscripts } from 'src/content/app/entity-viewer/shared/helpers/transcripts-filter';
......@@ -59,7 +59,7 @@ type Transcript = DefaultTranscriptListItemProps['transcript'] & {
type Gene = DefaultTranscriptListItemProps['gene'] & {
stable_id: FullGene['stable_id'];
transcripts: Array<Transcript>;
slice: Pick2<Slice, 'location', 'start' | 'end'>;
slice: Pick2<Slice, 'location', 'length'>;
};
export type Props = {
......@@ -134,8 +134,7 @@ const DefaultTranscriptslist = (props: Props) => {
const StripedBackground = (props: Props) => {
const { scale, ticks } = props.rulerTicks;
const { start: geneStart, end: geneEnd } = getFeatureCoordinates(props.gene);
const geneLength = geneEnd - geneStart; // FIXME should use gene length property
const geneLength = getFeatureLength(props.gene);
const extendedTicks = [1, ...ticks, geneLength];
const stripes = extendedTicks.map((tick) => {
......
......@@ -18,7 +18,10 @@ import React from 'react';
import { scaleLinear } from 'd3';
import { Pick3 } from 'ts-multipick';
import { getFeatureCoordinates } from 'src/content/app/entity-viewer/shared/helpers/entity-helpers';
import {
getFeatureCoordinates,
getFeatureLength
} from 'src/content/app/entity-viewer/shared/helpers/entity-helpers';
import { getStrandDisplayName } from 'src/shared/helpers/formatters/strandFormatter';
import { pluralise } from 'src/shared/helpers/formatters/pluralisationFormatter';
......@@ -35,11 +38,11 @@ import styles from './GeneOverviewImage.scss';
import settings from 'src/content/app/entity-viewer/gene-view/styles/_constants.scss';
type Gene = Pick<FullGene, 'stable_id'> &
Pick3<FullGene, 'slice', 'location', 'start' | 'end'> &
Pick3<FullGene, 'slice', 'location', 'start' | 'end' | 'length'> &
Pick3<FullGene, 'slice', 'strand', 'code'> & {
transcripts: Array<
UnsplicedTranscriptProps['transcript'] &
Pick3<FullTranscript, 'slice', 'location', 'start' | 'end'>
Pick3<FullTranscript, 'slice', 'location', 'start' | 'end' | 'length'>
>;
};
......@@ -51,8 +54,7 @@ export type GeneOverviewImageProps = {
const gene_image_width = Number(settings.gene_image_width);
const GeneOverviewImage = (props: GeneOverviewImageProps) => {
const { start: geneStart, end: geneEnd } = getFeatureCoordinates(props.gene); // FIXME: use gene length further on
const length = geneEnd - geneStart;
const length = getFeatureLength(props.gene);
return (
<div className={styles.container}>
......@@ -84,12 +86,10 @@ export const GeneImage = (props: GeneOverviewImageProps) => {
const renderedTranscripts = props.gene.transcripts.map(
(transcript, index) => {
const {
start: transcriptStart,
end: transcriptEnd
} = getFeatureCoordinates(transcript);
const startX = scale(transcriptStart) as number;
const endX = scale(transcriptEnd) as number;
const { start: transcriptStart, end: transcriptEnd } =
getFeatureCoordinates(transcript);
const startX = scale(transcriptStart);
const endX = scale(transcriptEnd);
const y = 10;
const width = Math.floor(endX - startX);
return (
......@@ -126,7 +126,6 @@ const DirectionIndicator = () => {
);
};
// FIXME translating response into display name (forward strand, reverse strand) should be a shared function
const StrandIndicator = (props: GeneOverviewImageProps) => {
const {
gene: {
......
......@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React from 'react';
import React, { ReactNode } from 'react';
import classNames from 'classnames';
import { scaleLinear, ScaleLinear } from 'd3';
import { Pick3 } from 'ts-multipick';
......@@ -54,34 +54,37 @@ It's possible that later we will switch to using UTRs for this purpose
*/
const UnsplicedTranscript = (props: UnsplicedTranscriptProps) => {
const {
spliced_exons,
product_generating_contexts,
slice
} = props.transcript;
const { spliced_exons, product_generating_contexts, slice } =
props.transcript;
const cds = product_generating_contexts[0]?.cds;
const {
location: { length: transcriptLength }
} = slice;
const scale = scaleLinear()
.domain([1, transcriptLength])
.rangeRound([1, props.width])
.range([1, props.width])
.interpolate(interpolateFloor)
.clamp(true);
const transcriptClasses = props.classNames?.transcript;
const exonRectangles = calculateExonRectangles({ spliced_exons, cds, scale });
const renderedExons = exonRectangles.map((tuple, index) => (
<ExonBlock
key={index}
exonRectangles={tuple}
className={props.classNames?.exon}
/>
));
const renderedTranscript = (
<g className={transcriptClasses}>
<Backbone {...props} scale={scale} />
{spliced_exons.map((spliced_exon, index) => (
<ExonBlock
key={index}
spliced_exon={spliced_exon}
cds={cds}
className={props.classNames?.exon}
scale={scale}
/>
))}
<Backbone
exonRectangles={exonRectangles}
className={props.classNames?.backbone}
/>
{renderedExons}
</g>
);
......@@ -103,169 +106,156 @@ UnsplicedTranscript.defaultProps = {
standalone: false
};
const Backbone = (
props: UnsplicedTranscriptProps & { scale: ScaleLinear<number, number> }
) => {
let intervals: [number, number][] = [];
const {
transcript: {
spliced_exons,
slice: {
location: { length: transcriptLength }
}
},
scale
} = props;
const backboneClasses = props.classNames?.backbone || undefined;
if (!spliced_exons.length) {
return (
type BackboneProps = {
exonRectangles: ReturnType<typeof calculateExonRectangles>;
className?: string;
};
const Backbone = (props: BackboneProps) => {
const { exonRectangles, className } = props;
if (exonRectangles.length < 2) {
return null;
}
const segments: ReactNode[] = [];
for (let i = 0; i < exonRectangles.length - 1; i++) {
/*
exonRectangles is an array of tuples, in which each tuple contains one or two elements:
is can be either a fully coding (or fully non-coding) rectangle,
or a coding and a non-coding rectangle next to each other
*/
const currentRectangle = exonRectangles[i]
.slice(-1)
.pop() as BackboneProps['exonRectangles'][number][number];
const nextRectangle = exonRectangles[i + 1]
.slice(0, 1)
.pop() as BackboneProps['exonRectangles'][number][number];
const currentRectangleEnd = currentRectangle.x + currentRectangle.width;
const nextRectangleStart = nextRectangle.x;
const segmentWidth = nextRectangleStart - currentRectangleEnd - 1;
if (segmentWidth < 1) {
continue;
}
const segment = (
<rect
className={backboneClasses}
key={currentRectangleEnd}
className={className}
y={0}
height={1}
x={0}
width={props.width}
x={currentRectangleEnd + 1}
width={segmentWidth}
/>
);
}
for (let i = 0; i < spliced_exons.length; i++) {
const {
relative_location: { start: exonStart, end: exonEnd }
} = spliced_exons[i];
if (i === 0) {
intervals.push([1, exonStart]);
} else {
const previousExon = spliced_exons[i - 1];
const {
relative_location: { end: previousExonEnd }
} = previousExon;
intervals.push([previousExonEnd, exonStart]);
}
if (i === spliced_exons.length - 1) {
intervals.push([exonEnd, transcriptLength]);
}
segments.push(segment);
}
intervals = intervals.filter((interval) => interval[0] !== interval[1]);
// NOTE: the intervals, from which backbone segments are rendered below, have been calculated from the start and the end coordinates of exons.
// This means that the right and left borders of exon boxes will have the same coordinates as the right and left edges of each backbone segment.
// In order to prevent backbone segments from invading exon boxes (which produces tiny bumps on both sides of empty boxes),
// we are adjusting the x-coordinate and the width of every backbone segment by moving it 1 point to the right and subtracting 2 points from its width.
return (
<g>
{intervals.map(([start, end]) => (
<rect
key={start}
className={backboneClasses}
y={0}
height={1}
x={scale(start)}
width={Math.max(0, (scale(end - start) as number) - 2)}
/>
))}
</g>
);
return <g>{segments}</g>;
};
type ExonBlockProps = {
spliced_exon: Pick<SplicedExon, 'relative_location'>;
cds?: {
relative_start: number;
relative_end: number;
} | null;
exonRectangles: ReturnType<typeof calculateExonRectangles>[number];
className?: string;
scale: ScaleLinear<number, number>;
};
const ExonBlock = (props: ExonBlockProps) => {
const { spliced_exon, cds } = props;
const { start: exonStart, end: exonEnd } = spliced_exon.relative_location;
const isCompletelyNonCoding =
cds && (exonEnd < cds.relative_start || exonStart > cds.relative_end);
const isNonCodingLeft =
cds && exonStart < cds.relative_start && exonEnd > cds.relative_start;
const isNonCodingRight =
cds && exonStart < cds.relative_end && exonEnd > cds.relative_end;
const y = -3;
const height = BLOCK_HEIGHT;
const exonClasses = props.className;
const rectElements = props.exonRectangles.map((exonRect, index) => {
const { filled, x, width } = exonRect;
const rectClasses =
classNames(props.className, {
[styles.emptyBlock]: !filled
}) || undefined;
if (cds && isNonCodingLeft) {
const nonCodingPart = (
<rect
className={classNames(exonClasses, styles.emptyBlock) || undefined}
y={y}
height={height}
x={props.scale(exonStart)}
width={props.scale(getLength(exonStart, cds.relative_start))}
/>
);
const codingPart = (
<rect
className={exonClasses}
y={y}
height={height}
x={props.scale(cds.relative_start)}
width={props.scale(getLength(cds.relative_start, exonEnd))}
/>
);
return (
<g key={exonStart} data-test-id="exon">
{nonCodingPart}
{codingPart}
</g>
);
} else if (cds && isNonCodingRight) {
const codingPart = (
<rect
className={exonClasses}
key={index}
data-test-id={props.exonRectangles.length === 1 ? 'exon' : undefined}
className={rectClasses}
x={x}
y={y}
width={width}
height={height}
x={props.scale(exonStart)}
width={props.scale(getLength(exonStart, cds.relative_end))}
/>
);
const nonCodingPart = (
<rect
className={classNames(exonClasses, styles.emptyBlock) || undefined}
y={y}
height={height}
x={props.scale(cds.relative_end)}
width={props.scale(getLength(cds.relative_end, exonEnd))}
/>
);
});
return (
<g key={exonStart} data-test-id="exon">
{codingPart}
{nonCodingPart}
</g>
);
if (props.exonRectangles.length > 1) {
return <g data-test-id="exon">{rectElements}</g>;
} else {
const classes =
classNames(exonClasses, {
[styles.emptyBlock]: isCompletelyNonCoding
}) || undefined;
return (
<rect
key={exonStart}
data-test-id="exon"
className={classes}
y={y}
height={height}
x={props.scale(exonStart)}
width={props.scale(getLength(exonStart, exonEnd))}
/>
);
return <>{rectElements}</>;
}
};
const getLength = (start: number, end: number) => end - start + 1;
type CalculateExonRectanglesParams = {
spliced_exons: Pick<SplicedExon, 'relative_location'>[];
cds?: {
relative_start: number;
relative_end: number;
} | null;
scale: ScaleLinear<number, number>;
};
const calculateExonRectangles = (params: CalculateExonRectanglesParams) => {
const { spliced_exons, cds, scale } = params;
return spliced_exons.map((exon) => {
const { start: exonStart, end: exonEnd } = exon.relative_location;
const isCompletelyNonCoding =
cds && (exonEnd < cds.relative_start || exonStart > cds.relative_end);
const isNonCodingLeft =
cds && exonStart < cds.relative_start && exonEnd > cds.relative_start;
const isNonCodingRight =
cds && exonStart < cds.relative_end && exonEnd > cds.relative_end;
if (cds && isNonCodingLeft) {
const nonCodingRect = {
filled: false,
x: scale(exonStart),
width: scale(getLength(exonStart, cds.relative_start))
};
const codingRect = {
filled: true,
x: scale(cds.relative_start),
width: scale(getLength(cds.relative_start, exonEnd))
};
return [nonCodingRect, codingRect];
} else if (cds && isNonCodingRight) {
const codingRect = {
filled: true,
x: scale(exonStart),
width: scale(getLength(exonStart, cds.relative_end))
};
const nonCodingRect = {
filled: false,
x: scale(cds.relative_end),
width: scale(getLength(cds.relative_end, exonEnd))
};
return [codingRect, nonCodingRect];
} else {
const rect = {
filled: !isCompletelyNonCoding,
x: scale(exonStart),
width: scale(getLength(exonStart, exonEnd))
};
return [rect];
}
});
};
// d3 has an interpolator for rounding numbers to the neares integer (https://github.com/d3/d3-interpolate/blob/main/src/round.js)
// but does not seem to have an interpolator for always using the smallest integer
// so here's the custom one
const interpolateFloor = (a: number, b: number) => (t: number) =>
Math.floor(a * (1 - t) + b * t);
export default UnsplicedTranscript;
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment