mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-10-06 01:38:12 +08:00
interactive scatterplot
This commit is contained in:
@@ -0,0 +1,57 @@
|
|||||||
|
import _ from "lodash";
|
||||||
|
|
||||||
|
import {
|
||||||
|
margin,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
createDimensions,
|
||||||
|
} from "./util";
|
||||||
|
|
||||||
|
const drawScatterplotCanvas = (
|
||||||
|
context,
|
||||||
|
xScale,
|
||||||
|
yScale,
|
||||||
|
currentCellSelection,
|
||||||
|
opacityForDeselectedCells,
|
||||||
|
expression,
|
||||||
|
scatterplotXXaccessor,
|
||||||
|
scatterplotYYaccessor
|
||||||
|
) => {
|
||||||
|
|
||||||
|
/* clear canvas */
|
||||||
|
context.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
const _currentCellSelectionMap = _.keyBy(currentCellSelection, "CellName"); /* move me to the reducer */
|
||||||
|
|
||||||
|
expression.data.cells.forEach((cell, i) => {
|
||||||
|
|
||||||
|
/*
|
||||||
|
this is necessary until we are no longer getting expression for all cells, but only for 'world'
|
||||||
|
...which will mean refetching when we regraph, or 'go back up to all cells'
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!_currentCellSelectionMap[cell.cellname]) { return }
|
||||||
|
|
||||||
|
context.beginPath();
|
||||||
|
/* context.arc(x,y,r,sAngle,eAngle,counterclockwise); */
|
||||||
|
context.arc(
|
||||||
|
xScale(cell.e[expression.data.genes.indexOf(scatterplotXXaccessor)]), /* x */
|
||||||
|
yScale(cell.e[expression.data.genes.indexOf(scatterplotYYaccessor)]), /* y */
|
||||||
|
_currentCellSelectionMap[cell.cellname]["__selected__"] ? 3 : 1.5, /* r */
|
||||||
|
0, /* sAngle */
|
||||||
|
2 * Math.PI /* eAngle */
|
||||||
|
);
|
||||||
|
|
||||||
|
context.fillStyle = _currentCellSelectionMap[cell.cellname]["__color__"]
|
||||||
|
|
||||||
|
if (_currentCellSelectionMap[cell.cellname]["__selected__"]) {
|
||||||
|
context.globalAlpha = 1;
|
||||||
|
} else {
|
||||||
|
context.globalAlpha = opacityForDeselectedCells;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.fill();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default _.debounce(drawScatterplotCanvas, 50)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
|
||||||
|
.scatterplot {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scatterplot svg,
|
||||||
|
.scatterplot canvas {
|
||||||
|
font: 10px sans-serif;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scatterplot canvas {
|
||||||
|
opacity: 0.9;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
// https://bl.ocks.org/Jverma/076377dd0125b1a508621441752735fc
|
||||||
|
// https://peterbeshai.com/scatterplot-in-d3-with-voronoi-interaction.html
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import _ from "lodash";
|
||||||
|
import { connect } from "react-redux";
|
||||||
|
|
||||||
|
import scatterplot from "./scatterplot";
|
||||||
|
import setupScatterplot from "./setupScatterplot";
|
||||||
|
import styles from './scatterplot.css';
|
||||||
|
import drawScatterplotCanvas from "./drawScatterplotCanvas";
|
||||||
|
|
||||||
|
import {
|
||||||
|
margin,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
createDimensions,
|
||||||
|
} from "./util";
|
||||||
|
|
||||||
|
@connect((state) => {
|
||||||
|
|
||||||
|
const ranges = state.cells.cells && state.cells.cells.data.ranges ? state.cells.cells.data.ranges : null;
|
||||||
|
const metadata = state.cells.cells && state.cells.cells.data.metadata ? state.cells.cells.data.metadata : null;
|
||||||
|
const initializeRanges = state.initialize.data && state.initialize.data.data.ranges ? state.initialize.data.data.ranges : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ranges,
|
||||||
|
metadata,
|
||||||
|
initializeRanges,
|
||||||
|
colorAccessor: state.controls.colorAccessor,
|
||||||
|
colorScale: state.controls.colorScale,
|
||||||
|
currentCellSelection: state.controls.currentCellSelection,
|
||||||
|
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
|
||||||
|
scatterplotYYaccessor: state.controls.scatterplotYYaccessor,
|
||||||
|
opacityForDeselectedCells: state.controls.opacityForDeselectedCells,
|
||||||
|
differential: state.differential,
|
||||||
|
expression: state.expression,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
class Scatterplot extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
svg: null,
|
||||||
|
ctx: null,
|
||||||
|
axes: null,
|
||||||
|
dimensions: null,
|
||||||
|
xScale: null,
|
||||||
|
yScale: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
const {svg, ctx} = setupScatterplot(
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
margin
|
||||||
|
);
|
||||||
|
this.setState({svg, ctx})
|
||||||
|
}
|
||||||
|
componentWillReceiveProps(nextProps) {
|
||||||
|
this.maybeSetupScalesAndDrawAxes(nextProps);
|
||||||
|
}
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
console.log('scatter sees color as', this.props.colorAccessor)
|
||||||
|
console.log('scatter sees scatter acess as', this.props.scatterplotXXaccessor, this.props.scatterplotYYaccessor)
|
||||||
|
if (
|
||||||
|
(this.props.scatterplotXXaccessor && this.props.scatterplotYYaccessor) &&
|
||||||
|
this.props.scatterplotXXaccessor !== prevProps.scatterplotXXaccessor || // was CLU now FTH1 etc
|
||||||
|
this.props.scatterplotYYaccessor !== prevProps.scatterplotYYaccessor
|
||||||
|
) {
|
||||||
|
this.drawAxesSVG(this.state.xScale, this.state.yScale);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.state.xScale &&
|
||||||
|
this.state.yScale
|
||||||
|
) {
|
||||||
|
drawScatterplotCanvas(
|
||||||
|
this.state.ctx,
|
||||||
|
this.state.xScale,
|
||||||
|
this.state.yScale,
|
||||||
|
this.props.currentCellSelection,
|
||||||
|
this.props.opacityForDeselectedCells,
|
||||||
|
this.props.expression,
|
||||||
|
this.props.scatterplotXXaccessor,
|
||||||
|
this.props.scatterplotYYaccessor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
maybeSetupScalesAndDrawAxes(nextProps) {
|
||||||
|
if (
|
||||||
|
nextProps.expression &&
|
||||||
|
nextProps.scatterplotXXaccessor &&
|
||||||
|
nextProps.scatterplotYYaccessor
|
||||||
|
) {
|
||||||
|
console.log('expression in scatterplot: ', nextProps)
|
||||||
|
const xScale = d3.scaleLinear()
|
||||||
|
.domain(d3.extent(nextProps.expression.data.cells, (cell, i) => {
|
||||||
|
return cell.e[nextProps.expression.data.genes.indexOf(nextProps.scatterplotXXaccessor)]
|
||||||
|
}))
|
||||||
|
.range([0, width])
|
||||||
|
|
||||||
|
const yScale = d3.scaleLinear()
|
||||||
|
.domain(d3.extent(nextProps.expression.data.cells, (cell) => {
|
||||||
|
return cell.e[nextProps.expression.data.genes.indexOf(nextProps.scatterplotYYaccessor)]
|
||||||
|
}))
|
||||||
|
.range([height, 0])
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
xScale,
|
||||||
|
yScale
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drawAxesSVG(xScale, yScale) {
|
||||||
|
|
||||||
|
this.state.svg.selectAll("*").remove();
|
||||||
|
|
||||||
|
// the axes are much cleaner and easier now. No need to rotate and orient the axis, just call axisBottom, axisLeft etc.
|
||||||
|
var xAxis = d3.axisBottom()
|
||||||
|
.scale(xScale);
|
||||||
|
|
||||||
|
var yAxis = d3.axisLeft()
|
||||||
|
.scale(yScale);
|
||||||
|
|
||||||
|
// adding axes is also simpler now, just translate x-axis to (0,height) and it's alread defined to be a bottom axis.
|
||||||
|
this.state.svg.append('g')
|
||||||
|
.attr('transform', 'translate(0,' + height + ')')
|
||||||
|
.attr('class', 'x axis')
|
||||||
|
.call(xAxis);
|
||||||
|
|
||||||
|
// y-axis is translated to (0,0)
|
||||||
|
this.state.svg.append('g')
|
||||||
|
.attr('transform', 'translate(0,0)')
|
||||||
|
.attr('class', 'y axis')
|
||||||
|
.call(yAxis);
|
||||||
|
|
||||||
|
// adding label. For x-axis, it's at (10, 10), and for y-axis at (width, height-10).
|
||||||
|
this.state.svg.append('text')
|
||||||
|
.attr('x', 10)
|
||||||
|
.attr('y', 10)
|
||||||
|
.attr('class', 'label')
|
||||||
|
.text(this.props.scatterplotYYaccessor);
|
||||||
|
|
||||||
|
this.state.svg.append('text')
|
||||||
|
.attr('x', width)
|
||||||
|
.attr('y', height - 10)
|
||||||
|
.attr('text-anchor', 'end')
|
||||||
|
.attr('class', 'label')
|
||||||
|
.text(this.props.scatterplotXXaccessor);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div id="scatterplot_wrapper">
|
||||||
|
<div
|
||||||
|
className={styles.scatterplot}
|
||||||
|
id="scatterplot"
|
||||||
|
style={{
|
||||||
|
width: width + margin.left + margin.right + "px",
|
||||||
|
height: height + margin.top + margin.bottom + "px",
|
||||||
|
marginBottom: 60
|
||||||
|
}}
|
||||||
|
>scatter</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Scatterplot;
|
||||||
|
|
||||||
|
|
||||||
|
// <SectionHeader text="Continuous Metadata"/>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/*****************************************
|
||||||
|
******************************************
|
||||||
|
Setup SVG & Canvas elements
|
||||||
|
******************************************
|
||||||
|
******************************************/
|
||||||
|
|
||||||
|
const setupScatterplot = (
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
margin
|
||||||
|
) => {
|
||||||
|
|
||||||
|
var container = d3.select("#scatterplot")
|
||||||
|
|
||||||
|
var svg = container.append("svg")
|
||||||
|
.attr("width", width + margin.left + margin.right)
|
||||||
|
.attr("height", height + margin.top + margin.bottom)
|
||||||
|
.append("g")
|
||||||
|
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
|
||||||
|
|
||||||
|
var canvas = container.append("canvas")
|
||||||
|
.attr("width", width * devicePixelRatio)
|
||||||
|
.attr("height", height * devicePixelRatio)
|
||||||
|
.style("width", width + "px")
|
||||||
|
.style("height", height + "px")
|
||||||
|
.style("margin-left", 10 + 3 + "px") /* magic number: this seems to be visually correct when it is equal to the amount axis is translated by plus or minus... a little? */
|
||||||
|
.style("margin-top", margin.top + margin.bottom - 2 + "px")
|
||||||
|
|
||||||
|
var ctx = canvas.node().getContext("2d");
|
||||||
|
ctx.globalCompositeOperation = 'darken';
|
||||||
|
ctx.globalAlpha = 0.15;
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.scale(devicePixelRatio, devicePixelRatio);
|
||||||
|
|
||||||
|
return {
|
||||||
|
svg,
|
||||||
|
ctx,
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default setupScatterplot;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import _ from "lodash";
|
||||||
|
|
||||||
|
const paddingRight = 120;
|
||||||
|
const continuousChartWidth = 1200;
|
||||||
|
|
||||||
|
export const margin = {top: 66, right: 110, bottom: 20, left: 60};
|
||||||
|
export const width = continuousChartWidth - margin.left - margin.right - paddingRight;
|
||||||
|
export const height = 680 - margin.top - margin.bottom;
|
||||||
|
export const innerHeight = height - 2;
|
||||||
|
|
||||||
|
export const devicePixelRatio = window.devicePixelRatio || 1;
|
||||||
Reference in New Issue
Block a user