diff --git a/package.json b/package.json index 841284d7..7b6d3461 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "q": "^1.1.2", "react": "^0.14.0", "react-dom": "^0.14.0", + "react-color": "^2.17.3", "shallow-equals": "0.0.0", "underscore": "^1.7.0", "memory-cache": "0.1.6" diff --git a/src/main/Menu.js b/src/main/Menu.js index a65750e5..935b9d02 100644 --- a/src/main/Menu.js +++ b/src/main/Menu.js @@ -16,32 +16,94 @@ 'use strict'; + +import { SketchPicker } from 'react-color'; import React from 'react'; +// RGBA = red, green, blue, alpha +// each value is between 0 and 1 +type RGBA = { + r: number, g: number, b: number, a: number +}; + +// some color pickers require hex as color input, +// others require rgb +type ColorItem = { + hex: string, + rgb: RGBA +}; + type MenuItem = { key: string; label: string; checked?: boolean; + color?: ColorItem; }; type Props = { header: string; items: Array; - onSelect: (key: string) => void; + onClick: (item: Object) => void; }; -class Menu extends React.Component { +type State = { + // list of booleans determining whether to show color palette for MenuItem + showPalette: boolean[]; +}; + +class Menu extends React.Component { props: Props; + state: State; + + constructor(props: Object) { + super(props); + this.state = { + showPalette: new Array(props.items.length).fill(false) + }; + } + + // toggle color picker for menu items that have color enabled + toggleColorPicker(key: number, e: SyntheticEvent<>) { + if (this.state.showPalette[key] == false) { + this.state.showPalette[key] = true + this.setState({showPalette: this.state.showPalette}); + } else { + this.state.showPalette[key] = false + this.setState({showPalette: this.state.showPalette}); + } + } - clickHandler(idx: number, e: SyntheticMouseEvent<>) { - e.preventDefault(); + clickHandler(idx: number, e: SyntheticMouseEvent<>, togglePicker: boolean = true) { + // do not call preventDefault on nullified events to avoid warnings + if (e.eventPhase != null) { + e.preventDefault(); + } var item = this.props.items[idx]; if (typeof(item) == 'string') return; // for flow - this.props.onSelect(item.key); + + // propogate root update if new opts do not == old opts + this.props.onClick(item); + + if (item.color && togglePicker) { + this.toggleColorPicker(idx, e) + } } + handleColorChange(idx: number, color: Object, e: SyntheticMouseEvent<>) { + // update both hex and rgb values + if (typeof(this.props.items[idx]) == 'string') return; // for flow not working + if (typeof(this.props.items[idx]) === 'undefined') return; // for flow not working + this.props.items[idx].color = { + 'hex': color.hex, + 'rgb': color.rgb + }; + this.clickHandler(idx, e, false); + }; + render(): any { var makeHandler = i => this.clickHandler.bind(this, i); + var makeColorPickerHandler = i => this.handleColorChange.bind(this, i); + var els = []; if (this.props.header) { els.push(
{this.props.header}
); @@ -51,12 +113,33 @@ class Menu extends React.Component { return
; } else { var checkClass = 'check' + (item.checked ? ' checked' : ''); - return ( -
- - {item.label} -
- ); + // initially hide color picker + if (typeof(item) != 'string' && item.color) { + var colorPicker = null + + if (this.state.showPalette[i]) { + colorPicker = ( + ) + } + + return ( +
+ + {item.label} + {colorPicker} +
+ ); + } else { + return ( +
+ + {item.label} +
+ ); + } } })); diff --git a/src/main/Root.js b/src/main/Root.js index 62899389..87952b65 100644 --- a/src/main/Root.js +++ b/src/main/Root.js @@ -8,6 +8,8 @@ import type {GenomeRange} from './types'; import type {TwoBitSource} from './sources/TwoBitDataSource'; import type {VisualizedTrack, VizWithOptions} from './types'; +import _ from 'underscore'; + import React from 'react'; import Controls from './Controls'; import Menu from './Menu'; @@ -30,6 +32,8 @@ class Root extends React.Component { props: Props; state: State; trackReactElements: Array; //it's an array of reactelement that are created for tracks + outsideClickHandler: (a: any) => void; + node: any; // used to track clicking outside this component constructor(props: Object) { super(props); @@ -40,6 +44,8 @@ class Root extends React.Component { settingsMenuKey: null }; this.trackReactElements = []; + this.node = null; + this.outsideClickHandler = this.handleOutsideClick.bind(this); } componentDidMount() { @@ -71,20 +77,33 @@ class Root extends React.Component { }).done(); } - toggleSettingsMenu(key: string, e: SyntheticEvent<>) { + // key can be string or null + toggleSettingsMenu(key: any, e: SyntheticEvent<>) { if (this.state.settingsMenuKey == key) { this.setState({settingsMenuKey: null}); + document.removeEventListener('click', this.outsideClickHandler, false); } else { this.setState({settingsMenuKey: key}); + // remove event listener for clicking off of menu + document.addEventListener('click', this.outsideClickHandler, false); } } - handleSelectOption(trackKey: string, optionKey: string) { - this.setState({settingsMenuKey: null}); + handleOutsideClick(e: SyntheticEvent<>) { + // if menu is visible and click is outside of menu component, + // toggle view off + if (this.state.settingsMenuKey != null && this.state.settingsMenuKey != undefined) { + if (!this.node.contains(e.target)) { + this.toggleSettingsMenu(this.state.settingsMenuKey, e); + } + } + } + + handleSelectOption(trackKey: string, item: Object) { var viz = this.props.tracks[Number(trackKey)].visualization; var oldOpts = viz.options; // $FlowIgnore: TODO remove flow suppression - var newOpts = viz.component.handleSelectOption(optionKey, oldOpts); + var newOpts = viz.component.handleSelectOption(item, oldOpts); viz.options = newOpts; if (newOpts != oldOpts) { this.forceUpdate(); @@ -94,7 +113,7 @@ class Root extends React.Component { makeDivForTrack(key: string, track: VisualizedTrack): React$Element<'div'> { //this should be improved, but I have no idea how (when trying to //access this.trackReactElements with string key, flow complains) - var intKey = parseInt(key); + var intKey = parseInt(key); var trackEl = ( { top: gearY + 'px' }; // $FlowIgnore: TODO remove flow suppression - var items = track.visualization.component.getOptionsMenu(track.visualization.options); + var items = _.clone(track.visualization.component.getOptionsMenu(track.visualization.options)); settingsMenu = ( -
- +
{ this.node = node; }}> +
); } - + var className = ['track', track.visualization.component.displayName || '', track.track.cssClass || ''].join(' '); return ( diff --git a/src/main/style.js b/src/main/style.js index 058add48..4b2088f8 100644 --- a/src/main/style.js +++ b/src/main/style.js @@ -29,6 +29,7 @@ function TEXT_STYLE(mode: number, fontSize: number): string { module.exports = { TEXT_STYLE, + DEFAULT_COLORPICKER: {hex: '#969696', rgb: {r: 150, g: 150, b: 150, a: 1}}, // grey in hex/rgb // Colors for individual base pairs BASE_COLORS: { diff --git a/src/main/viz/CoverageTrack.js b/src/main/viz/CoverageTrack.js index 7a6d0b78..ff778206 100644 --- a/src/main/viz/CoverageTrack.js +++ b/src/main/viz/CoverageTrack.js @@ -112,7 +112,7 @@ function renderBars(ctx: DataCanvasRenderingContext2D, if (!lastPos) { let {barX1} = binPos(pos, bin.count); - ctx.fillStyle = style.COVERAGE_BIN_COLOR; + ctx.fillStyle = `rgba(${options.color.rgb.r}, ${options.color.rgb.g}, ${options.color.rgb.b}, ${options.color.rgb.a})`; ctx.beginPath(); ctx.moveTo(barX1, vBasePosY); } @@ -188,6 +188,8 @@ class CoverageTrack extends React.Component; tiles: CoverageTiledCanvas; static defaultOptions: Object; + static getOptionsMenu: (options: Object) => any; + static handleSelectOption: (item: Object, oldOptions: Object) => Object; constructor(props: VizProps>) { super(props); @@ -333,7 +335,25 @@ CoverageTrack.defaultOptions = { // exceeds this amount. When there are >=2 agreeing mismatches, they are // always rendered. But for mismatches below this threshold, the reference is // not colored in the bar chart. This draws attention to high-VAF mismatches. - vafColorThreshold: 0.2 + vafColorThreshold: 0.2, + color: style.DEFAULT_COLORPICKER }; +CoverageTrack.getOptionsMenu = function(options: Object): any { + return [ + {key: 'pick-color', label: 'Change track color', color: options.color} + ]; +}; + +CoverageTrack.handleSelectOption = function(item: Object, oldOptions: Object): Object { + var opts = _.clone(oldOptions); + if (item.key == "pick-color") { + // This is all handled by the menu. Do nothing. + opts.color = item.color; + return opts; + } + return oldOptions; // no change +}; + + module.exports = CoverageTrack; diff --git a/src/main/viz/FeatureTrack.js b/src/main/viz/FeatureTrack.js index dde69d91..c4aa3118 100644 --- a/src/main/viz/FeatureTrack.js +++ b/src/main/viz/FeatureTrack.js @@ -63,7 +63,7 @@ class FeatureTiledCanvas extends TiledCanvas { // get visual features with assigned rows var vFeatures = this.cache.getGroupsOverlapping(relaxedRange); - renderFeatures(ctx, scale, relaxedRange, vFeatures); + renderFeatures(ctx, scale, relaxedRange, vFeatures, this.options); } } @@ -71,7 +71,8 @@ class FeatureTiledCanvas extends TiledCanvas { function renderFeatures(ctx: DataCanvasRenderingContext2D, scale: (num: number) => number, range: ContigInterval, - vFeatures: VisualGroup[]) { + vFeatures: VisualGroup[], + options: Object) { ctx.font = `${style.GENE_FONT_SIZE}px ${style.GENE_FONT}`; ctx.textAlign = 'center'; @@ -84,11 +85,12 @@ function renderFeatures(ctx: DataCanvasRenderingContext2D, // Create transparency value based on score. Score of <= 200 is the same transparency. var alphaScore = Math.max(feature.score / 1000.0, 0.2); - ctx.fillStyle = 'rgba(0, 0, 0, ' + alphaScore + ')'; - + options.color.rgb.a=alphaScore; + ctx.fillStyle = `rgba(${options.color.rgb.r}, ${options.color.rgb.g}, ${options.color.rgb.b}, ${options.color.rgb.a})`; var x = Math.round(scale(vFeature.span.start())); var width = Math.ceil(scale(vFeature.span.stop()) - scale(vFeature.span.start())); - var y = yForRow(vFeature.row); + // if collapse mode, render everything in a single row + var y = options.collapse ? yForRow(0) : yForRow(vFeature.row); ctx.fillRect(x - 0.5, y, width, style.READ_HEIGHT); ctx.popObject(); }); @@ -99,6 +101,9 @@ class FeatureTrack extends React.Component>, State> state: State; tiles: FeatureTiledCanvas; cache: GenericFeatureCache; + static defaultOptions: Object; + static getOptionsMenu: (options: Object) => any; + static handleSelectOption: (item: Object, oldOptions: Object) => Object; constructor(props: VizProps>) { super(props); @@ -178,8 +183,28 @@ class FeatureTrack extends React.Component>, State> } componentDidUpdate(prevProps: any, prevState: any) { + var shouldUpdate = false; + if (this.props.options != prevProps.options) { + this.handleOptionsChange(prevProps.options); + shouldUpdate = true; + } + if (!shallowEquals(this.props, prevProps) || - !shallowEquals(this.state, prevState)) { + !shallowEquals(this.state, prevState)|| + shouldUpdate) { + this.tiles.update(this.props.options); + this.tiles.invalidateAll(); + this.updateVisualization(); + } + } + + handleOptionsChange(oldOpts: Object) { + this.tiles.invalidateAll(); + if (oldOpts.collapse != this.props.options.collapse) { + this.tiles.update(this.props.options); + this.tiles.invalidateAll(); + this.updateVisualization(); + } else if (oldOpts.color != this.props.options.color) { this.tiles.update(this.props.options); this.tiles.invalidateAll(); this.updateVisualization(); @@ -198,16 +223,18 @@ class FeatureTrack extends React.Component>, State> var ctx = dataCanvas.getDataContext(canvasUtils.getContext(canvas)); - ctx.reset(); ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); // get parent of canvas // The typecasts through `any` are to fool flow. var parent = ((d3utils.findParent(canvas, "features") : any) : HTMLCanvasElement); - + // Height can only be computed after the pileup has been updated. - var height = this.tiles.heightForRef(this.props.range.contig); + var height = style.READ_HEIGHT + style.READ_SPACING; + if (!this.props.options.collapse) { + height = this.tiles.heightForRef(this.props.range.contig); + } // resize height for device height = d3utils.heightForCanvas(canvas, height); @@ -235,7 +262,7 @@ class FeatureTrack extends React.Component>, State> scale = this.getScale(), vFeatures = this.cache.getGroupsOverlapping(range); - renderFeatures(trackingCtx, scale, range, vFeatures); + renderFeatures(trackingCtx, scale, range, vFeatures, this.props.options); var feature = _.find(trackingCtx.hits[0], hit => hit); if (feature) { @@ -259,4 +286,29 @@ class FeatureTrack extends React.Component>, State> FeatureTrack.displayName = 'features'; +FeatureTrack.defaultOptions = { + collapse: false, + color: style.DEFAULT_COLORPICKER +}; + +FeatureTrack.getOptionsMenu = function(options: Object): any { + return [ + {key: 'collapse', label: 'Collapse track', checked: options.collapse}, + {key: 'pick-color', label: 'Change track color', color: options.color} + ]; +}; + +FeatureTrack.handleSelectOption = function(item: Object, oldOptions: Object): Object { + var opts = _.clone(oldOptions); + if (item.key == 'collapse') { + opts.collapse = !opts.collapse; + return opts; + } else if (item.key == "pick-color") { + // This is all handled by the menu. Do nothing. + opts.color = item.color; + return opts; + } + return oldOptions; // no change +}; + module.exports = FeatureTrack; diff --git a/src/main/viz/PileupTrack.js b/src/main/viz/PileupTrack.js index f40ffde8..9688c14b 100644 --- a/src/main/viz/PileupTrack.js +++ b/src/main/viz/PileupTrack.js @@ -235,7 +235,7 @@ class PileupTrack extends React.Component>, State tiles: PileupTiledCanvas; static defaultOptions: { viewAsPairs: boolean }; static getOptionsMenu: (options: Object) => any; - static handleSelectOption: (key: string, oldOptions: Object) => Object; + static handleSelectOption: (item: Object, oldOptions: Object) => Object; constructor(props: VizProps>) { super(props); @@ -494,23 +494,23 @@ PileupTrack.getOptionsMenu = function(options: Object): any { var messageId = 1; -PileupTrack.handleSelectOption = function(key: string, oldOptions: Object): Object { +PileupTrack.handleSelectOption = function(item: Object, oldOptions: Object): Object { var opts = _.clone(oldOptions); - if (key == 'view-pairs') { + if (item.key == 'view-pairs') { opts.viewAsPairs = !opts.viewAsPairs; return opts; - } else if (key == 'color-insert') { + } else if (item.key == 'color-insert') { opts.colorByInsert = !opts.colorByInsert; if (opts.colorByInsert) opts.colorByStrand = false; return opts; - } else if (key == 'color-strand') { + } else if (item.key == 'color-strand') { opts.colorByStrand = !opts.colorByStrand; if (opts.colorByStrand) opts.colorByInsert = false; return opts; - } else if (key == 'hide-alignments') { + } else if (item.key == 'hide-alignments') { opts.hideAlignments = !opts.hideAlignments; return opts; - } else if (key == 'sort') { + } else if (item.key == 'sort') { opts.sort = (messageId++); return opts; } diff --git a/style/pileup.css b/style/pileup.css index 93149fb1..11044df0 100644 --- a/style/pileup.css +++ b/style/pileup.css @@ -13,6 +13,7 @@ .pileup-root > .track { display: flex; flex-direction: row; + margin-bottom: 4px; } .pileup-root text, .track-label { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; @@ -59,6 +60,14 @@ overflow-x: hidden; position: relative; } +.features > .track-content, .pileup > .track-content, .genes > .track-content, +.variants > .track-content, .coverage > .track-content { + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; + border-color: #bcbbbb; + border-left: 7px solid #bcbbbb; +} + .track-content > div { position: absolute; /* Gets the child of the flex-item to fill height 100% */ } @@ -101,7 +110,7 @@ } .gear { margin-left: 0.5em; - font-size: 2em; + font-size: 1.2em; color: #666; line-height: normal; /* avoid cutting off gear icon */ }