Unverified Commit 7a328d5e authored by Andrey Azov's avatar Andrey Azov Committed by GitHub
Browse files

Add BasePairsRuler (#244)

This commit introduces d3 into our codebase
parent f2a375ea
Pipeline #59420 passed with stages
in 5 minutes and 42 seconds
This diff is collapsed.
......@@ -54,6 +54,7 @@
"classnames": "2.2.6",
"connected-react-router": "6.6.1",
"core-js": "3.6.4",
"d3": "5.15.0",
"dotenv": "8.2.0",
"ensembl-genome-browser": "https://raw.githubusercontent.com/Ensembl/ensembl-genome-browser-assets/master/assets-80f51620ed443c640cdfd6b5aebd505b.tar.gz",
"koa-proxy": "1.0.0-alpha.3",
......@@ -91,6 +92,7 @@
"@storybook/react": "5.3.6",
"@svgr/webpack": "5.0.1",
"@types/classnames": "2.2.9",
"@types/d3": "5.7.2",
"@types/enzyme": "3.10.4",
"@types/enzyme-adapter-react-16": "1.0.5",
"@types/faker": "4.1.9",
......
@import 'src/styles/common';
.containerSvg {
overflow: visible;
}
.axis,
.tick {
fill: $orange;
}
.label {
fill: white;
font-size: 10px;
font-family: $font-family-monospace;
}
import React from 'react';
import { mount, render } from 'enzyme';
import BasePairsRuler from './BasePairsRuler';
const defaultProps = {
length: 80792,
width: 600
};
describe('<BasePairsRuler />', () => {
describe('rendering', () => {
it('renders inside an <svg> element if standalone', () => {
const wrapper = render(
<BasePairsRuler {...defaultProps} standalone={true} />
);
expect(wrapper.is('svg')).toBe(true);
});
it('renders inside a <g> element (svg group) if not standalone', () => {
const wrapper = render(<BasePairsRuler {...defaultProps} />);
expect(wrapper.is('g')).toBe(true);
});
});
describe('behaviour', () => {
const props = {
...defaultProps,
standalone: true
};
it('passes calculated ticks to the callback', () => {
const callback = jest.fn();
mount(<BasePairsRuler {...props} onTicksCalculated={callback} />);
expect(callback).toHaveBeenCalledTimes(1);
const payload = callback.mock.calls[0][0];
expect(payload.ticks).toBeDefined();
expect(payload.labelledTicks).toBeDefined();
});
});
});
/*
This component is a ruler for displaying alongside visualisation of a nucleic acid
It follows the following rules for displaying labelled and unlabelled ticks
1. The ruler starts at 1 and ends at the length of the feature.
Both the start and the end positions of the ruler are labelled.
2. Apart from the start and the end positions, there should be at least one label, but no greater than 5 labels
3. There may also be some unlabelled ticks. The total number of ticks (both labelled and unlabelled)
between the start and the end positions should not be greater than 10.
4. Last tick cannot be labelled if it is at a less than 10% distance from the end of the ruler
5. Ticks can be either:
a) multiple of the same power of 10 as the length of the feature, or
b) half of this power of 10
*/
import React, { useEffect } from 'react';
import { scaleLinear } from 'd3';
import { getTicks } from './basePairsRulerHelper';
import { getCommaSeparatedNumber } from 'src/shared/helpers/numberFormatter';
import styles from './BasePairsRuler.scss';
type Ticks = {
ticks: number[];
labelledTicks: number[];
};
type Props = {
length: number; // number of biological building blocks (e.g. nucleotides) in the feature
width: number; // number of pixels allotted to the axis on the screen
onTicksCalculated?: (ticks: Ticks) => void; // way to pass the ticks to the parent if it is interested in them
standalone: boolean; // wrap the component in an svg element if true
};
const FeatureLengthAxis = (props: Props) => {
const domain = [1, props.length];
const range = [0, props.width];
const scale = scaleLinear()
.domain(domain)
.range(range);
const { ticks, labelledTicks } = getTicks(scale);
useEffect(() => {
if (props.onTicksCalculated) {
props.onTicksCalculated({ ticks, labelledTicks });
}
}, [props.length]);
const renderedAxis = (
<g>
<rect
className={styles.axis}
x={0}
y={0}
width={props.width}
height={1}
/>
<g>
<rect className={styles.tick} width={1} height={6} />
<text className={styles.label} x={0} y={20} textAnchor="end">
bp 1
</text>
</g>
{ticks.map((tick) => (
<g key={tick} transform={`translate(${scale(tick)})`}>
<rect className={styles.tick} width={1} height={6} />
{labelledTicks.includes(tick) && (
<text className={styles.label} x={0} y={20} textAnchor="middle">
{getCommaSeparatedNumber(tick)}
</text>
)}
</g>
))}
<text
className={styles.label}
x={0}
y={20}
textAnchor="start"
transform={`translate(${scale(props.length)})`}
>
{getCommaSeparatedNumber(props.length)}
</text>
</g>
);
return props.standalone ? (
<svg className={styles.containerSvg} width={props.width}>
{renderedAxis}
</svg>
) : (
renderedAxis
);
};
FeatureLengthAxis.defaultProps = {
standalone: false
};
export default FeatureLengthAxis;
import { scaleLinear } from 'd3';
import { getTicks } from './basePairsRulerHelper';
type Example = {
length: number;
ticks: number[];
labelledTicks: number[];
};
// concrete test cases (because it's hard to come up with a random number generator for this)
const examples: Example[] = [
{
length: 5,
ticks: [2, 3, 4],
labelledTicks: [2, 3, 4]
},
{
length: 7,
ticks: [2, 3, 4, 5, 6],
labelledTicks: [2, 3, 4, 5, 6] // 5 intermediate labels at most
},
{
length: 8,
ticks: [2, 3, 4, 5, 6, 7],
labelledTicks: [5]
},
{
length: 100, // edge case: length is equal to the power of ten that we use to filter the ticks
ticks: [10, 20, 30, 40, 50, 60, 70, 80, 90],
labelledTicks: [50]
},
{
length: 101, // behaves the same as the previous case
ticks: [10, 20, 30, 40, 50, 60, 70, 80, 90],
labelledTicks: [50]
},
{
length: 103, // as previous case, but includes the last tick
ticks: [10, 20, 30, 40, 50, 60, 70, 80, 90, 100], // notice the last tick is the same as the power of ten
labelledTicks: [50]
},
{
length: 593,
ticks: [100, 200, 300, 400, 500],
labelledTicks: [100, 200, 300, 400, 500] // 500 is included in labelled ticks, because it's at more than 10% distance from 593
},
{
length: 679,
ticks: [100, 200, 300, 400, 500, 600],
labelledTicks: [500] // can't have more than 5 labels
},
{
length: 1160,
ticks: [1000],
labelledTicks: [1000]
},
{
length: 3921,
ticks: [1000, 2000, 3000],
labelledTicks: [1000, 2000, 3000]
},
{
length: 5367,
ticks: [1000, 2000, 3000, 4000, 5000],
labelledTicks: [1000, 2000, 3000, 4000] // notice that the last tick is not included in labelled ticks (less than 10% distance from length)
},
{
length: 25623,
ticks: [10000, 20000],
labelledTicks: [10000, 20000]
},
{
length: 84792,
ticks: [10000, 20000, 30000, 40000, 50000, 60000, 70000, 80000],
labelledTicks: [50000]
},
{
length: 304813,
ticks: [100000, 200000, 300000],
labelledTicks: [100000, 200000]
},
{
length: 304813,
ticks: [100000, 200000, 300000],
labelledTicks: [100000, 200000]
},
{
length: 2486000,
ticks: [1000000, 2000000],
labelledTicks: [1000000, 2000000]
}
];
describe('featureLengthAxisHelper', () => {
describe('getTicks', () => {
const width = 600;
const generateScale = (length: number) =>
scaleLinear()
.domain([1, length])
.range([0, width]);
it('produces expected labelled ticks', () => {
for (const example of examples) {
const scale = generateScale(example.length);
const { ticks, labelledTicks } = getTicks(scale);
expect(ticks).toEqual(example.ticks);
expect(labelledTicks).toEqual(example.labelledTicks);
}
});
});
});
import { ScaleLinear } from 'd3';
export const getTicks = (scale: ScaleLinear<number, number>) => {
// use d3 scale to get 'approximately' 10 ticks (exact number not guaranteed)
// which are "human-readable" (i.e. are multiples of powers of 10)
// and are guaranteed to fall within the scale's domain
let ticks = scale.ticks();
const length = scale.domain()[1]; // get back the initial length value on which the scale is based
const step = ticks[1] - ticks[0];
// choose only the "important" ticks for labelling
const exponent = Math.floor(Math.log10(length));
const powerOfTen = 10 ** exponent; // e.g. 100, 1000, 10000, etc.
if (length >= powerOfTen && length < powerOfTen + step) {
return handleLengthAsPowerOfTen(ticks, powerOfTen, step, length);
}
ticks = ticks.filter((tick) => {
// do not add a tick if it is the beginning of the ruler (position 1)
// or in the end of the ruler (tick == length), because these cases are handled separately;
// and throw away all the possible 'inelegant' intermediate ticks, such as 50, etc.
return tick !== 1 && tick !== length && tick % powerOfTen === 0;
});
let labelledTicks = getLabelledTicks(ticks, powerOfTen, length);
if (!labelledTicks.length) {
// let's have at least one label, roughly in the middle of the ruler
const halvedPowerOfTen = powerOfTen / 2;
ticks = [...ticks, halvedPowerOfTen].sort();
labelledTicks = [halvedPowerOfTen];
}
return {
ticks,
labelledTicks
};
};
const handleLengthAsPowerOfTen = (
ticks: number[],
powerOfTen: number,
step: number,
totalLength: number
) => {
ticks = ticks.filter((tick, index) => {
if (index === ticks.length - 1) {
return totalLength - tick > step * 0.2; // show last tick if it's more that 20% of step length removed from end of ruler
}
return true;
});
return {
ticks,
labelledTicks: [powerOfTen / 2]
};
};
const getLabelledTicks = (
ticks: number[],
powerOfTen: number,
totalLength: number
) => {
let filterForLabels = buildFilterForLabels(powerOfTen, totalLength);
let labelledTicks = ticks.filter(filterForLabels);
if (labelledTicks.length > 5) {
// that's too many labels; let's use the half of the next power of ten for labelling
const nextPowerOfTen = powerOfTen * 10;
const halvedNextPowerOfTen = nextPowerOfTen / 2;
filterForLabels = buildFilterForLabels(halvedNextPowerOfTen, totalLength);
const newLabelledTicks = ticks.filter(filterForLabels);
if (newLabelledTicks.length < 5) {
labelledTicks = newLabelledTicks;
}
}
return labelledTicks;
};
const buildFilterForLabels = (powerOfTen: number, totalLength: number) => (
tick: number,
index: number,
ticks: number[]
) => {
const lastIndex = ticks.length - 1;
if (tick % powerOfTen !== 0) {
return false;
} else if (index !== lastIndex) {
return true;
} else {
return totalLength - tick > totalLength * 0.1;
}
};
@import 'src/styles/common';
.container {
display: flex;
flex-direction: column;
align-items: center;
height: 100vh;
background-color: $black;
padding-top: 20vh;
}
.form {
display: flex;
flex-direction: column;
align-items: center;
button {
margin-top: 1em;
padding: 0.4em;
background-color: white;
border: black;
}
}
import React, { useState, useRef } from 'react';
import { storiesOf } from '@storybook/react';
import BasePairsRuler from 'src/content/app/entity-viewer/components/base-pairs-ruler/BasePairsRuler';
import styles from './BasePairsRuler.stories.scss';
type ContainerProps = {
value: number;
onChange: (length: number) => void;
};
const LengthInputForm = (props: ContainerProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const onSubmit = (event: React.FormEvent) => {
event.preventDefault();
const value = inputRef.current?.value;
const parsedValue = value ? parseInt(value, 10) : null;
parsedValue && props.onChange(parsedValue);
};
return (
<form className={styles.form} onSubmit={onSubmit}>
<input ref={inputRef} defaultValue={props.value} />
<button>Change length</button>
</form>
);
};
storiesOf('Components|EntityViewer/FeatureLengthAxis', module).add(
'default',
() => {
const initialLength = 80792;
const [length, setLength] = useState(initialLength);
const handleLenghtChange = (length: number) => {
setLength(length);
};
return (
<div className={styles.container}>
<BasePairsRuler length={length} width={800} standalone={true} />
<div>
<LengthInputForm value={length} onChange={handleLenghtChange} />
</div>
</div>
);
}
);
import './base-pairs-ruler/BasePairsRuler.stories';
......@@ -4,3 +4,4 @@ import './design-primitives/index.ts';
import './shared-components/index.ts';
import './static-images/index.ts';
import './genome-browser/index.ts';
import './entity-viewer/index.ts';
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