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 renderedTranscript = (
<g className={transcriptClasses}>
<Backbone {...props} scale={scale} />
{spliced_exons.map((spliced_exon, index) => (
const exonRectangles = calculateExonRectangles({ spliced_exons, cds, scale });
const renderedExons = exonRectangles.map((tuple, index) => (
<ExonBlock
key={index}
spliced_exon={spliced_exon}
cds={cds}
exonRectangles={tuple}
className={props.classNames?.exon}
scale={scale}
/>
))}
));
const renderedTranscript = (
<g className={transcriptClasses}>
<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 }
type BackboneProps = {
exonRectangles: ReturnType<typeof calculateExonRectangles>;
className?: string;
};
const Backbone = (props: BackboneProps) => {
const { exonRectangles, className } = props;
if (exonRectangles.length < 2) {
return null;
}
},
scale
} = props;
const backboneClasses = props.classNames?.backbone || undefined;
if (!spliced_exons.length) {
return (
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]);
segments.push(segment);
}
if (i === spliced_exons.length - 1) {
intervals.push([exonEnd, transcriptLength]);
}
}
return <g>{segments}</g>;
};
type ExonBlockProps = {
exonRectangles: ReturnType<typeof calculateExonRectangles>[number];
className?: string;
};
intervals = intervals.filter((interval) => interval[0] !== interval[1]);
const ExonBlock = (props: ExonBlockProps) => {
const y = -3;
const height = BLOCK_HEIGHT;
const rectElements = props.exonRectangles.map((exonRect, index) => {
const { filled, x, width } = exonRect;
const rectClasses =
classNames(props.className, {
[styles.emptyBlock]: !filled
}) || undefined;
// 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)}
key={index}
data-test-id={props.exonRectangles.length === 1 ? 'exon' : undefined}
className={rectClasses}
x={x}
y={y}
width={width}
height={height}
/>
))}
</g>
);
});
if (props.exonRectangles.length > 1) {
return <g data-test-id="exon">{rectElements}</g>;
} else {
return <>{rectElements}</>;
}
};
type ExonBlockProps = {
spliced_exon: Pick<SplicedExon, 'relative_location'>;
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;
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 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;
const y = -3;
const height = BLOCK_HEIGHT;
const exonClasses = props.className;
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>
);
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 codingPart = (
<rect
className={exonClasses}
y={y}
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>
);
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 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))}
/>
);
const rect = {
filled: !isCompletelyNonCoding,
x: scale(exonStart),
width: scale(getLength(exonStart, exonEnd))
};
return [rect];
}
});
};
const getLength = (start: number, end: number) => end - start + 1;
// 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