diff --git a/frontend/src/components/trackConfig/CallingCardTrackConfig.ts b/frontend/src/components/trackConfig/CallingCardTrackConfig.ts index 0bf5d84a..a5dabd4c 100644 --- a/frontend/src/components/trackConfig/CallingCardTrackConfig.ts +++ b/frontend/src/components/trackConfig/CallingCardTrackConfig.ts @@ -7,8 +7,13 @@ import LocalBedSource from '../../dataSources/LocalBedSource'; import CallingCard from '../../model/CallingCard'; import BedRecord from '../../dataSources/bed/BedRecord'; import HeightConfig from '../trackContextMenu/HeightConfig'; -import { BackgroundColorConfig, PrimaryColorConfig } from '../trackContextMenu/ColorConfig'; +import YscaleConfig from '../trackContextMenu/YscaleConfig'; +import LogScaleConfig from '../trackContextMenu/LogScaleConfig'; +import DownsamplingChoices from '../trackContextMenu/DownsamplingConfig'; +import OpacitySliderConfig from '../trackContextMenu/OpacitySilderConfig'; import MarkerSizeConfig from '../trackContextMenu/MarkerSizeConfig'; +import { BackgroundColorConfig, PrimaryColorConfig } from '../trackContextMenu/ColorConfig'; + export class CallingCardTrackConfig extends TrackConfig { constructor(trackModel: TrackModel) { @@ -39,7 +44,8 @@ export class CallingCardTrackConfig extends TrackConfig { } getMenuComponents() { - return [...super.getMenuComponents(), HeightConfig, MarkerSizeConfig, - PrimaryColorConfig, BackgroundColorConfig]; + return [...super.getMenuComponents(), + HeightConfig, YscaleConfig, LogScaleConfig, DownsamplingChoices, + OpacitySliderConfig, MarkerSizeConfig, PrimaryColorConfig, BackgroundColorConfig]; } } diff --git a/frontend/src/components/trackContextMenu/DownsamplingConfig.js b/frontend/src/components/trackContextMenu/DownsamplingConfig.js new file mode 100644 index 00000000..2588900f --- /dev/null +++ b/frontend/src/components/trackContextMenu/DownsamplingConfig.js @@ -0,0 +1,39 @@ +import React from 'react'; +import SelectConfig from './SelectConfig'; +import NumberConfig from './NumberConfig'; +import { DownsamplingChoices } from '../../model/DownsamplingChoices'; +/** + * A context menu item that configures track y-scale. + * + * @param {Object} props - props as specified by React + * @return {JSX.Element} element to render + */ +function DownsamplingConfig(props) { + const downSample = props.optionsObjects[0].show === 'sample' ? + + : null; + return ( + + + {downSample} + + ); +} + +export default DownsamplingConfig; diff --git a/frontend/src/components/trackContextMenu/LogScaleConfig.js b/frontend/src/components/trackContextMenu/LogScaleConfig.js new file mode 100644 index 00000000..e10a42f1 --- /dev/null +++ b/frontend/src/components/trackContextMenu/LogScaleConfig.js @@ -0,0 +1,25 @@ +import React from 'react'; +import SelectConfig from './SelectConfig'; +import { LogChoices } from '../../model/LogChoices'; +/** + * A context menu item that configures track log-scaling on the y-axis. + * + * @param {Object} props - props as specified by React + * @return {JSX.Element} element to render + */ +function LogScaleConfig(props) { + return +} + +export default LogScaleConfig; diff --git a/frontend/src/components/trackContextMenu/OpacitySilderConfig.js b/frontend/src/components/trackContextMenu/OpacitySilderConfig.js new file mode 100644 index 00000000..2fa5564d --- /dev/null +++ b/frontend/src/components/trackContextMenu/OpacitySilderConfig.js @@ -0,0 +1,22 @@ +import React from 'react'; +import SliderConfig from './SliderConfig'; +/** + * A context menu item that configures track opacity. + * + * @param {Object} props - props as specified by React + * @return {JSX.Element} element to render + */ +function OpacitySliderConfig(props) { + return {} } // Do nothing when updating + /> +} + +export default OpacitySliderConfig; diff --git a/frontend/src/components/trackContextMenu/SliderConfig.js b/frontend/src/components/trackContextMenu/SliderConfig.js new file mode 100644 index 00000000..dfb24488 --- /dev/null +++ b/frontend/src/components/trackContextMenu/SliderConfig.js @@ -0,0 +1,169 @@ +import React from 'react'; +import { Slider, Rail, Handles, Tracks } from 'react-compound-slider'; +import PropTypes from 'prop-types'; +import { ITEM_PROP_TYPES } from './TrackContextMenu'; + +import './TrackContextMenu.css'; + +const sliderStyle = { // Give the slider some width + position: 'relative', + width: 200, + height: 40, + marginLeft: '5%', + marginTop: 10, +} + +const railStyle = { + position: 'absolute', + width: '100%', + height: 10, + marginTop: 15, + borderRadius: 5, + backgroundColor: '#D8D8D8', +} + +function Track({ source, target, getTrackProps }) { + return ( +
+ ) +} + +export function Handle({ + handle: { id, value, percent }, + getHandleProps + }) { + return ( +
+
+ {value} +
+
+ ) +} + +/** + * A context menu item that renders a slider element for inputting data. + * + * @author Arnav Moudgil + */ +class SliderConfig extends React.PureComponent { + static propTypes = Object.assign({}, ITEM_PROP_TYPES, { + optionName: PropTypes.string.isRequired, // The prop to change of a TrackModel's options object. + label: PropTypes.string.isRequired, // Label for the input + mode: PropTypes.number.isRequired, // Number of slider handles + step: PropTypes.number.isRequired, // Slider step size + domain: PropTypes.array.isRequired, // Range of the slider + values: PropTypes.array.isRequired, // Values of the slider handles + }); + + static defaultProps = { + label: "Slider", + mode: 1, + step: 1, + domain: [0, 100], + values: [100], + }; + + constructor(props) { + super(props); + this.state = { + values: props.values.slice(), + update: props.values.slice(), + }; + this.onUpdate = props.onUpdate === undefined ? this.onUpdate.bind(this) : props.onUpdate; + this.onChange = props.onChange === undefined ? this.onChange.bind(this) : props.onChange; + } + + onUpdate = update => { + this.setState({ update }); + this.props.onOptionSet(this.props.optionName, update); + } + + onChange = values => { + this.setState({ values }) + this.props.onOptionSet(this.props.optionName, values); + } + + render() { + const {label, mode, step, domain} = this.props; + let slider = +
+ + {({ getRailProps }) => ( +
+ )} + + + {({ handles, getHandleProps }) => ( +
+ {handles.map(handle => ( + + ))} +
+ )} +
+ + {({ tracks, getTrackProps }) => ( +
+ {tracks.map(({ id, source, target }) => ( + + ))} +
+ )} +
+ + return ( +
+ +
+ ); + } +} + +export default SliderConfig; diff --git a/frontend/src/components/trackVis/CallingCardTrack.js b/frontend/src/components/trackVis/CallingCardTrack.js index 51d5a2e1..f3709cee 100644 --- a/frontend/src/components/trackVis/CallingCardTrack.js +++ b/frontend/src/components/trackVis/CallingCardTrack.js @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import _ from 'lodash'; -import { scaleLinear } from 'd3-scale'; +import { scaleLinear, scaleLog } from 'd3-scale'; import memoizeOne from 'memoize-one'; import { notify } from 'react-notify-toast'; import Track from './commonComponents/Track'; @@ -10,12 +10,18 @@ import GenomicCoordinates from './commonComponents/GenomicCoordinates'; import HoverTooltipContext from './commonComponents/tooltip/HoverTooltipContext'; import { RenderTypes, DesignRenderer } from '../../art/DesignRenderer'; import { ScaleChoices } from '../../model/ScaleChoices'; +import { LogChoices } from '../../model/LogChoices'; +import { DownsamplingChoices } from '../../model/DownsamplingChoices'; import { FeatureAggregator } from '../../model/FeatureAggregator'; export const DEFAULT_OPTIONS = { height: 40, color: "blue", yScale: ScaleChoices.AUTO, + logScale: LogChoices.AUTO, + show: "all", + sampleSize: 1000, + opacity: [100], yMax: 10, yMin: 0, markerSize: 3, @@ -26,7 +32,7 @@ const TOP_PADDING = 5; /** * Track specialized in showing calling card data. * - * @author Silas Hsu and Daofeng Li + * @author Silas Hsu, Daofeng Li, and Arnav Moudgil */ class CallingCardTrack extends React.PureComponent { static propTypes = Object.assign({}, Track.propsFromTrackContainer, @@ -35,6 +41,11 @@ class CallingCardTrack extends React.PureComponent { options: PropTypes.shape({ height: PropTypes.number.isRequired, // Height of the track color: PropTypes.string, // Color to draw circle + scaleType: PropTypes.any, // Unused for now + scaleRange: PropTypes.array, // Unused for now + logScale: PropTypes.string, // For log-scaling y-axis + opacity: PropTypes.array, // For track opacity + show: PropTypes.string, // For downsampling }).isRequired, isLoading: PropTypes.bool, // If true, applies loading styling error: PropTypes.any, // If present, applies error styling @@ -44,7 +55,7 @@ class CallingCardTrack extends React.PureComponent { super(props); this.xToValue = null; this.scales = null; - this.computeScales = memoizeOne(this.computeScales); + // this.computeScales = memoizeOne(this.computeScales); // for some reason computeScales doesn't work when memoized this.aggregateFeatures = memoizeOne(this.aggregateFeatures); this.renderTooltip = this.renderTooltip.bind(this); } @@ -56,7 +67,7 @@ class CallingCardTrack extends React.PureComponent { } computeScales(xToValue, height) { - const {yScale, yMin, yMax} = this.props.options; + const {yScale, yMin, yMax, logScale} = this.props.options; if (yMin > yMax) { notify.show('Y-axis min must less than max', 'error', 2000); } @@ -70,8 +81,23 @@ class CallingCardTrack extends React.PureComponent { if (min > max) { min = max; } + // Define transformation function for log scaling + let transformer = null; + switch (logScale) { + case LogChoices.AUTO: + transformer = scaleLinear; + break; + case LogChoices.BASE10: + transformer = scaleLog; + // Set valid minimum value to one; + // after log-transforming, it will be zero + min = 1; + break; + default: + notify.show('Invalid logarithm base', 'error', 2000); + } return { - valueToY: scaleLinear().domain([max, min]).range([TOP_PADDING, height]).clamp(true), + valueToY: transformer().domain([max, min]).range([TOP_PADDING, height]).clamp(true), min, max, }; @@ -81,48 +107,126 @@ class CallingCardTrack extends React.PureComponent { * Renders the default tooltip that is displayed on hover. * * @param {number} relativeX - x coordinate of hover relative to the visualizer + * @param {number} relativeY - y coordinate of hover relative to the visualizer * @param {number} value - * @return {JSX.Element} tooltip to render */ - renderTooltip(relativeX) { + renderTooltip(relativeX, relativeY) { const {trackModel, viewRegion, width} = this.props; - const value = this.xToValue[Math.round(relativeX)]; - const stringValue = value !== undefined && value.length > 0 ? this.formatCards(value) : '(no data)'; - return ( -
-
- -
-
{trackModel.getDisplayLabel()}
-
{stringValue}
-
- ); + const {markerSize} = this.props.options; + // const radius = height * tooltipRadius; + var cards = []; + // Get nearest CallingCards to cursor along x-axis + for (let i = relativeX - markerSize; i <= relativeX + markerSize; i++) { + cards = cards.concat(this.xToValue[i]); + } + // Draw tooltip only if there are values near this x position + if (cards !== undefined && cards.length > 0) { + // Now find nearest CallingCards to the cursor along y-axis + const nearest = this.nearestCards(cards, relativeX, relativeY, markerSize); + if (nearest.length > 0) { + return ( +
+
+ +
+
{trackModel.getDisplayLabel()}
+
{this.formatCards(nearest)}
+
+ ); + }; + }; } formatCards = (cards) => { const head = ( - Barcode - Count + Value + Strand + String ); - const rows = cards.slice(0, 10).map((card,i) => {card.barcode}{card.value}); + const rows = cards.slice(0, 10).map((card,i) => {card.value}{card.strand}{card.string}); return {head}{rows}
; } + // Return closest calling cards to the cursor + nearestCards = (cards, relativeX, relativeY, radius) => { + const distances = cards.map((card) => Math.pow(relativeX - card.relativeX, 2) + Math.pow(relativeY - card.relativeY, 2)); + // Avoid taking square roots if possible; compare to radius^2 + const mindist = Math.min(...distances); + if (mindist < radius * radius) { + var returnCards = []; + for (var i = 0; i < distances.length; i++) { + if (Math.abs(distances[i]) === mindist) returnCards.push(cards[i]); + } + return returnCards; + } else { + return []; + }; + } + + /** + * Shuffles array in place (Fisher-Yates algorithm) + * @param {Array} a items An array containing the items. + */ + shuffleArray = (a) => { + var j, x, i; + for (i = a.length - 1; i > 0; i--) { + j = Math.floor(Math.random() * (i + 1)); + x = a[i]; + a[i] = a[j]; + a[j] = x; + } + return a; + } + + randomCards = (cards, n) => { + return this.shuffleArray(cards).slice(0, n); + } + + downSample(xToValue, sampleSize) { + if (xToValue.length === 0) return []; + // Initialize return value + var sampled_xToValue = []; + sampled_xToValue.length = xToValue.length; + sampled_xToValue.fill([]); + // Draw random downsample + const randomSample = this.randomCards(xToValue.flat(), sampleSize); + for (let i = 0; i < randomSample.length; i++) { + var j = randomSample[i].relativeX; + sampled_xToValue[j] = sampled_xToValue[j].concat([randomSample[i]]); + } + return sampled_xToValue; + } + render() { const {data, viewRegion, width, trackModel, options, forceSvg} = this.props; - const {height, color, colorAboveMax, markerSize} = options; + const {height, color, colorAboveMax, markerSize, opacity, show, sampleSize} = options; this.xToValue = data.length > 0 ? this.aggregateFeatures(data, viewRegion, width) : []; this.scales = this.computeScales(this.xToValue, height); + // Set relative coordinates for each CallingCard (used for tooltip) + for (let i = 0; i < this.xToValue.length; i++) { + for (let j = 0; j < this.xToValue[i].length; j++) { + this.xToValue[i][j].relativeX = i; + this.xToValue[i][j].relativeY = this.scales.valueToY(this.xToValue[i][j].value); + } + } + // Downsample if necessary + if (show === DownsamplingChoices.SAMPLE && data.length > sampleSize) { + // Store original data structure + this.xToValueOriginal = this.xToValue; + // Set to the downsampled dataset + this.xToValue = this.downSample(this.xToValue, sampleSize); + } const legend = ; const visualizer = ( - + ); @@ -149,6 +256,9 @@ class CallingCardPlot extends React.PureComponent { height: PropTypes.number.isRequired, color: PropTypes.string, markerSize: PropTypes.number, + alpha: PropTypes.number, + show: PropTypes.string, + sampleSize: PropTypes.number, } constructor(props) { @@ -167,11 +277,11 @@ class CallingCardPlot extends React.PureComponent { if (value.length === 0) { return null; } - const {scales, color, markerSize} = this.props; + const {scales, color, markerSize, alpha} = this.props; return value.map((card,idx) => { const y = scales.valueToY(card.value); const key = `${x}-${idx}`; - return ; + return ; }); } @@ -179,7 +289,7 @@ class CallingCardPlot extends React.PureComponent { render() { const {xToValue, height, forceSvg} = this.props; return - {this.props.xToValue.map(this.renderPixel)} + {xToValue.map(this.renderPixel)} } } diff --git a/frontend/src/model/CallingCard.ts b/frontend/src/model/CallingCard.ts index 8dc8d9eb..6d2c6dd2 100644 --- a/frontend/src/model/CallingCard.ts +++ b/frontend/src/model/CallingCard.ts @@ -5,14 +5,14 @@ import BedRecord from '../dataSources/bed/BedRecord'; enum CallingCardColumnIndex { VALUE=3, STRAND=4, - BARCODE=5 + STRING=5 }; /** * A data container for a calling card. * - * @author Daofeng Li + * @author Daofeng Li and Arnav Moudgil */ class CallingCard extends Feature { /* @@ -25,13 +25,15 @@ class CallingCard extends Feature { * Constructs a new CallingCard, given a string from tabix * */ - barcode: any; + string: any; value: number; + relativeX: number; // Store relative position of CallingCard in visualizer + relativeY: number; // Used to find nearest CallingCard to cursor for tooltip; also for downsampling constructor(bedRecord: BedRecord) { const locus = new ChromosomeInterval(bedRecord.chr, bedRecord.start, bedRecord.end); super('', locus, bedRecord[CallingCardColumnIndex.STRAND]); - this.barcode = bedRecord[CallingCardColumnIndex.BARCODE] this.value = Number.parseFloat(bedRecord[CallingCardColumnIndex.VALUE]); + this.string = bedRecord[CallingCardColumnIndex.STRING]; } } diff --git a/frontend/src/model/DownsamplingChoices.ts b/frontend/src/model/DownsamplingChoices.ts new file mode 100644 index 00000000..bb6f22a1 --- /dev/null +++ b/frontend/src/model/DownsamplingChoices.ts @@ -0,0 +1,4 @@ +export enum DownsamplingChoices { + ALL = "all", + SAMPLE = "sample", +}; \ No newline at end of file diff --git a/frontend/src/model/LinearDrawingModel.ts b/frontend/src/model/LinearDrawingModel.ts index 60fed21b..1994c070 100644 --- a/frontend/src/model/LinearDrawingModel.ts +++ b/frontend/src/model/LinearDrawingModel.ts @@ -94,11 +94,12 @@ class LinearDrawingModel { */ baseSpanToXCenter(baseInterval: OpenInterval): OpenInterval { const span = this.baseSpanToXSpan(baseInterval); - const centerX = (span.start + span.end) / 2; + const centerX = Math.round((span.start + span.end) / 2); // const startX = this.baseToX(baseInterval.start); // const endX = this.baseToX(baseInterval.end); // const centerX = (startX + endX) / 2; - return new OpenInterval(centerX, centerX+1); + // Round centerx and return (centerX, centerX) to plot a single marker + return new OpenInterval(centerX, centerX); } /** diff --git a/frontend/src/model/LogChoices.ts b/frontend/src/model/LogChoices.ts new file mode 100644 index 00000000..4f47c769 --- /dev/null +++ b/frontend/src/model/LogChoices.ts @@ -0,0 +1,6 @@ +export enum LogChoices { + AUTO = "none", + BASE10 = "log10", + // BASE2 = "log2", + // NATURAL = "ln", +}; \ No newline at end of file