mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-09-26 23:58:12 +08:00
renaming backend, cellxgene to server, client respectively
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
// jshint esversion: 6
|
||||
import * as globals from "../globals";
|
||||
import store from "../reducers";
|
||||
import URI from "urijs";
|
||||
import _ from "lodash";
|
||||
|
||||
const requestCells = (query = "") => {
|
||||
return dispatch => {
|
||||
dispatch({ type: "request cells started" });
|
||||
return fetch(`${globals.API.prefix}${globals.API.version}cells${query}`, {
|
||||
method: "get",
|
||||
headers: new Headers({
|
||||
"Content-Type": "application/json"
|
||||
})
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(
|
||||
data => dispatch({ type: "request cells success", data }),
|
||||
error => dispatch({ type: "request cells error", error })
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
/* SELECT */
|
||||
const regraph = () => {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: "regraph started" });
|
||||
|
||||
const state = getState();
|
||||
const selectedMetadata = {};
|
||||
|
||||
_.each(state.controls.categoricalAsBooleansMap, (options, field) => {
|
||||
let atLeastOneOptionDeselected = false;
|
||||
|
||||
_.each(options, (isActive, option) => {
|
||||
if (!isActive) {
|
||||
atLeastOneOptionDeselected = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (atLeastOneOptionDeselected) {
|
||||
_.each(options, (isActive, option) => {
|
||||
if (isActive) {
|
||||
if (selectedMetadata[field]) {
|
||||
selectedMetadata[field].push(option);
|
||||
} else if (!selectedMetadata[field]) {
|
||||
selectedMetadata[field] = [option];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let uri = new URI();
|
||||
uri.setSearch(selectedMetadata);
|
||||
console.log(uri.search(), selectedMetadata);
|
||||
|
||||
dispatch(requestCells(uri.search())).then(res => {
|
||||
if (res.error) {
|
||||
dispatch({ type: "regraph error" });
|
||||
} else {
|
||||
dispatch({ type: "regraph success" });
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const resetGraph = () => {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: "reset graph" });
|
||||
};
|
||||
};
|
||||
|
||||
const initialize = () => {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: "initialize started" });
|
||||
fetch(`${globals.API.prefix}${globals.API.version}initialize`, {
|
||||
method: "get",
|
||||
headers: new Headers({
|
||||
"Content-Type": "application/json"
|
||||
})
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(
|
||||
data => dispatch({ type: "initialize success", data }),
|
||||
error => dispatch({ type: "initialize error", error })
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
// This code defends against the case where /expression returns a cellname
|
||||
// never seen before (ie, not returned by /cells). This should not happen
|
||||
// (see https://github.com/chanzuckerberg/cellxgene-rest-api/issues/34) but
|
||||
// occasionally does.
|
||||
//
|
||||
function cleanupExpressionResponse(data) {
|
||||
const s = store.getState();
|
||||
const metadata = s.controls.allCellsMetadataMap;
|
||||
let errorFound = false;
|
||||
data.data.cells = _.filter(data.data.cells, cell => {
|
||||
if (!errorFound && !metadata[cell.cellname]) {
|
||||
errorFound = true;
|
||||
console.error(
|
||||
"Warning: /expression REST API returned unexpected cell names -- discarding surprises."
|
||||
);
|
||||
}
|
||||
return metadata[cell.cellname];
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
const requestGeneExpressionCounts = () => {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: "get expression started" });
|
||||
fetch(`${globals.API.prefix}${globals.API.version}expression`, {
|
||||
method: "get",
|
||||
headers: new Headers({
|
||||
accept: "application/json"
|
||||
})
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => cleanupExpressionResponse(data))
|
||||
.then(
|
||||
data => dispatch({ type: "get expression success", data }),
|
||||
error => dispatch({ type: "get expression error", error })
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
const requestSingleGeneExpressionCountsForColoringPOST = gene => {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: "get single gene expression for coloring started" });
|
||||
fetch(`${globals.API.prefix}${globals.API.version}expression`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
genelist: [gene]
|
||||
}),
|
||||
headers: new Headers({
|
||||
accept: "application/json",
|
||||
"Content-Type": "application/json"
|
||||
})
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => cleanupExpressionResponse(data))
|
||||
.then(
|
||||
data =>
|
||||
dispatch({
|
||||
type: "color by expression",
|
||||
gene: gene,
|
||||
data
|
||||
}),
|
||||
error =>
|
||||
dispatch({
|
||||
type: "get single gene expression for coloring error",
|
||||
error
|
||||
})
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
const requestGeneExpressionCountsPOST = genes => {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: "get expression started" });
|
||||
fetch(`${globals.API.prefix}${globals.API.version}expression`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
genelist: genes
|
||||
}),
|
||||
headers: new Headers({
|
||||
accept: "application/json",
|
||||
"Content-Type": "application/json"
|
||||
})
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => cleanupExpressionResponse(data))
|
||||
.then(
|
||||
data => dispatch({ type: "get expression success", data }),
|
||||
error => dispatch({ type: "get expression error", error })
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
const requestDifferentialExpression = (celllist1, celllist2, num_genes = 7) => {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: "request differential expression started" });
|
||||
fetch(`${globals.API.prefix}${globals.API.version}diffexpression`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
celllist1,
|
||||
celllist2,
|
||||
num_genes
|
||||
}),
|
||||
headers: new Headers({
|
||||
accept: "application/json",
|
||||
"Content-Type": "application/json"
|
||||
})
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(
|
||||
data => {
|
||||
/* kick off a secondary action to get all expression counts for all cells now that we know what the top expressed are */
|
||||
dispatch(
|
||||
requestGeneExpressionCountsPOST(
|
||||
_.union(
|
||||
data.data.celllist1.topgenes,
|
||||
data.data.celllist2.topgenes
|
||||
) // ["GPM6B", "FEZ1", "TSPAN31", "PCSK1N", "TUBA1A", "GPM6A", "CLU", "FCER1G", "TYROBP", "C1QB", "CD74", "CYBA", "GPX1", "TMSB4X"]
|
||||
)
|
||||
);
|
||||
/* then send the success case action through */
|
||||
return dispatch({
|
||||
type: "request differential expression success",
|
||||
data
|
||||
});
|
||||
},
|
||||
error =>
|
||||
dispatch({ type: "request differential expression error", error })
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
export default {
|
||||
initialize,
|
||||
requestCells,
|
||||
regraph,
|
||||
resetGraph,
|
||||
requestGeneExpressionCounts,
|
||||
requestGeneExpressionCountsPOST,
|
||||
requestSingleGeneExpressionCountsForColoringPOST,
|
||||
requestDifferentialExpression
|
||||
};
|
||||
@@ -0,0 +1,251 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import _ from "lodash";
|
||||
import DeckGL, {
|
||||
PointCloudLayer,
|
||||
ScreenGridLayer,
|
||||
COORDINATE_SYSTEM
|
||||
} from "deck.gl";
|
||||
import OrbitController from "./orbit-control";
|
||||
import { Popup } from "./popup";
|
||||
|
||||
class Heatmap extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.onChangeViewport = this.onChangeViewport.bind(this);
|
||||
this.onInitialized = this.onInitialized.bind(this);
|
||||
this.onResize = this.onResize.bind(this);
|
||||
this.onUpdate = this.onUpdate.bind(this);
|
||||
this.onHover = this.onHover.bind(this);
|
||||
|
||||
this.state = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
points: [],
|
||||
sampleExpressionMatrix: [
|
||||
{ color: [0, 255, 0], position: [100, 100] },
|
||||
{ color: [0, 255, 0], position: [100, 100] },
|
||||
{ color: [0, 255, 0], position: [100, 100] }
|
||||
],
|
||||
progress: 0,
|
||||
popup: {
|
||||
displayed: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
title: ""
|
||||
},
|
||||
viewport: {
|
||||
lookAt: [0, 0, 0],
|
||||
distance: 1,
|
||||
rotationX: 0,
|
||||
rotationY: 0,
|
||||
fov: 30,
|
||||
minDistance: 0.5,
|
||||
maxDistance: 3
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
getColor(cluster) {
|
||||
let color = [0, 0, 0];
|
||||
|
||||
switch (cluster) {
|
||||
case 0:
|
||||
color = [166, 206, 227];
|
||||
break;
|
||||
case 1:
|
||||
color = [31, 120, 180];
|
||||
break;
|
||||
case 2:
|
||||
color = [178, 223, 138];
|
||||
break;
|
||||
case 3:
|
||||
color = [51, 160, 44];
|
||||
break;
|
||||
case 4:
|
||||
color = [251, 154, 153];
|
||||
break;
|
||||
case 5:
|
||||
color = [227, 26, 28];
|
||||
break;
|
||||
case 6:
|
||||
color = [253, 191, 111];
|
||||
break;
|
||||
case 7:
|
||||
color = [255, 127, 0];
|
||||
break;
|
||||
case 8:
|
||||
color = [202, 178, 214];
|
||||
break;
|
||||
case 9:
|
||||
color = [106, 61, 154];
|
||||
break;
|
||||
}
|
||||
|
||||
return color;
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
window.addEventListener("resize", this.onResize);
|
||||
this.onResize();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.canvas.fitBounds([-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]);
|
||||
|
||||
this.fetchData();
|
||||
|
||||
window.requestAnimationFrame(this.onUpdate);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("resize", this.onResize);
|
||||
}
|
||||
|
||||
fetchData() {
|
||||
fetch(
|
||||
"https://raw.githubusercontent.com/zdenekhynek/data-science-capstone-visualisation/master/public/clusters.json"
|
||||
).then(res => {
|
||||
res.json().then(obj => {
|
||||
const clusters = Object.keys(obj).map(k => obj[k]);
|
||||
const points = clusters.map(cluster => {
|
||||
const position = [cluster.x, cluster.y, cluster.z];
|
||||
const color = [255, 0, 0];
|
||||
const id = cluster.id;
|
||||
const title = cluster.webTitle;
|
||||
return { id, title, position, color };
|
||||
});
|
||||
|
||||
this.setState({ points, progress: 1 });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onHover(d) {
|
||||
let popup = { displayed: false };
|
||||
console.log("d", d);
|
||||
|
||||
if (d.object) {
|
||||
const object = d.object;
|
||||
popup = {
|
||||
id: object.id,
|
||||
title: object.title,
|
||||
displayed: true,
|
||||
x: d.x,
|
||||
y: d.y
|
||||
};
|
||||
}
|
||||
|
||||
this.setState({ popup });
|
||||
}
|
||||
|
||||
onResize() {
|
||||
const { innerWidth: width, innerHeight: height } = window;
|
||||
this.setState({ width: width / 1.5, height: height / 1.5 });
|
||||
}
|
||||
|
||||
onInitialized(gl) {
|
||||
gl.clearColor(0, 0, 0, 1);
|
||||
gl.enable(gl.DEPTH_TEST);
|
||||
gl.depthFunc(gl.LEQUAL);
|
||||
}
|
||||
|
||||
onChangeViewport(viewport) {
|
||||
this.setState({
|
||||
rotating: !viewport.isDragging,
|
||||
viewport: { ...this.state.viewport, ...viewport }
|
||||
});
|
||||
}
|
||||
|
||||
onUpdate() {
|
||||
const { viewport } = this.state;
|
||||
window.requestAnimationFrame(this.onUpdate);
|
||||
}
|
||||
|
||||
renderPointCloudLayer() {
|
||||
return (
|
||||
this.state.points.length &&
|
||||
new PointCloudLayer({
|
||||
id: "point-cloud-layer",
|
||||
data: this.state.points,
|
||||
projectionMode: COORDINATE_SYSTEM.IDENTITY,
|
||||
pickable: true,
|
||||
onHover: this.onHover,
|
||||
getPosition: d => d.position,
|
||||
getNormal: d => [0, 0.5, 0.2],
|
||||
getColor: d => d.color,
|
||||
radiusPixels: 2
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
renderGridLayer() {
|
||||
/**
|
||||
* Data format:
|
||||
* [
|
||||
* {position: [-122.4, 37.7]},
|
||||
* ...
|
||||
* ]
|
||||
*/
|
||||
const screenGridLayer = new ScreenGridLayer({
|
||||
id: "screen-grid-layer",
|
||||
data: this.state.sampleExpressionMatrix,
|
||||
projectionMode: COORDINATE_SYSTEM.IDENTITY,
|
||||
pickable: true,
|
||||
getPosition: d => d.position,
|
||||
getColor: d => d.color,
|
||||
cellSizePixels: 40
|
||||
});
|
||||
|
||||
return screenGridLayer;
|
||||
}
|
||||
|
||||
renderDeckGLCanvas() {
|
||||
const { width, height, viewport } = this.state;
|
||||
const canvasProps = { width, height, ...viewport };
|
||||
const glViewport = OrbitController.getViewport(canvasProps);
|
||||
|
||||
return (
|
||||
width &&
|
||||
height && (
|
||||
<OrbitController
|
||||
{...canvasProps}
|
||||
ref={canvas => {
|
||||
this.canvas = canvas;
|
||||
}}
|
||||
onChangeViewport={this.onChangeViewport}
|
||||
>
|
||||
<DeckGL
|
||||
width={width}
|
||||
height={height}
|
||||
viewport={glViewport}
|
||||
layers={[
|
||||
// this.renderPointCloudLayer(),
|
||||
this.renderGridLayer()
|
||||
].filter(Boolean)}
|
||||
onWebGLInitialized={this.onInitialized}
|
||||
/>
|
||||
</OrbitController>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { width, height, popup } = this.state;
|
||||
if (!width || !height) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderedPopup = popup.displayed ? <Popup {...popup} /> : null;
|
||||
|
||||
return (
|
||||
<div id="heatmap">
|
||||
{this.renderDeckGLCanvas()}
|
||||
{renderedPopup}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Heatmap;
|
||||
@@ -0,0 +1,170 @@
|
||||
// jshint esversion: 6
|
||||
/* global window */
|
||||
import React, { Component } from "react";
|
||||
import { PerspectiveViewport } from "deck.gl";
|
||||
import { vec3 } from "gl-matrix";
|
||||
|
||||
/* Utils */
|
||||
// constrain number between bounds
|
||||
function clamp(x, min, max) {
|
||||
if (x < min) {
|
||||
return min;
|
||||
}
|
||||
if (x > max) {
|
||||
return max;
|
||||
}
|
||||
return x;
|
||||
}
|
||||
|
||||
const ua =
|
||||
typeof window.navigator !== "undefined"
|
||||
? window.navigator.userAgent.toLowerCase()
|
||||
: "";
|
||||
const firefox = ua.indexOf("firefox") !== -1;
|
||||
|
||||
/* Interaction */
|
||||
|
||||
export default class OrbitController extends Component {
|
||||
static getViewport({
|
||||
width,
|
||||
height,
|
||||
lookAt,
|
||||
distance,
|
||||
rotationX,
|
||||
rotationY,
|
||||
fov
|
||||
}) {
|
||||
const cameraPos = vec3.add([], lookAt, [0, 0, distance]);
|
||||
vec3.rotateX(cameraPos, cameraPos, lookAt, rotationX / 180 * Math.PI);
|
||||
vec3.rotateY(cameraPos, cameraPos, lookAt, rotationY / 180 * Math.PI);
|
||||
|
||||
return new PerspectiveViewport({
|
||||
width,
|
||||
height,
|
||||
lookAt,
|
||||
far: 1000,
|
||||
near: 0.1,
|
||||
fovy: fov,
|
||||
eye: cameraPos
|
||||
});
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this._dragStartPos = null;
|
||||
}
|
||||
|
||||
_onDragStart(evt) {
|
||||
const { pageX, pageY } = evt;
|
||||
this._dragStartPos = [pageX, pageY];
|
||||
this.props.onChangeViewport({ isDragging: true });
|
||||
}
|
||||
|
||||
_onDrag(evt) {
|
||||
if (this._dragStartPos) {
|
||||
const { pageX, pageY } = evt;
|
||||
const { width, height } = this.props;
|
||||
const dx = (pageX - this._dragStartPos[0]) / width;
|
||||
const dy = (pageY - this._dragStartPos[1]) / height;
|
||||
|
||||
if (evt.shiftKey || evt.ctrlKey || evt.altKey || evt.metaKey) {
|
||||
// pan
|
||||
const { lookAt, distance, rotationX, rotationY, fov } = this.props;
|
||||
|
||||
const unitsPerPixel = distance / Math.tan(fov / 180 * Math.PI / 2) / 2;
|
||||
|
||||
const newLookAt = vec3.add([], lookAt, [
|
||||
-unitsPerPixel * dx,
|
||||
unitsPerPixel * dy,
|
||||
0
|
||||
]);
|
||||
vec3.rotateX(newLookAt, newLookAt, lookAt, rotationX / 180 * Math.PI);
|
||||
vec3.rotateY(newLookAt, newLookAt, lookAt, rotationY / 180 * Math.PI);
|
||||
|
||||
this.props.onChangeViewport({
|
||||
lookAt: newLookAt
|
||||
});
|
||||
} else {
|
||||
// rotate
|
||||
const { rotationX, rotationY } = this.props;
|
||||
const newRotationX = clamp(rotationX - dy * 180, -90, 90);
|
||||
const newRotationY = (rotationY - dx * 180) % 360;
|
||||
|
||||
this.props.onChangeViewport({
|
||||
rotationX: newRotationX,
|
||||
rotationY: newRotationY
|
||||
});
|
||||
}
|
||||
|
||||
this._dragStartPos = [pageX, pageY];
|
||||
}
|
||||
}
|
||||
|
||||
_onDragEnd() {
|
||||
this._dragStartPos = null;
|
||||
this.props.onChangeViewport({ isDragging: false });
|
||||
}
|
||||
|
||||
_onWheel(evt) {
|
||||
evt.preventDefault();
|
||||
let value = evt.deltaY;
|
||||
// Firefox doubles the values on retina screens...
|
||||
if (firefox && evt.deltaMode === window.WheelEvent.DOM_DELTA_PIXEL) {
|
||||
value /= window.devicePixelRatio;
|
||||
}
|
||||
if (evt.deltaMode === window.WheelEvent.DOM_DELTA_LINE) {
|
||||
value *= 40;
|
||||
}
|
||||
if (value !== 0 && value % 4.000244140625 === 0) {
|
||||
// This one is definitely a mouse wheel event.
|
||||
// Normalize this value to match trackpad.
|
||||
value = Math.floor(value / 4);
|
||||
}
|
||||
|
||||
const { distance, minDistance, maxDistance } = this.props;
|
||||
const newDistance = clamp(
|
||||
distance * Math.pow(1.01, value),
|
||||
minDistance,
|
||||
maxDistance
|
||||
);
|
||||
|
||||
this.props.onChangeViewport({
|
||||
distance: newDistance
|
||||
});
|
||||
}
|
||||
|
||||
// public API
|
||||
fitBounds(min, max) {
|
||||
const { fov } = this.props;
|
||||
const size = Math.max(max[0] - min[0], max[1] - min[1], max[2] - min[2]);
|
||||
const newDistance = size / Math.tan(fov / 180 * Math.PI / 2) / 2;
|
||||
|
||||
this.props.onChangeViewport({
|
||||
distance: newDistance
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
style={{ position: "relative", userSelect: "none" }}
|
||||
onMouseDown={this._onDragStart.bind(this)}
|
||||
onMouseMove={this._onDrag.bind(this)}
|
||||
onMouseLeave={this._onDragEnd.bind(this)}
|
||||
onMouseUp={this._onDragEnd.bind(this)}
|
||||
onWheel={this._onWheel.bind(this)}
|
||||
>
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
OrbitController.defaultProps = {
|
||||
lookAt: [0, 0, 0],
|
||||
rotationX: 0,
|
||||
rotationY: 0,
|
||||
minDistance: 0,
|
||||
maxDistance: Infinity,
|
||||
fov: 50
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
// jshint esversion: 6
|
||||
import React, { PureComponent } from "react";
|
||||
|
||||
export class Popup extends PureComponent {
|
||||
render() {
|
||||
const { title, x, y } = this.props;
|
||||
const style = {
|
||||
position: "absolute",
|
||||
top: y,
|
||||
left: x,
|
||||
maxWidth: "200px",
|
||||
padding: "10px",
|
||||
color: "white",
|
||||
backgroundColor: "black",
|
||||
pointerEvents: "none",
|
||||
transform: "translate(10px, -50%)"
|
||||
};
|
||||
|
||||
const arrowStyle = {
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "-14px",
|
||||
width: "7px",
|
||||
height: "5px",
|
||||
boxSizing: "border-box",
|
||||
transform: "translateY(-50%)",
|
||||
border: "7px solid transparent",
|
||||
borderRight: "7px solid black"
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={style}>
|
||||
<div style={arrowStyle} />
|
||||
{title}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Popup.defaultProps = {
|
||||
id: "id",
|
||||
title: "",
|
||||
x: 0,
|
||||
y: 0
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import _ from "lodash";
|
||||
import Helmet from "react-helmet";
|
||||
import Container from "./framework/container";
|
||||
import { connect } from "react-redux";
|
||||
import PulseLoader from "halogen/PulseLoader";
|
||||
|
||||
import LeftSideBar from "./leftsidebar";
|
||||
import Parallel from "./continuous/parallel";
|
||||
import Legend from "./continuousLegend";
|
||||
import Joy from "./joy/joy";
|
||||
import Graph from "./graph/graph";
|
||||
import * as globals from "../globals";
|
||||
import actions from "../actions";
|
||||
|
||||
import SectionHeader from "./framework/sectionHeader";
|
||||
|
||||
@connect(state => {
|
||||
return {
|
||||
cells: state.cells
|
||||
};
|
||||
})
|
||||
class App extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
}
|
||||
_onURLChanged() {
|
||||
this.props.dispatch({ type: "url changed", url: document.location.href });
|
||||
}
|
||||
componentDidMount() {
|
||||
/* listen for url changes, fire one when we start the app up */
|
||||
window.addEventListener("popstate", this._onURLChanged);
|
||||
this._onURLChanged();
|
||||
|
||||
this.props.dispatch(actions.initialize());
|
||||
|
||||
/*
|
||||
first request includes query straight off the url bar for now
|
||||
*/
|
||||
this.props.dispatch(actions.requestCells(window.location.search));
|
||||
/* listen for resize events */
|
||||
window.addEventListener("resize", () => {
|
||||
this.props.dispatch({
|
||||
type: "window resize",
|
||||
data: {
|
||||
height: window.innerHeight,
|
||||
width: window.innerWidth
|
||||
}
|
||||
});
|
||||
});
|
||||
this.props.dispatch({
|
||||
type: "window resize",
|
||||
data: {
|
||||
height: window.innerHeight,
|
||||
width: window.innerWidth
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
// console.log('app:', this.props, this.state)
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Helmet title="cellxgene" />
|
||||
{this.props.cells.loading ? (
|
||||
<div
|
||||
style={{ position: "fixed", left: window.innerWidth / 2, top: 150 }}
|
||||
>
|
||||
<PulseLoader color="rgb(0,0,0)" size="10px" margin="4px" />
|
||||
<span
|
||||
style={{ fontFamily: globals.accentFont, fontStyle: "italic" }}
|
||||
>
|
||||
loading cells
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{this.props.cells.error ? "Error loading cells" : null}
|
||||
{false ? (
|
||||
<Joy data={this.state.expressions && this.state.expressions.data} />
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<div>
|
||||
<LeftSideBar />
|
||||
<div
|
||||
style={{
|
||||
padding: 15,
|
||||
width: 1440 - 410 /* but responsive */,
|
||||
marginLeft: 350 /* but responsive */
|
||||
}}
|
||||
>
|
||||
<Graph />
|
||||
<Legend />
|
||||
{/*<Parallel/>*/}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,208 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import _ from "lodash";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
import * as globals from "../../globals";
|
||||
import styles from "./categorical.css";
|
||||
import SectionHeader from "../framework/sectionHeader";
|
||||
import Value from "./value";
|
||||
import { alphabeticallySortedValues } from "./util";
|
||||
|
||||
import FaArrowRight from "react-icons/lib/fa/angle-right";
|
||||
import FaArrowDown from "react-icons/lib/fa/angle-down";
|
||||
import FaPaintBrush from "react-icons/lib/fa/paint-brush";
|
||||
|
||||
@connect(state => {
|
||||
return {
|
||||
colorAccessor: state.controls.colorAccessor
|
||||
};
|
||||
})
|
||||
class Category extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isChecked: true,
|
||||
isExpanded: false
|
||||
};
|
||||
}
|
||||
|
||||
handleColorChange() {
|
||||
this.props.dispatch({
|
||||
type: "color by categorical metadata",
|
||||
colorAccessor: this.props.metadataField
|
||||
});
|
||||
}
|
||||
toggleAll() {
|
||||
this.props.dispatch({
|
||||
type: "categorical metadata filter all of these",
|
||||
metadataField: this.props.metadataField
|
||||
});
|
||||
this.setState({ isChecked: true });
|
||||
}
|
||||
toggleNone() {
|
||||
this.props.dispatch({
|
||||
type: "categorical metadata filter none of these",
|
||||
metadataField: this.props.metadataField,
|
||||
value: this.props.value
|
||||
});
|
||||
this.setState({ isChecked: false });
|
||||
}
|
||||
renderCategoryItems() {
|
||||
return _.map(alphabeticallySortedValues(this.props.values), (v, i) => {
|
||||
return (
|
||||
<Value
|
||||
key={v}
|
||||
metadataField={this.props.metadataField}
|
||||
count={this.props.values[v]}
|
||||
value={v}
|
||||
i={i}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
// display: "flex",
|
||||
// alignItems: "baseline",
|
||||
maxWidth: globals.maxControlsWidth
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "baseline"
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
// flexShrink: 0,
|
||||
fontWeight: 500,
|
||||
// textAlign: "right",
|
||||
// fontFamily: globals.accentFont,
|
||||
// fontStyle: "italic",
|
||||
margin: "3px 10px 3px 0px"
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
display: "inline-block",
|
||||
position: "relative",
|
||||
top: 2
|
||||
}}
|
||||
onClick={() => {
|
||||
this.setState({ isExpanded: !this.state.isExpanded });
|
||||
}}
|
||||
>
|
||||
{this.state.isExpanded ? <FaArrowDown /> : <FaArrowRight />}
|
||||
</span>
|
||||
{this.props.metadataField}
|
||||
<input
|
||||
onChange={
|
||||
this.state.isChecked
|
||||
? this.toggleNone.bind(this)
|
||||
: this.toggleAll.bind(this)
|
||||
}
|
||||
checked={this.state.isChecked}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span
|
||||
onClick={this.handleColorChange.bind(this)}
|
||||
style={{
|
||||
fontSize: 16,
|
||||
marginLeft: 4,
|
||||
// padding: this.props.colorAccessor === this.props.metadataField ? 3 : "auto",
|
||||
borderRadius: 3,
|
||||
color:
|
||||
this.props.colorAccessor === this.props.metadataField
|
||||
? globals.brightBlue
|
||||
: "black",
|
||||
// backgroundColor: this.props.colorAccessor === this.props.metadataField ? globals.brightBlue : "inherit",
|
||||
display: "inline-block",
|
||||
position: "relative",
|
||||
top: 2,
|
||||
cursor: "pointer"
|
||||
}}
|
||||
>
|
||||
<FaPaintBrush />
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>{this.state.isExpanded ? this.renderCategoryItems() : null}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@connect(state => {
|
||||
const ranges =
|
||||
state.cells.cells && state.cells.cells.data.ranges
|
||||
? state.cells.cells.data.ranges
|
||||
: null;
|
||||
|
||||
return {
|
||||
ranges
|
||||
};
|
||||
})
|
||||
class Categories extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.props.ranges) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 310,
|
||||
marginRight: 40,
|
||||
paddingRight: 20,
|
||||
flexShrink: 0
|
||||
// height: 700,
|
||||
// overflow: "auto",
|
||||
}}
|
||||
>
|
||||
{_.map(this.props.ranges, (value, key) => {
|
||||
const isColorField = key.includes("color") || key.includes("Color");
|
||||
if (value.options && key !== "CellName" && !isColorField) {
|
||||
return (
|
||||
<Category key={key} metadataField={key} values={value.options} />
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Categories;
|
||||
|
||||
/*
|
||||
<SectionHeader text="Categorical Metadata"/>
|
||||
[on off] toggle hide deselected filters (shows a menu vs shows what you ordered in compact/narrative form. fold out animation.)
|
||||
|
||||
<p> Sort by: Alphabetical / Count || Show counts: true / false || Collapse: all / none</p>
|
||||
|
||||
|
||||
<p> <button> Field name [initial state] [create 2d graph] [explain part of 2d graph] </button> </p>
|
||||
<p> <button> Field name [initial state] [create hypothesis] [validate hypothesis] </button> </p>
|
||||
<p> <button> Field name [total] [selected in current filters] [selected in graph selection] </button> </p>
|
||||
<p> <button> Tumor [400] [40] [4] </button> </p>
|
||||
|
||||
might be interesting to show them as an 👁 icon, or with a slash through it, to allow for visible or hidden state
|
||||
|
||||
*/
|
||||
|
||||
/*
|
||||
|
||||
Each category has a color associated with it - ie., color by location should show up on these buttons,
|
||||
Does colorby live here? Like a global, with the category labels at the top? If not, we have to
|
||||
duplicate the category names somewhere else - but then, not all (like Cluster_2d_color) won't be listed here.
|
||||
|
||||
*/
|
||||
@@ -0,0 +1,8 @@
|
||||
// jshint esversion: 6
|
||||
export const alphabeticallySortedValues = values => {
|
||||
return Object.keys(values).sort((a, b) => {
|
||||
var textA = a.toUpperCase();
|
||||
var textB = b.toUpperCase();
|
||||
return textA < textB ? -1 : textA > textB ? 1 : 0;
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
// jshint esversion: 6
|
||||
import { connect } from "react-redux";
|
||||
import React from "react";
|
||||
import * as globals from "../../globals";
|
||||
import actions from "../../actions";
|
||||
|
||||
@connect(state => {
|
||||
return {
|
||||
categoricalAsBooleansMap: state.controls.categoricalAsBooleansMap,
|
||||
colorScale: state.controls.colorScale,
|
||||
colorAccessor: state.controls.colorAccessor
|
||||
};
|
||||
})
|
||||
class CategoryValue extends React.Component {
|
||||
toggleOff() {
|
||||
this.props.dispatch({
|
||||
type: "categorical metadata filter deselect",
|
||||
metadataField: this.props.metadataField,
|
||||
value: this.props.value
|
||||
});
|
||||
}
|
||||
|
||||
toggleOn() {
|
||||
this.props.dispatch({
|
||||
type: "categorical metadata filter select",
|
||||
metadataField: this.props.metadataField,
|
||||
value: this.props.value
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.props.categoricalAsBooleansMap) return null;
|
||||
|
||||
const selected = this.props.categoricalAsBooleansMap[
|
||||
this.props.metadataField
|
||||
][this.props.value];
|
||||
const c =
|
||||
this.props.metadataField ===
|
||||
this.props
|
||||
.colorAccessor; /* this is the color scale, so add swatches below */
|
||||
|
||||
return (
|
||||
<div
|
||||
key={this.props.i}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
fontWeight: selected ? 700 : 400
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
paddingLeft: 15,
|
||||
width: 200,
|
||||
flexShrink: 0,
|
||||
margin: 0
|
||||
// lineHeight: "1em"
|
||||
}}
|
||||
>
|
||||
<input
|
||||
style={{ position: "relative", top: 1 }}
|
||||
onChange={
|
||||
selected ? this.toggleOff.bind(this) : this.toggleOn.bind(this)
|
||||
}
|
||||
checked={selected}
|
||||
type="checkbox"
|
||||
/>
|
||||
{this.props.value}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
padding: "1px 10px",
|
||||
width: 80,
|
||||
textAlign: "center",
|
||||
backgroundColor: c
|
||||
? this.props.colorScale(this.props.value)
|
||||
: "inherit",
|
||||
color: c ? "white" : "black",
|
||||
margin: 0
|
||||
// lineHeight: "1em"
|
||||
}}
|
||||
>
|
||||
{this.props.count}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default CategoryValue;
|
||||
|
||||
// toggleOnlyThis() {
|
||||
// this.props.dispatch({
|
||||
// type: "categorical metadata filter none of these",
|
||||
// metadataField: this.props.metadataField,
|
||||
// value: this.props.value
|
||||
// })
|
||||
// }
|
||||
// <span
|
||||
// onClick={this.toggleOnlyThis.bind(this)}
|
||||
// style={{
|
||||
// fontFamily: globals.accentFont,
|
||||
// fontSize: 10,
|
||||
// fontWeight: 100,
|
||||
// fontStyle: "italic",
|
||||
// cursor: "pointer",
|
||||
// }}>
|
||||
// {"only"}
|
||||
// </span>
|
||||
|
||||
// onClick={selected ? this.toggleOff.bind(this) : this.toggleOn.bind(this)}
|
||||
// toggleOn() {
|
||||
// this.props.dispatch(
|
||||
// actions.attemptCategoricalMetadataSelection(
|
||||
// this.props.metadataField,
|
||||
// this.props.value
|
||||
// ))
|
||||
// }
|
||||
// toggleOff() {
|
||||
// this.props.dispatch(
|
||||
// actions.attemptCategoricalMetadataDeselection(
|
||||
// this.props.metadataField,
|
||||
// this.props.value
|
||||
// ))
|
||||
// }
|
||||
@@ -0,0 +1,94 @@
|
||||
// jshint esversion: 6
|
||||
/* rc slider https://www.npmjs.com/package/rc-slider */
|
||||
|
||||
import React from "react";
|
||||
import _ from "lodash";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
import styles from "./parallelCoordinates.css";
|
||||
import SectionHeader from "../framework/sectionHeader";
|
||||
|
||||
import setupParallelCoordinates from "./setupParallelCoordinates";
|
||||
import drawAxes from "./drawAxes";
|
||||
import drawLinesCanvas from "./drawLinesCanvas";
|
||||
|
||||
import HistogramBrush from "./histogramBrush";
|
||||
|
||||
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,
|
||||
graphBrushSelection: state.controls.graphBrushSelection,
|
||||
axesHaveBeenDrawn: state.controls.axesHaveBeenDrawn
|
||||
};
|
||||
})
|
||||
class Continuous extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
svg: null,
|
||||
ctx: null,
|
||||
axes: null,
|
||||
dimensions: null
|
||||
};
|
||||
}
|
||||
componentDidMount() {}
|
||||
componentWillReceiveProps(nextProps) {}
|
||||
|
||||
componentDidMount() {}
|
||||
handleBrushAction(selection) {
|
||||
this.props.dispatch({
|
||||
type: "continuous selection using parallel coords brushing",
|
||||
data: selection
|
||||
});
|
||||
}
|
||||
handleColorAction(key) {
|
||||
this.props.dispatch({
|
||||
type: "color by continuous metadata",
|
||||
colorAccessor: key,
|
||||
rangeMaxForColorAccessor: this.props.initializeRanges[key].range.max
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
{_.map(this.props.ranges, (value, key) => {
|
||||
const isColorField = key.includes("color") || key.includes("Color");
|
||||
if (value.range && key !== "CellName" && !isColorField) {
|
||||
return (
|
||||
<HistogramBrush
|
||||
key={key}
|
||||
metadataField={key}
|
||||
ranges={value.range}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Continuous;
|
||||
|
||||
// <SectionHeader text="Continuous Metadata"/>
|
||||
@@ -0,0 +1,88 @@
|
||||
// jshint esversion: 6
|
||||
import styles from "./parallelCoordinates.css";
|
||||
import { yAxis, brushstart } from "./util";
|
||||
|
||||
const drawAxes = (
|
||||
svg,
|
||||
ctx,
|
||||
dimensions,
|
||||
xscale,
|
||||
height,
|
||||
width,
|
||||
handleBrushAction,
|
||||
handleColorAction
|
||||
) => {
|
||||
/*****************************************
|
||||
******************************************
|
||||
Handles a brush event, toggling the display of foreground lines.
|
||||
******************************************
|
||||
******************************************/
|
||||
|
||||
function brush() {
|
||||
var actives = [];
|
||||
svg
|
||||
.selectAll(".parcoords_axis .parcoords_brush")
|
||||
.filter(function(d) {
|
||||
return d3.brushSelection(this);
|
||||
})
|
||||
.each(function(d) {
|
||||
actives.push({
|
||||
dimension: d,
|
||||
extent: d3.brushSelection(this)
|
||||
});
|
||||
});
|
||||
/* fire action, with selected dimensions & their values */
|
||||
handleBrushAction(actives);
|
||||
}
|
||||
|
||||
var axes = svg
|
||||
.selectAll(".parcoords_axis")
|
||||
.data(dimensions)
|
||||
.enter()
|
||||
.append("g")
|
||||
.attr("class", `${styles.axis} parcoords_axis`)
|
||||
.attr("transform", (d, i) => {
|
||||
return "translate(" + xscale(i) + ")";
|
||||
});
|
||||
|
||||
axes
|
||||
.append("g")
|
||||
.each(function(d) {
|
||||
var renderAxis =
|
||||
"axis" in d
|
||||
? d.axis.scale(d.scale) // custom axis
|
||||
: yAxis.scale(d.scale); // default axis
|
||||
d3.select(this).call(renderAxis);
|
||||
})
|
||||
.append("text")
|
||||
.on("click", d => {
|
||||
handleColorAction(d.key);
|
||||
})
|
||||
.attr("class", styles.title)
|
||||
.attr("text-anchor", "start")
|
||||
.text(function(d) {
|
||||
return "description" in d ? d.description + " 🖌️" : d.key + " 🖌️";
|
||||
});
|
||||
|
||||
// Add and store a brush for each axis.
|
||||
axes
|
||||
.append("g")
|
||||
.attr("class", `${styles.brush} parcoords_brush`)
|
||||
.each(function(d) {
|
||||
d3.select(this).call(
|
||||
(d.brush = d3
|
||||
.brushY()
|
||||
.extent([[-10, 0], [10, height]])
|
||||
.on("start", brushstart)
|
||||
.on("brush", brush)
|
||||
.on("end", brush))
|
||||
);
|
||||
})
|
||||
.selectAll("rect")
|
||||
.attr("x", -8)
|
||||
.attr("width", 16);
|
||||
|
||||
return axes;
|
||||
};
|
||||
|
||||
export default drawAxes;
|
||||
@@ -0,0 +1,97 @@
|
||||
// jshint esversion: 6
|
||||
import _ from "lodash";
|
||||
import { project } from "./util";
|
||||
|
||||
import renderQueue from "../../util/renderQueue";
|
||||
|
||||
/*****************************************
|
||||
******************************************
|
||||
draw loop
|
||||
******************************************
|
||||
******************************************/
|
||||
|
||||
const drawLinesCanvas = (
|
||||
ctx,
|
||||
dimensions,
|
||||
xscale,
|
||||
colorAccessor,
|
||||
colorScale
|
||||
) => {
|
||||
return d => {
|
||||
ctx.globalAlpha = 0.1;
|
||||
|
||||
if (d["__selected__"]) {
|
||||
ctx.strokeStyle = d["__color__"];
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
var coords = project(d, dimensions, xscale);
|
||||
coords.forEach((p, i) => {
|
||||
// this tricky bit avoids rendering null values as 0
|
||||
if (p === null) {
|
||||
// this bit renders horizontal lines on the previous/next
|
||||
// dimensions, so that sandwiched null values are visible
|
||||
if (i > 0) {
|
||||
var prev = coords[i - 1];
|
||||
if (prev !== null) {
|
||||
ctx.moveTo(prev[0], prev[1]);
|
||||
ctx.lineTo(prev[0] + 6, prev[1]);
|
||||
}
|
||||
}
|
||||
if (i < coords.length - 1) {
|
||||
var next = coords[i + 1];
|
||||
if (next !== null) {
|
||||
ctx.moveTo(next[0] - 6, next[1]);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (i == 0) {
|
||||
ctx.moveTo(p[0], p[1]);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.lineTo(p[0], p[1]);
|
||||
});
|
||||
ctx.stroke();
|
||||
};
|
||||
};
|
||||
|
||||
const drawCellLinesUsingRenderQueue = (
|
||||
metadata,
|
||||
dimensions,
|
||||
xscale,
|
||||
ctx,
|
||||
colorAccessor,
|
||||
colorScale
|
||||
) => {
|
||||
const _renderLinesWithQueue = renderQueue(
|
||||
drawLinesCanvas(ctx, dimensions, xscale, colorAccessor, colorScale)
|
||||
).rate(50);
|
||||
_renderLinesWithQueue(metadata);
|
||||
return _renderLinesWithQueue;
|
||||
};
|
||||
|
||||
const drawCellLinesSync = (
|
||||
metadata,
|
||||
dimensions,
|
||||
xscale,
|
||||
ctx,
|
||||
colorAccessor,
|
||||
colorScale
|
||||
) => {
|
||||
const _draw = drawLinesCanvas(
|
||||
ctx,
|
||||
dimensions,
|
||||
xscale,
|
||||
colorAccessor,
|
||||
colorScale
|
||||
);
|
||||
_.each(metadata, _draw);
|
||||
};
|
||||
|
||||
export default drawCellLinesUsingRenderQueue;
|
||||
// export default drawCellLinesUsingRenderQueue;
|
||||
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
https://bl.ocks.org/mbostock/4341954
|
||||
https://bl.ocks.org/mbostock/34f08d5e11952a80609169b7917d4172
|
||||
https://bl.ocks.org/SpaceActuary/2f004899ea1b2bd78d6f1dbb2febf771
|
||||
*/
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import _ from "lodash";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
@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 {
|
||||
colorAccessor: state.controls.colorAccessor,
|
||||
colorScale: state.controls.colorScale,
|
||||
cellsMetadata: state.controls.cellsMetadata
|
||||
};
|
||||
})
|
||||
class HistogramBrush extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.width = 300;
|
||||
this.height = 100;
|
||||
this.marginBottom = 20;
|
||||
this.histogramCache = {};
|
||||
|
||||
this.state = {
|
||||
svg: null,
|
||||
ctx: null,
|
||||
axes: null,
|
||||
dimensions: null,
|
||||
brush: null
|
||||
};
|
||||
}
|
||||
componentDidMount() {}
|
||||
componentDidUpdate() {}
|
||||
|
||||
calcHistogramCache(nextProps) {
|
||||
// recalculate expensive stuff
|
||||
const allValuesForContinuousFieldAsArray = _.map(
|
||||
nextProps.cellsMetadata,
|
||||
nextProps.metadataField
|
||||
);
|
||||
|
||||
this.histogramCache.x = d3
|
||||
.scaleLinear()
|
||||
.domain([nextProps.ranges.min, nextProps.ranges.max])
|
||||
.range([0, this.width]);
|
||||
|
||||
this.histogramCache.y = d3
|
||||
.scaleLinear()
|
||||
.range([this.height - this.marginBottom, 0]);
|
||||
// .range([height - margin.bottom, margin.top]);
|
||||
|
||||
this.histogramCache.bins = d3
|
||||
.histogram()
|
||||
.domain(this.histogramCache.x.domain())
|
||||
.thresholds(40)(allValuesForContinuousFieldAsArray);
|
||||
|
||||
this.histogramCache.numValues = allValuesForContinuousFieldAsArray.length;
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.calcHistogramCache(this.props);
|
||||
}
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (
|
||||
this.props.metadataField !== nextProps.metadataField ||
|
||||
!this.histogramCache.x
|
||||
) {
|
||||
this.calcHistogramCache(nextProps);
|
||||
}
|
||||
}
|
||||
|
||||
onBrush(selection, x) {
|
||||
return () => {
|
||||
if (d3.event.selection) {
|
||||
this.props.dispatch({
|
||||
type: "continuous metadata histogram brush",
|
||||
selection: this.props.metadataField,
|
||||
range: [x(d3.event.selection[0]), x(d3.event.selection[1])]
|
||||
});
|
||||
} else {
|
||||
this.props.dispatch({
|
||||
type: "continuous metadata histogram brush",
|
||||
selection: this.props.metadataField,
|
||||
range: null
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
drawHistogram(svgRef) {
|
||||
const x = this.histogramCache.x;
|
||||
const y = this.histogramCache.y;
|
||||
const bins = this.histogramCache.bins;
|
||||
const numValues = this.histogramCache.numValues;
|
||||
|
||||
d3
|
||||
.select(svgRef)
|
||||
.insert("g", "*")
|
||||
.attr("fill", "#bbb")
|
||||
.selectAll("rect")
|
||||
.data(bins)
|
||||
.enter()
|
||||
.append("rect")
|
||||
.attr("x", function(d) {
|
||||
return x(d.x0) + 1;
|
||||
})
|
||||
.attr("y", function(d) {
|
||||
return y(d.length / numValues);
|
||||
})
|
||||
.attr("width", function(d) {
|
||||
return Math.abs(x(d.x1) - x(d.x0) - 1);
|
||||
})
|
||||
.attr("height", function(d) {
|
||||
return y(0) - y(d.length / numValues);
|
||||
});
|
||||
|
||||
if (!this.state.brush && !this.state.axis) {
|
||||
const brush = d3
|
||||
.select(svgRef)
|
||||
.append("g")
|
||||
.attr("class", "brush")
|
||||
.call(
|
||||
d3
|
||||
.brushX()
|
||||
.on(
|
||||
"end",
|
||||
this.onBrush(this.props.metadataField, x.invert).bind(this)
|
||||
)
|
||||
);
|
||||
|
||||
const xAxis = d3
|
||||
.select(svgRef)
|
||||
.append("g")
|
||||
.attr("class", "axis axis--x")
|
||||
.attr(
|
||||
"transform",
|
||||
"translate(0," + (this.height - this.marginBottom) + ")"
|
||||
)
|
||||
.call(d3.axisBottom(x).ticks(5))
|
||||
.append("text")
|
||||
.attr("x", 300)
|
||||
.attr("y", -6)
|
||||
.attr("fill", "#000")
|
||||
.attr("text-anchor", "end")
|
||||
.attr("font-weight", "bold")
|
||||
.text(this.props.metadataField);
|
||||
|
||||
this.setState({ brush, xAxis });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
style={{ marginTop: 10 }}
|
||||
id={"histogram_" + this.props.metadataField}
|
||||
>
|
||||
<svg
|
||||
width={this.width}
|
||||
height={this.height}
|
||||
ref={svgRef => {
|
||||
this.drawHistogram(svgRef);
|
||||
}}
|
||||
>
|
||||
{this.props.ranges.min}
|
||||
{" to "}
|
||||
{this.props.ranges.max}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default HistogramBrush;
|
||||
@@ -0,0 +1,155 @@
|
||||
// jshint esversion: 6
|
||||
/* rc slider https://www.npmjs.com/package/rc-slider */
|
||||
|
||||
import React from "react";
|
||||
import _ from "lodash";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
import styles from "./parallelCoordinates.css";
|
||||
import SectionHeader from "../framework/sectionHeader";
|
||||
|
||||
import setupParallelCoordinates from "./setupParallelCoordinates";
|
||||
import drawAxes from "./drawAxes";
|
||||
import drawLinesCanvas from "./drawLinesCanvas";
|
||||
|
||||
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,
|
||||
graphBrushSelection: state.controls.graphBrushSelection,
|
||||
cellsMetadata: state.controls.cellsMetadata,
|
||||
axesHaveBeenDrawn: state.controls.axesHaveBeenDrawn
|
||||
};
|
||||
})
|
||||
class Parallel extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
svg: null,
|
||||
ctx: null,
|
||||
axes: null,
|
||||
dimensions: null
|
||||
};
|
||||
}
|
||||
componentDidMount() {
|
||||
const { svg, ctx } = setupParallelCoordinates(width, height, margin);
|
||||
this.setState({ svg, ctx });
|
||||
}
|
||||
componentWillReceiveProps(nextProps) {
|
||||
this.maybeDrawAxes(nextProps);
|
||||
this.maybeDrawLines(nextProps);
|
||||
}
|
||||
maybeDrawAxes(nextProps) {
|
||||
if (
|
||||
!this.state.axes &&
|
||||
nextProps.initializeRanges /* axes are created on full range of data */
|
||||
) {
|
||||
const dimensions = createDimensions(nextProps.initializeRanges);
|
||||
|
||||
const xscale = d3
|
||||
.scalePoint()
|
||||
.domain(d3.range(dimensions.length))
|
||||
.range([0, width]);
|
||||
|
||||
const axes = drawAxes(
|
||||
this.state.svg,
|
||||
this.state.ctx,
|
||||
dimensions,
|
||||
xscale,
|
||||
height,
|
||||
width,
|
||||
this.handleBrushAction.bind(this),
|
||||
this.handleColorAction.bind(this)
|
||||
);
|
||||
|
||||
this.setState({
|
||||
axes,
|
||||
xscale,
|
||||
dimensions
|
||||
});
|
||||
|
||||
this.props.dispatch({
|
||||
type: "parallel coordinates axes have been drawn"
|
||||
});
|
||||
}
|
||||
}
|
||||
maybeDrawLines = _.debounce(nextProps => {
|
||||
/* https://stackoverflow.com/questions/23123138/perform-debounce-in-react-js */
|
||||
if (
|
||||
nextProps.ranges &&
|
||||
nextProps.cellsMetadata &&
|
||||
nextProps.axesHaveBeenDrawn
|
||||
) {
|
||||
if (this.state._drawLinesCanvas) {
|
||||
this.state._drawLinesCanvas.invalidate(); /* this is only necessary if the internals of drawLinesCanvas are using the render queue */
|
||||
}
|
||||
|
||||
this.state.ctx.clearRect(0, 0, width, height);
|
||||
|
||||
const _drawLinesCanvas = drawLinesCanvas(
|
||||
nextProps.cellsMetadata,
|
||||
this.state.dimensions,
|
||||
this.state.xscale,
|
||||
this.state.ctx,
|
||||
nextProps.colorAccessor,
|
||||
nextProps.colorScale
|
||||
);
|
||||
|
||||
this.setState({
|
||||
_drawLinesCanvas /* this will only exist if the internals of drawLinesCanvas are using the render queue */
|
||||
});
|
||||
}
|
||||
}, 200);
|
||||
|
||||
handleBrushAction(selection) {
|
||||
this.props.dispatch({
|
||||
type: "continuous selection using parallel coords brushing",
|
||||
data: selection
|
||||
});
|
||||
}
|
||||
handleColorAction(key) {
|
||||
this.props.dispatch({
|
||||
type: "color by continuous metadata",
|
||||
colorAccessor: key,
|
||||
rangeMaxForColorAccessor: this.props.initializeRanges[key].range.max
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div id="parcoords_wrapper">
|
||||
<div
|
||||
className={styles.parcoords}
|
||||
id="parcoords"
|
||||
style={{
|
||||
width: width + margin.left + margin.right + "px",
|
||||
height: height + margin.top + margin.bottom + "px"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Parallel;
|
||||
|
||||
// <SectionHeader text="Continuous Metadata"/>
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
|
||||
This code can be found: https://bl.ocks.org/syntagmatic/05a5b0897a48890133beb59c815bd953
|
||||
|
||||
body {
|
||||
min-width: 760px;
|
||||
}
|
||||
*/
|
||||
|
||||
.parcoords {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.parcoords svg,
|
||||
.parcoords canvas {
|
||||
font: 10px sans-serif;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.parcoords canvas {
|
||||
opacity: 0.9;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.axis .title {
|
||||
font-size: 10px;
|
||||
transform: rotate(-21deg) translate(-5px,-6px);
|
||||
fill: #222;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* -webkit-filter: grayscale(100%);
|
||||
filter: grayscale(100%); */
|
||||
|
||||
.axis line,
|
||||
.axis path {
|
||||
fill: none;
|
||||
stroke: #ccc;
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
.axis .tick text {
|
||||
fill: #222;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/*
|
||||
old, from reference bl.ocks
|
||||
.axis.manufac_name .tick text,
|
||||
.axis.food_group .tick text {
|
||||
opacity: 1;
|
||||
}
|
||||
*/
|
||||
|
||||
.axis:hover line,
|
||||
.axis:hover path,
|
||||
.axis.active line,
|
||||
.axis.active path {
|
||||
fill: none;
|
||||
stroke: #222;
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
.axis:hover .title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.axis:hover .tick text {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.axis.active .title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.axis.active .tick text {
|
||||
opacity: 1;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.brush .extent {
|
||||
fill-opacity: .3;
|
||||
stroke: #fff;
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
.pre {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
margin: 6px 12px;
|
||||
tab-size: 40;
|
||||
font-size: 10px;
|
||||
overflow: auto;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*****************************************
|
||||
******************************************
|
||||
Setup SVG & Canvas elements
|
||||
******************************************
|
||||
******************************************/
|
||||
// jshint esversion: 6
|
||||
const setupParallelCoordinates = (width, height, margin) => {
|
||||
var container = d3.select("#parcoords");
|
||||
|
||||
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-top", margin.top + "px")
|
||||
.style("margin-left", margin.left + "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 setupParallelCoordinates;
|
||||
@@ -0,0 +1,57 @@
|
||||
// jshint esversion: 6
|
||||
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 = 340 - margin.top - margin.bottom;
|
||||
export const innerHeight = height - 2;
|
||||
|
||||
export const devicePixelRatio = window.devicePixelRatio || 1;
|
||||
|
||||
export const createDimensions = data => {
|
||||
const newArr = [];
|
||||
_.each(data, (value, key) => {
|
||||
if (value.range) {
|
||||
newArr.push({
|
||||
key: key /* room for confusion: lodash calls this key, it's also the name of the property parallel coords code is looking for */,
|
||||
type: {
|
||||
within: (d, extent, dim) => {
|
||||
return extent[0] <= dim.scale(d) && dim.scale(d) <= extent[1];
|
||||
}
|
||||
},
|
||||
scale: d3
|
||||
.scaleSqrt()
|
||||
.range([innerHeight, 0])
|
||||
.domain([0, value.range.max])
|
||||
});
|
||||
}
|
||||
});
|
||||
return newArr;
|
||||
};
|
||||
|
||||
export const yAxis = d3.axisLeft();
|
||||
|
||||
export const brushstart = () => {
|
||||
d3.event.sourceEvent.stopPropagation();
|
||||
};
|
||||
|
||||
export const d3_functor = v => {
|
||||
return typeof v === "function"
|
||||
? v
|
||||
: () => {
|
||||
return v;
|
||||
};
|
||||
};
|
||||
|
||||
export const project = (d, dimensions, xscale) => {
|
||||
return dimensions.map((p, i) => {
|
||||
// check if data element has property and contains a value
|
||||
if (!(p.key in d) || d[p.key] === null) return null;
|
||||
|
||||
return [xscale(i), p.scale(d[p.key])];
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import _ from "lodash";
|
||||
import { connect } from "react-redux";
|
||||
import * as globals from "../../globals";
|
||||
|
||||
// create continuous color legend
|
||||
// http://bl.ocks.org/syntagmatic/e8ccca52559796be775553b467593a9f
|
||||
const continuous = (selector_id, colorscale) => {
|
||||
var legendheight = 200,
|
||||
legendwidth = 80,
|
||||
margin = { top: 10, right: 60, bottom: 10, left: 2 };
|
||||
|
||||
var canvas = d3
|
||||
.select(selector_id)
|
||||
.style("height", legendheight + "px")
|
||||
.style("width", legendwidth + "px")
|
||||
// .style("position", "relative")
|
||||
.append("canvas")
|
||||
.attr("height", legendheight - margin.top - margin.bottom)
|
||||
.attr("width", 1)
|
||||
.style("height", legendheight - margin.top - margin.bottom + "px")
|
||||
.style("width", legendwidth - margin.left - margin.right + "px")
|
||||
// .style("border", "1px solid #000")
|
||||
.style("position", "absolute")
|
||||
.style("top", margin.top + 1 + "px")
|
||||
.style("left", margin.left + 1 + "px")
|
||||
.style(
|
||||
"transform",
|
||||
"scale(1,-1)"
|
||||
) /* flip it! dark is high value light is low. we flip the color scale as well [1, 0] instead of [0, 1] */
|
||||
.node();
|
||||
|
||||
var ctx = canvas.getContext("2d");
|
||||
|
||||
var legendscale = d3
|
||||
.scaleLinear()
|
||||
.range([1, legendheight - margin.top - margin.bottom])
|
||||
.domain([
|
||||
colorscale.domain()[1],
|
||||
colorscale.domain()[0]
|
||||
]); /* we flip this to make viridis colors dark if high in the color scale */
|
||||
|
||||
// image data hackery based on http://bl.ocks.org/mbostock/048d21cf747371b11884f75ad896e5a5
|
||||
var image = ctx.createImageData(1, legendheight);
|
||||
d3.range(legendheight).forEach(function(i) {
|
||||
var c = d3.rgb(colorscale(legendscale.invert(i)));
|
||||
image.data[4 * i] = c.r;
|
||||
image.data[4 * i + 1] = c.g;
|
||||
image.data[4 * i + 2] = c.b;
|
||||
image.data[4 * i + 3] = 255;
|
||||
});
|
||||
ctx.putImageData(image, 0, 0);
|
||||
|
||||
// A simpler way to do the above, but possibly slower. keep in mind the legend width is stretched because the width attr of the canvas is 1
|
||||
// See http://stackoverflow.com/questions/4899799/whats-the-best-way-to-set-a-single-pixel-in-an-html5-canvas
|
||||
/*
|
||||
d3.range(legendheight).forEach(function(i) {
|
||||
ctx.fillStyle = colorscale(legendscale.invert(i));
|
||||
ctx.fillRect(0,i,1,1);
|
||||
});
|
||||
*/
|
||||
|
||||
var legendaxis = d3
|
||||
.axisRight()
|
||||
.scale(legendscale)
|
||||
.tickSize(6)
|
||||
.ticks(8);
|
||||
|
||||
var svg = d3
|
||||
.select(selector_id)
|
||||
.append("svg")
|
||||
.attr("height", legendheight + "px")
|
||||
.attr("width", legendwidth + "px")
|
||||
.style("position", "absolute")
|
||||
.style("left", "0px")
|
||||
.style("top", "0px");
|
||||
|
||||
svg
|
||||
.append("g")
|
||||
.attr("class", "axis")
|
||||
.attr(
|
||||
"transform",
|
||||
"translate(" +
|
||||
(legendwidth - margin.left - margin.right + 3) +
|
||||
"," +
|
||||
margin.top +
|
||||
")"
|
||||
)
|
||||
.call(legendaxis);
|
||||
};
|
||||
|
||||
@connect(state => {
|
||||
return {
|
||||
colorAccessor: state.controls.colorAccessor,
|
||||
colorScale: state.controls.colorScale,
|
||||
responsive: state.responsive
|
||||
};
|
||||
})
|
||||
class ContinuousLegend extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
}
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.colorAccessor !== this.props.colorAccessor) {
|
||||
/* always remove it, if it's not continuous we don't put it back. */
|
||||
d3
|
||||
.select("#continuous_legend")
|
||||
.selectAll("*")
|
||||
.remove();
|
||||
}
|
||||
|
||||
if (nextProps.colorAccessor && nextProps.colorScale) {
|
||||
/* fragile! continuous range is 0 to 1, not [#fa4b2c, ...], make this a flag? */
|
||||
if (nextProps.colorScale.range()[0][0] !== "#") {
|
||||
continuous(
|
||||
"#continuous_legend",
|
||||
d3
|
||||
.scaleSequential(d3.interpolateViridis)
|
||||
.domain(nextProps.colorScale.domain())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
drawScale() {}
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
id="continuous_legend"
|
||||
style={{
|
||||
position: "fixed",
|
||||
display: this.props.colorAccessor ? "inherit" : "none",
|
||||
right: 0,
|
||||
top: this.props.responsive.height / 2
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ContinuousLegend;
|
||||
@@ -0,0 +1,77 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import _ from "lodash";
|
||||
import { connect } from "react-redux";
|
||||
import * as globals from "../../globals";
|
||||
import styles from "../framework/buttons.css";
|
||||
import actions from "../../actions";
|
||||
|
||||
@connect()
|
||||
class CellSetButton extends React.Component {
|
||||
set() {
|
||||
const set = _.map(this.props.crossfilter.cells.allFiltered(), "CellName");
|
||||
|
||||
this.props.dispatch({
|
||||
type:
|
||||
"store current cell selection as differential set " +
|
||||
this.props.eitherCellSetOneOrTwo,
|
||||
data: set
|
||||
});
|
||||
}
|
||||
render() {
|
||||
return (
|
||||
<span style={{ marginRight: 10 }}>
|
||||
<button
|
||||
style={{
|
||||
color: "#FFF",
|
||||
borderRadius: 2,
|
||||
padding: "0px 10px",
|
||||
height: 30,
|
||||
backgroundColor: globals.brightBlue,
|
||||
border: "none",
|
||||
cursor: "pointer"
|
||||
}}
|
||||
onClick={this.set.bind(this)}
|
||||
>
|
||||
<span style={{ fontSize: 24, fontWeight: 700 }}>
|
||||
{" "}
|
||||
{this.props.eitherCellSetOneOrTwo}{" "}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "Georgia",
|
||||
fontStyle: "italic",
|
||||
marginLeft: 8,
|
||||
position: "relative",
|
||||
top: -3
|
||||
}}
|
||||
>
|
||||
{this.props.differential[
|
||||
"celllist" + this.props.eitherCellSetOneOrTwo
|
||||
]
|
||||
? this.props.differential[
|
||||
"celllist" + this.props.eitherCellSetOneOrTwo
|
||||
].length + " cells"
|
||||
: 0 + " cells"}
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default CellSetButton;
|
||||
|
||||
//
|
||||
// <button style={{
|
||||
// marginLeft: 3,
|
||||
// color: "#FFF",
|
||||
// padding: "0px 10px",
|
||||
// height: 30,
|
||||
// backgroundColor: globals.brightBlue,
|
||||
// border: "none",
|
||||
// cursor: "pointer",
|
||||
// }}>
|
||||
// <span style={{fontSize: 24, fontWeight: 700}}> X </span>
|
||||
//
|
||||
// </button>
|
||||
@@ -0,0 +1,323 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import _ from "lodash";
|
||||
import { connect } from "react-redux";
|
||||
import * as globals from "../../globals";
|
||||
import styles from "./expression.css";
|
||||
import SectionHeader from "../framework/sectionHeader";
|
||||
import actions from "../../actions";
|
||||
import ReactAutocomplete from "react-autocomplete"; /* http://emilebres.github.io/react-virtualized-checkbox/ */
|
||||
import getContrast from "font-color-contrast"; // https://www.npmjs.com/package/font-color-contrast
|
||||
import FaPaintBrush from "react-icons/lib/fa/paint-brush";
|
||||
|
||||
class HeatmapSquare extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
value: ""
|
||||
};
|
||||
}
|
||||
render() {
|
||||
const contrastColor = getContrast(
|
||||
this.props.backgroundColor
|
||||
.substring(4, this.props.backgroundColor.length - 1)
|
||||
.replace(/ /g, "")
|
||||
.split(",")
|
||||
);
|
||||
return (
|
||||
<p
|
||||
style={{
|
||||
padding: "12px 6px",
|
||||
textAlign: "center",
|
||||
color: contrastColor,
|
||||
width: 40,
|
||||
flexShrink: 0,
|
||||
fontSize: 12,
|
||||
margin: 0,
|
||||
backgroundColor: this.props.backgroundColor
|
||||
}}
|
||||
>
|
||||
{this.props.text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**********************************
|
||||
***********************************
|
||||
***********************************
|
||||
Row
|
||||
***********************************
|
||||
***********************************
|
||||
**********************************/
|
||||
@connect(state => {
|
||||
return {
|
||||
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
|
||||
scatterplotYYaccessor: state.controls.scatterplotYYaccessor,
|
||||
colorAccessor: state.controls.colorAccessor
|
||||
};
|
||||
})
|
||||
class HeatmapRow extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
value: ""
|
||||
};
|
||||
}
|
||||
handleGeneColorScaleClick(gene) {
|
||||
return () => {
|
||||
this.props.dispatch(
|
||||
actions.requestSingleGeneExpressionCountsForColoringPOST(
|
||||
this.props.gene
|
||||
)
|
||||
);
|
||||
};
|
||||
}
|
||||
handleSetGeneAsScatterplotX(gene) {
|
||||
return () => {
|
||||
this.props.dispatch({
|
||||
type: "set scatterplot x",
|
||||
data: this.props.gene
|
||||
});
|
||||
};
|
||||
}
|
||||
handleSetGeneAsScatterplotY(gene) {
|
||||
return () => {
|
||||
this.props.dispatch({
|
||||
type: "set scatterplot y",
|
||||
data: this.props.gene
|
||||
});
|
||||
};
|
||||
}
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 220,
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "baseline"
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 150, flexShrink: 0 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14
|
||||
}}
|
||||
>
|
||||
{this.props.gene}
|
||||
</span>
|
||||
</div>
|
||||
<HeatmapSquare
|
||||
backgroundColor={this.props.greyColorScale(this.props.set1exp)}
|
||||
text={this.props.set1exp}
|
||||
/>
|
||||
<HeatmapSquare
|
||||
backgroundColor={this.props.greyColorScale(this.props.set2exp)}
|
||||
text={this.props.set2exp}
|
||||
/>
|
||||
<span
|
||||
title={this.props.aveDiff}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
marginLeft: 10,
|
||||
marginRight: 10,
|
||||
}}
|
||||
>
|
||||
{this.props.aveDiff.toFixed(2)}
|
||||
</span>
|
||||
<span
|
||||
onClick={this.handleSetGeneAsScatterplotX(this.props.gene).bind(
|
||||
this
|
||||
)}
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color:
|
||||
this.props.scatterplotXXaccessor === this.props.gene
|
||||
? "white"
|
||||
: globals.brightBlue,
|
||||
cursor: "pointer",
|
||||
position: "relative",
|
||||
top: 1,
|
||||
fontWeight: 700,
|
||||
marginRight: 4,
|
||||
borderRadius: 3,
|
||||
padding: "2px 3px",
|
||||
backgroundColor:
|
||||
this.props.scatterplotXXaccessor === this.props.gene
|
||||
? globals.brightBlue
|
||||
: "inherit"
|
||||
}}
|
||||
>
|
||||
X
|
||||
</span>
|
||||
<span
|
||||
onClick={this.handleSetGeneAsScatterplotY(this.props.gene).bind(
|
||||
this
|
||||
)}
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color:
|
||||
this.props.scatterplotYYaccessor === this.props.gene
|
||||
? "white"
|
||||
: globals.brightBlue,
|
||||
cursor: "pointer",
|
||||
position: "relative",
|
||||
top: 1,
|
||||
fontWeight: 700,
|
||||
marginRight: 4,
|
||||
borderRadius: 3,
|
||||
padding: "2px 3px",
|
||||
backgroundColor:
|
||||
this.props.scatterplotYYaccessor === this.props.gene
|
||||
? globals.brightBlue
|
||||
: "inherit"
|
||||
}}
|
||||
>
|
||||
Y
|
||||
</span>
|
||||
<span
|
||||
onClick={this.handleGeneColorScaleClick(this.props.gene).bind(this)}
|
||||
style={{
|
||||
fontSize: 16,
|
||||
cursor: "pointer",
|
||||
position: "relative",
|
||||
marginRight: 6,
|
||||
borderRadius: 3,
|
||||
padding: "0px 2px 2px 2px",
|
||||
color:
|
||||
this.props.colorAccessor === this.props.gene
|
||||
? "white"
|
||||
: "inherit",
|
||||
backgroundColor:
|
||||
this.props.colorAccessor === this.props.gene
|
||||
? globals.brightBlue
|
||||
: "inherit"
|
||||
}}
|
||||
>
|
||||
<FaPaintBrush style={{ display: "inline-block" }} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**********************************
|
||||
***********************************
|
||||
***********************************
|
||||
DiffExp Heatmap
|
||||
***********************************
|
||||
***********************************
|
||||
**********************************/
|
||||
|
||||
@connect(state => {
|
||||
return {
|
||||
differential: state.differential,
|
||||
allGeneNames: state.controls.allGeneNames
|
||||
};
|
||||
})
|
||||
class Heatmap extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
value: ""
|
||||
};
|
||||
}
|
||||
render() {
|
||||
if (!this.props.differential.diffExp)
|
||||
return <p>Select cells & compute differential to see heatmap</p>;
|
||||
|
||||
const topGenesForCellSet1 = this.props.differential.diffExp.data.celllist1;
|
||||
const topGenesForCellSet2 = this.props.differential.diffExp.data.celllist2;
|
||||
|
||||
const extent = d3.extent(
|
||||
_.union(
|
||||
topGenesForCellSet1.mean_expression_cellset1,
|
||||
topGenesForCellSet1.mean_expression_cellset2,
|
||||
topGenesForCellSet2.mean_expression_cellset1,
|
||||
topGenesForCellSet2.mean_expression_cellset2
|
||||
)
|
||||
);
|
||||
|
||||
const greyColorScale = d3
|
||||
.scaleSequential()
|
||||
.domain(extent)
|
||||
.interpolator(d3.interpolateGreys);
|
||||
|
||||
return (
|
||||
<div>
|
||||
Color by any gene:
|
||||
<ReactAutocomplete
|
||||
items={this.props.allGeneNames}
|
||||
shouldItemRender={(item, value) =>
|
||||
item.toLowerCase().indexOf(value.toLowerCase()) > -1
|
||||
}
|
||||
getItemValue={item => item}
|
||||
renderItem={(item, highlighted) => (
|
||||
<div
|
||||
key={item}
|
||||
style={{ backgroundColor: highlighted ? "#eee" : "transparent" }}
|
||||
>
|
||||
{item}
|
||||
</div>
|
||||
)}
|
||||
value={this.state.value}
|
||||
onChange={e => this.setState({ value: e.target.value })}
|
||||
onSelect={value => {
|
||||
this.setState({ value });
|
||||
this.props.dispatch(
|
||||
actions.requestSingleGeneExpressionCountsForColoringPOST(value)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
width: 400,
|
||||
fontWeight: 700
|
||||
}}
|
||||
>
|
||||
<p style={{ marginRight: 110 }}>Gene</p>
|
||||
<p style={{ marginRight: 25 }}>1</p>
|
||||
<p style={{ marginRight: 20 }}>2</p>
|
||||
<p>ave diff</p>
|
||||
</div>
|
||||
{topGenesForCellSet1.topgenes.map((gene, i) => {
|
||||
return (
|
||||
<HeatmapRow
|
||||
key={gene}
|
||||
gene={gene}
|
||||
greyColorScale={greyColorScale}
|
||||
aveDiff={topGenesForCellSet1.ave_diff[i]}
|
||||
set1exp={Math.floor(
|
||||
topGenesForCellSet1.mean_expression_cellset1[i]
|
||||
)}
|
||||
set2exp={Math.floor(
|
||||
topGenesForCellSet1.mean_expression_cellset2[i]
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{topGenesForCellSet2.topgenes.map((gene, i) => {
|
||||
return (
|
||||
<HeatmapRow
|
||||
key={gene}
|
||||
gene={gene}
|
||||
greyColorScale={greyColorScale}
|
||||
aveDiff={topGenesForCellSet2.ave_diff[i]}
|
||||
set1exp={Math.floor(
|
||||
topGenesForCellSet2.mean_expression_cellset1[i]
|
||||
)}
|
||||
set2exp={Math.floor(
|
||||
topGenesForCellSet2.mean_expression_cellset2[i]
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Heatmap;
|
||||
@@ -0,0 +1,94 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import _ from "lodash";
|
||||
import { connect } from "react-redux";
|
||||
import * as globals from "../../globals";
|
||||
import actions from "../../actions";
|
||||
import CellSetButton from "./cellSetButtons";
|
||||
|
||||
@connect(state => {
|
||||
return {
|
||||
differential: state.differential,
|
||||
crossfilter: state.controls.crossfilter
|
||||
};
|
||||
})
|
||||
class Expression extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
}
|
||||
handleClick(gene) {
|
||||
return () => {
|
||||
this.props.dispatch({
|
||||
type: "color by expression",
|
||||
gene: gene
|
||||
});
|
||||
};
|
||||
}
|
||||
computeDiffExp() {
|
||||
this.props.dispatch(
|
||||
actions.requestDifferentialExpression(
|
||||
this.props.differential.celllist1,
|
||||
this.props.differential.celllist2
|
||||
)
|
||||
);
|
||||
}
|
||||
render() {
|
||||
if (!this.props.differential) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div style={{ margin: 10 }}>
|
||||
<div style={{ marginBottom: 15, width: 300 }}>
|
||||
There are currently
|
||||
{" " +
|
||||
(this.props.crossfilter
|
||||
? this.props.crossfilter.cells.countFiltered()
|
||||
: 0) +
|
||||
" "}
|
||||
cells selected, click a cell set button to store them.
|
||||
</div>
|
||||
<CellSetButton {...this.props} eitherCellSetOneOrTwo={1} />
|
||||
<CellSetButton {...this.props} eitherCellSetOneOrTwo={2} />
|
||||
</div>
|
||||
<div>
|
||||
{this.props.differential.celllist1 &&
|
||||
this.props.differential.celllist2 ? (
|
||||
<button
|
||||
style={{
|
||||
fontSize: 18,
|
||||
margin: 10,
|
||||
fontWeight: 700,
|
||||
color: "#FFF",
|
||||
padding: "12px 20px",
|
||||
backgroundColor: globals.brightBlue,
|
||||
border: "none",
|
||||
cursor: "pointer"
|
||||
}}
|
||||
onClick={this.computeDiffExp.bind(this)}
|
||||
>
|
||||
Compute differential expression
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
style={{
|
||||
fontSize: 18,
|
||||
margin: 10,
|
||||
fontWeight: 700,
|
||||
color: "#FFF",
|
||||
padding: "12px 20px",
|
||||
backgroundColor: globals.mediumGrey,
|
||||
border: "none"
|
||||
}}
|
||||
>
|
||||
Compute differential expression
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Expression;
|
||||
@@ -0,0 +1,6 @@
|
||||
.btn {
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: "#FFF";
|
||||
border: "none";
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.container {
|
||||
min-height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
|
||||
import styles from "./container.css";
|
||||
|
||||
const Container = props => (
|
||||
<div className={styles.container}>{props.children}</div>
|
||||
);
|
||||
|
||||
export default Container;
|
||||
@@ -0,0 +1,3 @@
|
||||
.header {
|
||||
background: red;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
|
||||
import Container from "./container";
|
||||
import styles from "./header.css";
|
||||
|
||||
const Header = () => (
|
||||
<header className={styles.header}>
|
||||
<Container />
|
||||
</header>
|
||||
);
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,15 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
|
||||
const SectionHeader = ({ text }) => (
|
||||
<p
|
||||
style={{
|
||||
fontSize: 32,
|
||||
fontWeight: 700
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
);
|
||||
|
||||
export default SectionHeader;
|
||||
@@ -0,0 +1,56 @@
|
||||
// jshint esversion: 6
|
||||
const mat4 = require("gl-mat4");
|
||||
|
||||
// opacity: https://github.com/spacetx/starfish/blob/master/viz/draw/regions.js
|
||||
|
||||
export default function(regl) {
|
||||
return regl({
|
||||
vert: `
|
||||
precision mediump float;
|
||||
attribute vec2 position;
|
||||
attribute vec3 color;
|
||||
attribute float size;
|
||||
uniform float distance;
|
||||
uniform mat4 projection, view;
|
||||
varying vec3 fragColor;
|
||||
void main() {
|
||||
gl_PointSize = 7.0 / pow(distance, 2.5) + size;
|
||||
gl_Position = projection * view * vec4(position.x, -position.y, 0, 1);
|
||||
fragColor = color;
|
||||
}`,
|
||||
|
||||
frag: `
|
||||
precision mediump float;
|
||||
varying vec3 fragColor;
|
||||
void main() {
|
||||
if (length(gl_PointCoord.xy - 0.5) > 0.5) {
|
||||
discard;
|
||||
}
|
||||
gl_FragColor = vec4(fragColor, 1);
|
||||
}`,
|
||||
|
||||
attributes: {
|
||||
position: regl.prop("position"),
|
||||
color: regl.prop("color"),
|
||||
size: regl.prop("size")
|
||||
},
|
||||
|
||||
uniforms: {
|
||||
distance: regl.prop("distance"),
|
||||
view: regl.prop("view"),
|
||||
projection: (context, props) => {
|
||||
return mat4.perspective(
|
||||
[],
|
||||
Math.PI / 2,
|
||||
context.viewportWidth * props.scale / context.viewportHeight,
|
||||
0.01,
|
||||
1000
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
count: regl.prop("count"),
|
||||
|
||||
primitive: "points"
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
.graphCanvas path {
|
||||
shape-rendering: crispEdges;
|
||||
}
|
||||
|
||||
#graphWrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#graphAttachPoint,
|
||||
.graphSVG,
|
||||
.graphCanvas {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
#graphSVG {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.axis path,
|
||||
.axis line {
|
||||
fill: none;
|
||||
stroke: #000;
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
circle {
|
||||
stroke-width: 4px;
|
||||
stroke: #000;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import _ from "lodash";
|
||||
import * as globals from "../../globals";
|
||||
import styles from "./graph.css";
|
||||
import { setupSVGandBrushElements } from "./setupSVGandBrush";
|
||||
import SectionHeader from "../framework/sectionHeader";
|
||||
import { connect } from "react-redux";
|
||||
import actions from "../../actions";
|
||||
|
||||
import mat4 from "gl-mat4";
|
||||
import fit from "canvas-fit";
|
||||
import _camera from "../../util/camera.js";
|
||||
import _regl from "regl";
|
||||
import _drawPoints from "./drawPointsRegl";
|
||||
import { scaleLinear } from "../../util/scaleLinear";
|
||||
|
||||
import FaCrosshair from "react-icons/lib/fa/crosshairs";
|
||||
import FaZoom from "react-icons/lib/fa/search-plus";
|
||||
import FaSave from "react-icons/lib/fa/download";
|
||||
|
||||
/* https://bl.ocks.org/mbostock/9078690 - quadtree for onClick / hover selections */
|
||||
|
||||
@connect(state => {
|
||||
return {
|
||||
cellsMetadata: state.controls.cellsMetadata,
|
||||
opacityForDeselectedCells: state.controls.opacityForDeselectedCells,
|
||||
responsive: state.responsive,
|
||||
crossfilter: state.controls.crossfilter
|
||||
};
|
||||
})
|
||||
class Graph extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.count = 0;
|
||||
this.inverse = mat4.identity([]);
|
||||
this.graphPaddingTop = 100;
|
||||
this.renderCache = {
|
||||
positions: null,
|
||||
colors: null
|
||||
};
|
||||
this.state = {
|
||||
drawn: false,
|
||||
svg: null,
|
||||
ctx: null,
|
||||
brush: null,
|
||||
mode: "brush"
|
||||
};
|
||||
}
|
||||
componentDidMount() {
|
||||
// setup canvas and camera
|
||||
const camera = _camera(this.reglCanvas, { scale: true, rotate: false });
|
||||
const regl = _regl(this.reglCanvas);
|
||||
|
||||
const drawPoints = _drawPoints(regl);
|
||||
|
||||
// preallocate buffers
|
||||
const pointBuffer = regl.buffer();
|
||||
const colorBuffer = regl.buffer();
|
||||
const sizeBuffer = regl.buffer();
|
||||
|
||||
regl.frame(({ viewportWidth, viewportHeight }) => {
|
||||
regl.clear({
|
||||
depth: 1,
|
||||
color: [1, 1, 1, 1]
|
||||
});
|
||||
|
||||
drawPoints({
|
||||
size: sizeBuffer,
|
||||
distance: camera.distance,
|
||||
color: colorBuffer,
|
||||
position: pointBuffer,
|
||||
count: this.count,
|
||||
view: camera.view(),
|
||||
scale: viewportHeight / viewportWidth
|
||||
});
|
||||
|
||||
this.setState({ camera });
|
||||
camera.tick();
|
||||
});
|
||||
|
||||
this.setState({
|
||||
regl,
|
||||
pointBuffer,
|
||||
colorBuffer,
|
||||
sizeBuffer
|
||||
});
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (this.state.regl && nextProps.crossfilter) {
|
||||
/* update the regl state */
|
||||
const crossfilter = nextProps.crossfilter.cells;
|
||||
const cells = crossfilter.all();
|
||||
const cellCount = cells.length;
|
||||
|
||||
// X/Y positions for each point - a cached value that only
|
||||
// changes if we have loaded entirely new cell data
|
||||
//
|
||||
if (
|
||||
!this.renderCache.positions ||
|
||||
this.props.crossfilter.cells != nextProps.crossfilter.cells
|
||||
) {
|
||||
if (!this.renderCache.positions)
|
||||
this.renderCache.positions = new Float32Array(2 * cellCount);
|
||||
|
||||
// d3.scaleLinear().domain([0,1]).range([-1,1])
|
||||
const glScaleX = scaleLinear([0, 1], [-1, 1]);
|
||||
// d3.scaleLinear().domain([0,1]).range([1,-1])
|
||||
const glScaleY = scaleLinear([0, 1], [1, -1]);
|
||||
|
||||
for (
|
||||
let i = 0, positions = this.renderCache.positions;
|
||||
i < cellCount;
|
||||
i++
|
||||
) {
|
||||
positions[2 * i] = glScaleX(cells[i].__x__);
|
||||
positions[2 * i + 1] = glScaleY(cells[i].__y__);
|
||||
}
|
||||
this.state.pointBuffer({
|
||||
data: this.renderCache.positions,
|
||||
dimension: 2
|
||||
});
|
||||
}
|
||||
|
||||
// Colors for each point - a cached value that only changes when
|
||||
// the cell metadata changes (done by updateCellColors middleware).
|
||||
// NOTE: this is a slightly pessimistic assumption, as the metadata
|
||||
// could have changed for some other reason, but for now color is
|
||||
// the only metadata that changes client-side. If this is problematic,
|
||||
// we could add some sort of color-specific indicator to the app state.
|
||||
if (
|
||||
!this.renderCache.colors ||
|
||||
this.props.cellsMetadata != nextProps.cellsMetadata
|
||||
) {
|
||||
if (!this.renderCache.colors)
|
||||
this.renderCache.colors = new Float32Array(3 * cellCount);
|
||||
for (let i = 0, colors = this.renderCache.colors; i < cellCount; i++) {
|
||||
colors.set(cells[i].__colorRGB__, 3 * i);
|
||||
}
|
||||
this.state.colorBuffer({ data: this.renderCache.colors, dimension: 3 });
|
||||
}
|
||||
|
||||
// Sizes for each point - this is presumed to change each time the
|
||||
// component receives new props. Almost always a true assumption, as
|
||||
// most property upates are due to changes driving a crossfilter
|
||||
// selection set change.
|
||||
//
|
||||
if (
|
||||
!this.renderCache.sizes ||
|
||||
this.props.crossfilter.cells != nextProps.crossfilter.cells
|
||||
) {
|
||||
this.renderCache.sizes = new Float32Array(cellCount);
|
||||
}
|
||||
crossfilter.fillByIsFiltered(this.renderCache.sizes, 4, 0.2);
|
||||
this.state.sizeBuffer({ data: this.renderCache.sizes, dimension: 1 });
|
||||
|
||||
this.count = cellCount;
|
||||
}
|
||||
|
||||
if (
|
||||
/* invisibly handles the initial null vs integer case as well as resize events */
|
||||
nextProps.responsive.height !== this.props.responsive.height ||
|
||||
nextProps.responsive.width !== this.props.responsive.width
|
||||
) {
|
||||
/* clear out whatever was on the div, even if nothing, but usually the brushes etc */
|
||||
d3
|
||||
.select("#graphAttachPoint")
|
||||
.selectAll("*")
|
||||
.remove();
|
||||
const { svg } = setupSVGandBrushElements(
|
||||
this.handleBrushSelectAction.bind(this),
|
||||
this.handleBrushDeselectAction.bind(this),
|
||||
nextProps.responsive,
|
||||
this.graphPaddingTop
|
||||
);
|
||||
this.setState({ svg });
|
||||
}
|
||||
}
|
||||
handleBrushSelectAction() {
|
||||
/*
|
||||
No idea why d3 event scope works like this
|
||||
but apparently
|
||||
it does
|
||||
https://bl.ocks.org/EfratVil/0e542f5fc426065dd1d4b6daaa345a9f
|
||||
*/
|
||||
const s = d3.event.selection;
|
||||
/*
|
||||
event describing brush position:
|
||||
@-------|
|
||||
| |
|
||||
| |
|
||||
|-------@
|
||||
*/
|
||||
|
||||
// compute inverse view matrix
|
||||
const inverse = mat4.invert([], this.state.camera.view());
|
||||
|
||||
// transform screen coordinates -> cell coordinates
|
||||
const invert = pin => {
|
||||
const x =
|
||||
2 * pin[0] / (this.props.responsive.height - this.graphPaddingTop) - 1;
|
||||
const y =
|
||||
2 *
|
||||
(1 - pin[1] / (this.props.responsive.height - this.graphPaddingTop)) -
|
||||
1;
|
||||
const pout = [x + inverse[12], y + inverse[13]];
|
||||
return [(pout[0] + 1) / 2, (pout[1] + 1) / 2];
|
||||
};
|
||||
|
||||
const brushCoords = {
|
||||
northwest: invert([s[0][0], s[0][1]]),
|
||||
southeast: invert([s[1][0], s[1][1]])
|
||||
};
|
||||
|
||||
this.props.dispatch({
|
||||
type: "graph brush selection change",
|
||||
brushCoords
|
||||
});
|
||||
}
|
||||
handleBrushDeselectAction() {
|
||||
if (!d3.event.selection) {
|
||||
this.props.dispatch({
|
||||
type: "graph brush deselect"
|
||||
});
|
||||
}
|
||||
}
|
||||
handleOpacityRangeChange(e) {
|
||||
this.props.dispatch({
|
||||
type: "change opacity deselected cells in 2d graph background",
|
||||
data: e.target.value
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div id="graphWrapper">
|
||||
<div style={{ position: "fixed", right: 0, top: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
padding: 10,
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
alignItems: "baseline"
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
this.props.dispatch(actions.resetGraph());
|
||||
}}
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
color: "white",
|
||||
padding: "10px 20px",
|
||||
marginRight: 10,
|
||||
borderRadius: 2,
|
||||
backgroundColor: globals.brightBlue,
|
||||
border: "none",
|
||||
cursor: "pointer"
|
||||
}}
|
||||
>
|
||||
reset graph
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
this.props.dispatch(actions.regraph());
|
||||
}}
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
color: "white",
|
||||
padding: "10px 20px",
|
||||
marginRight: 10,
|
||||
borderRadius: 2,
|
||||
backgroundColor: globals.brightBlue,
|
||||
border: "none",
|
||||
cursor: "pointer"
|
||||
}}
|
||||
>
|
||||
regraph selection
|
||||
</button>
|
||||
<div>
|
||||
<span style={{ position: "relative", top: 3 }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
this.setState({ mode: "brush" });
|
||||
}}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
border:
|
||||
this.state.mode === "brush"
|
||||
? "1px solid black"
|
||||
: "1px solid white",
|
||||
backgroundColor: "white",
|
||||
padding: 5,
|
||||
borderRadius: 3
|
||||
}}
|
||||
>
|
||||
{" "}
|
||||
<FaCrosshair />{" "}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
this.setState({ mode: "zoom" });
|
||||
}}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
border:
|
||||
this.state.mode === "zoom"
|
||||
? "1px solid black"
|
||||
: "1px solid white",
|
||||
backgroundColor: "white",
|
||||
padding: 5,
|
||||
borderRadius: 3
|
||||
}}
|
||||
>
|
||||
{" "}
|
||||
<FaZoom />{" "}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
color: "white",
|
||||
padding: "10px 20px",
|
||||
backgroundColor: globals.lightGrey,
|
||||
border: "none",
|
||||
cursor: "pointer"
|
||||
}}
|
||||
>
|
||||
export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginRight: 50,
|
||||
marginTop: 50,
|
||||
zIndex: -9999,
|
||||
position: "fixed",
|
||||
right: 20,
|
||||
bottom: 20
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: this.state.mode === "brush" ? "inherit" : "none"
|
||||
}}
|
||||
id="graphAttachPoint"
|
||||
/>
|
||||
<div style={{ padding: 0, margin: 0 }}>
|
||||
<canvas
|
||||
width={this.props.responsive.height - this.graphPaddingTop}
|
||||
height={this.props.responsive.height - this.graphPaddingTop}
|
||||
ref={canvas => {
|
||||
this.reglCanvas = canvas;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Graph;
|
||||
|
||||
// <span style={{ marginRight: 10, fontSize: 12 }}>
|
||||
// deselected opacity
|
||||
// </span>
|
||||
// <input
|
||||
// style={{ position: "relative", top: 6, marginRight: 20 }}
|
||||
// type="range"
|
||||
// onChange={this.handleOpacityRangeChange.bind(this)}
|
||||
// min={0}
|
||||
// max={1}
|
||||
// step="0.01"
|
||||
// />
|
||||
@@ -0,0 +1,43 @@
|
||||
// jshint esversion: 6
|
||||
import styles from "./graph.css";
|
||||
import _ from "lodash";
|
||||
import * as globals from "../../globals";
|
||||
|
||||
/******************************************
|
||||
*******************************************
|
||||
put svg & brush in DOM
|
||||
*******************************************
|
||||
******************************************/
|
||||
|
||||
export const setupSVGandBrushElements = (
|
||||
handleBrushSelectAction,
|
||||
handleBrushDeselectAction,
|
||||
responsive,
|
||||
graphPaddingTop
|
||||
) => {
|
||||
const side = responsive.height - graphPaddingTop;
|
||||
const svg = d3
|
||||
.select("#graphAttachPoint")
|
||||
.append("svg")
|
||||
.attr("width", side)
|
||||
.attr("height", side)
|
||||
.attr("class", `${styles.graphSVG}`);
|
||||
|
||||
svg.append("g").call(
|
||||
d3
|
||||
.brush()
|
||||
.extent([
|
||||
[0, 0],
|
||||
[
|
||||
responsive.height - graphPaddingTop,
|
||||
responsive.height - graphPaddingTop
|
||||
]
|
||||
])
|
||||
.on("brush", handleBrushSelectAction)
|
||||
.on("end", handleBrushDeselectAction)
|
||||
);
|
||||
|
||||
return {
|
||||
svg
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
// jshint esversion: 6
|
||||
// createExpressionsCountsMap () {
|
||||
//
|
||||
// const CHANGE_ME_MAGIC_GENE_INDEX = 5;
|
||||
//
|
||||
// const expressionsCountsMap = {};
|
||||
//
|
||||
// /* currently selected gene */
|
||||
// expressionsCountsMap.geneName = this.state.expressions.data.genes[3];
|
||||
//
|
||||
// let maxExpressionValue = 0;
|
||||
//
|
||||
// /* create map of expressions for every cell */
|
||||
// this.state.expressions.data.cells.map((c) => {
|
||||
// /* cellname = 234 */
|
||||
// expressionsCountsMap[c.cellname] = c["e"][CHANGE_ME_MAGIC_GENE_INDEX];
|
||||
// /* collect the maximum value as we iterate */
|
||||
// if (c["e"][CHANGE_ME_MAGIC_GENE_INDEX] > maxExpressionValue) {
|
||||
// maxExpressionValue = c["e"][CHANGE_ME_MAGIC_GENE_INDEX]
|
||||
// }
|
||||
// })
|
||||
//
|
||||
// expressionsCountsMap.maxValue = maxExpressionValue;
|
||||
//
|
||||
// return expressionsCountsMap;
|
||||
// }
|
||||
@@ -0,0 +1,163 @@
|
||||
// jshint esversion: 6
|
||||
import styles from "./joy.css";
|
||||
|
||||
/*
|
||||
via https://bl.ocks.org/armollica/3b5f83836c1de5cca7b1d35409a013e3
|
||||
*/
|
||||
|
||||
var margin = { top: 30, right: 10, bottom: 30, left: 100 },
|
||||
width = 400 - margin.left - margin.right,
|
||||
height = 600 - margin.top - margin.bottom;
|
||||
|
||||
// Percent two area charts can overlap
|
||||
var overlap = 0.4;
|
||||
|
||||
var formatTime = d3.timeFormat("%I %p");
|
||||
|
||||
var x = function(d) {
|
||||
return d.time;
|
||||
},
|
||||
xScale = d3.scaleTime().range([0, width]),
|
||||
xValue = function(d) {
|
||||
return xScale(x(d));
|
||||
},
|
||||
xAxis = d3.axisBottom(xScale).tickFormat(formatTime);
|
||||
|
||||
var y = function(d) {
|
||||
return d.value;
|
||||
},
|
||||
yScale = d3.scaleLinear(),
|
||||
yValue = function(d) {
|
||||
return yScale(y(d));
|
||||
};
|
||||
|
||||
var activity = function(d) {
|
||||
return d.key;
|
||||
},
|
||||
activityScale = d3.scaleBand().range([0, height]),
|
||||
activityValue = function(d) {
|
||||
return activityScale(activity(d));
|
||||
},
|
||||
activityAxis = d3.axisLeft(activityScale);
|
||||
|
||||
var area = d3
|
||||
.area()
|
||||
.x(xValue)
|
||||
.y1(yValue);
|
||||
|
||||
var line = area.lineY1();
|
||||
|
||||
function parseTime(offset) {
|
||||
var date = new Date(2017, 0, 1); // chose an arbitrary day
|
||||
return d3.timeMinute.offset(date, offset);
|
||||
}
|
||||
|
||||
function row(d) {
|
||||
return {
|
||||
activity: d.activity,
|
||||
time: parseTime(d.time),
|
||||
value: +d.p_smooth
|
||||
};
|
||||
}
|
||||
|
||||
const drawJoy = data => {
|
||||
console.log("drawJoy: ", data);
|
||||
|
||||
var svg = d3
|
||||
.select("#joyplot")
|
||||
.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 + ")");
|
||||
|
||||
d3.tsv(
|
||||
"https://gist.githubusercontent.com/armollica/3b5f83836c1de5cca7b1d35409a013e3/raw/783d1bbc2dd3aabbfcba83ece4a670de6f1ec371/data.tsv",
|
||||
row,
|
||||
function(error, dataFlat) {
|
||||
// Sort by time
|
||||
dataFlat.sort(function(a, b) {
|
||||
return a.time - b.time;
|
||||
});
|
||||
|
||||
var data = d3
|
||||
.nest()
|
||||
.key(function(d) {
|
||||
return d.activity;
|
||||
})
|
||||
.entries(dataFlat);
|
||||
|
||||
// Sort activities by peak activity time
|
||||
function peakTime(d) {
|
||||
var i = d3.scan(d.values, function(a, b) {
|
||||
return y(b) - y(a);
|
||||
});
|
||||
return d.values[i].time;
|
||||
}
|
||||
data.sort(function(a, b) {
|
||||
return peakTime(b) - peakTime(a);
|
||||
});
|
||||
|
||||
console.log("sorted", data);
|
||||
|
||||
xScale.domain(d3.extent(dataFlat, x));
|
||||
|
||||
activityScale.domain(
|
||||
data.map(function(d) {
|
||||
return d.key;
|
||||
})
|
||||
);
|
||||
|
||||
var areaChartHeight =
|
||||
(1 + overlap) * (height / activityScale.domain().length);
|
||||
|
||||
yScale.domain(d3.extent(dataFlat, y)).range([areaChartHeight, 0]);
|
||||
|
||||
area.y0(yScale(0));
|
||||
|
||||
var gActivity = svg
|
||||
.append("g")
|
||||
.attr("class", "activities")
|
||||
.selectAll(".activity")
|
||||
.data(data)
|
||||
.enter()
|
||||
.append("g")
|
||||
.attr("class", function(d) {
|
||||
return `${styles.activity} ${styles.activity["--" + d.key]}`;
|
||||
})
|
||||
.attr("transform", function(d) {
|
||||
var ty = activityValue(d) - activityScale.bandwidth() + 5;
|
||||
return "translate(0," + ty + ")";
|
||||
});
|
||||
|
||||
gActivity
|
||||
.append("path")
|
||||
.attr("class", styles.area)
|
||||
.datum(function(d) {
|
||||
return d.values;
|
||||
})
|
||||
.attr("d", area);
|
||||
|
||||
gActivity
|
||||
.append("path")
|
||||
.attr("class", styles.line)
|
||||
.datum(function(d) {
|
||||
return d.values;
|
||||
})
|
||||
.attr("d", line);
|
||||
|
||||
svg
|
||||
.append("g")
|
||||
.attr("class", `${styles.axis} ${styles["axis--x"]}`)
|
||||
.attr("transform", "translate(0," + height + ")")
|
||||
.call(xAxis);
|
||||
|
||||
svg
|
||||
.append("g")
|
||||
.attr("class", `${styles.axis} ${styles["axis--activity"]}`)
|
||||
.call(activityAxis);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default drawJoy;
|
||||
@@ -0,0 +1,42 @@
|
||||
svg {
|
||||
display: block;
|
||||
/*margin: 0 auto;*/
|
||||
}
|
||||
|
||||
.axis .domain {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.axis--x text {
|
||||
fill: #999;
|
||||
}
|
||||
|
||||
.axis--x line {
|
||||
stroke: #aaa;
|
||||
}
|
||||
|
||||
.axis--activity .tick line {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.axis--activity text {
|
||||
font-size: 12px;
|
||||
fill: #000;
|
||||
}
|
||||
|
||||
/*.axis--activity .tick:nth-child(odd) text {
|
||||
fill: #222;
|
||||
}*/
|
||||
|
||||
.line {
|
||||
fill: none;
|
||||
stroke: #fff;
|
||||
}
|
||||
|
||||
.area {
|
||||
fill: #448cab;
|
||||
}
|
||||
|
||||
.activity:nth-child(odd) .area {
|
||||
fill: #5ca3c1;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import _ from "lodash";
|
||||
import styles from "./joy.css";
|
||||
import drawJoy from "./drawJoy";
|
||||
import joyParser from "./joyParser";
|
||||
|
||||
class Joy extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.data) {
|
||||
console.log("joyplot data 44", nextProps.data);
|
||||
drawJoy(joyParser(nextProps.data));
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div id="joyplot_wrapper" style={{ marginTop: 50 }}>
|
||||
<h3> Joy </h3>
|
||||
<p>
|
||||
{" "}
|
||||
Cell expression distribution per gene & if differential expression,
|
||||
Ie., cells for cluster 5, top genes expressed by cluster 8
|
||||
</p>
|
||||
<div id="joyplot"> </div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Joy;
|
||||
@@ -0,0 +1,29 @@
|
||||
// jshint esversion: 6
|
||||
|
||||
const joyParser = (data, count = 20) => {
|
||||
const genes = [];
|
||||
|
||||
/* setup */
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const gene = {
|
||||
key:
|
||||
data.genes[
|
||||
i
|
||||
] /* key values naming: https://bl.ocks.org/armollica/3b5f83836c1de5cca7b1d35409a013e3 */,
|
||||
values: []
|
||||
};
|
||||
|
||||
data.cells.forEach(cell => {
|
||||
gene.values.push({
|
||||
value: cell["e"][i]
|
||||
});
|
||||
});
|
||||
|
||||
genes.push(gene);
|
||||
}
|
||||
|
||||
return genes;
|
||||
};
|
||||
|
||||
export default joyParser;
|
||||
@@ -0,0 +1,109 @@
|
||||
// jshint esversion: 6
|
||||
import React from "react";
|
||||
import _ from "lodash";
|
||||
import Categorical from "./categorical/categorical";
|
||||
import Continuous from "./continuous/continuous";
|
||||
import ExpressionButtons from "./expression/expressionButtons";
|
||||
import { connect } from "react-redux";
|
||||
import Heatmap from "./expression/diffExpHeatmap";
|
||||
import * as globals from "../globals";
|
||||
import DynamicScatterplot from "./scatterplot/scatterplot";
|
||||
|
||||
@connect(state => {
|
||||
return {
|
||||
responsive: state.responsive
|
||||
};
|
||||
})
|
||||
class LeftSideBar extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.metadataSectionPadding = 300;
|
||||
this.state = {
|
||||
currentTab: "metadata"
|
||||
};
|
||||
}
|
||||
render() {
|
||||
return (
|
||||
<div style={{ position: "fixed" }}>
|
||||
<p
|
||||
style={{
|
||||
margin: 10,
|
||||
fontSize: 24,
|
||||
color: globals.lightGrey,
|
||||
fontWeight: 700,
|
||||
width: "100%"
|
||||
}}
|
||||
>
|
||||
CELLxGENE {globals.datasetTitle}{" "}
|
||||
</p>
|
||||
<div style={{ padding: 10 }}>
|
||||
<button
|
||||
style={{
|
||||
padding: "none",
|
||||
outline: 0,
|
||||
fontSize: 14,
|
||||
fontWeight: this.state.currentTab === "metadata" ? 700 : 400,
|
||||
fontStyle:
|
||||
this.state.currentTab === "metadata" ? "inherit" : "italic",
|
||||
cursor: "pointer",
|
||||
border: "none",
|
||||
backgroundColor: "#FFF",
|
||||
borderTop: "none",
|
||||
borderBottom: "none",
|
||||
borderRight: "none",
|
||||
borderLeft: "none"
|
||||
}}
|
||||
onClick={() => {
|
||||
this.setState({ currentTab: "metadata" });
|
||||
}}
|
||||
>
|
||||
Metadata
|
||||
</button>
|
||||
<button
|
||||
style={{
|
||||
padding: "none",
|
||||
outline: 0,
|
||||
fontSize: 14,
|
||||
fontWeight: this.state.currentTab === "expression" ? 700 : 400,
|
||||
fontStyle:
|
||||
this.state.currentTab === "expression" ? "inherit" : "italic",
|
||||
cursor: "pointer",
|
||||
border: "none",
|
||||
backgroundColor: "#FFF",
|
||||
borderTop: "none",
|
||||
borderBottom: "none",
|
||||
borderRight: "none",
|
||||
borderLeft: "none"
|
||||
}}
|
||||
onClick={() => {
|
||||
this.setState({ currentTab: "expression" });
|
||||
}}
|
||||
>
|
||||
Expression
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: this.props.responsive.height - this.metadataSectionPadding,
|
||||
width: 400,
|
||||
padding: 10,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden"
|
||||
}}
|
||||
>
|
||||
{this.state.currentTab === "metadata" ? <Categorical /> : null}
|
||||
{this.state.currentTab === "metadata" ? <Continuous /> : null}
|
||||
{this.state.currentTab === "expression" ? <Heatmap /> : null}
|
||||
</div>
|
||||
<div style={{ position: "fixed", bottom: 0, left: 0 }}>
|
||||
{this.state.currentTab === "metadata" ? <ExpressionButtons /> : null}
|
||||
{this.state.currentTab === "expression" ? (
|
||||
<DynamicScatterplot />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default LeftSideBar;
|
||||
@@ -0,0 +1,56 @@
|
||||
// jshint esversion: 6
|
||||
const mat4 = require("gl-mat4");
|
||||
|
||||
// opacity: https://github.com/spacetx/starfish/blob/master/viz/draw/regions.js
|
||||
|
||||
export default function(regl) {
|
||||
return regl({
|
||||
vert: `
|
||||
precision mediump float;
|
||||
attribute vec2 position;
|
||||
attribute vec3 color;
|
||||
attribute float size;
|
||||
uniform float distance;
|
||||
uniform mat4 projection, view;
|
||||
varying vec3 fragColor;
|
||||
void main() {
|
||||
gl_PointSize = 7.0 / pow(distance, 2.5) + size;
|
||||
gl_Position = projection * view * vec4(position.x, -position.y, 0, 1);
|
||||
fragColor = color;
|
||||
}`,
|
||||
|
||||
frag: `
|
||||
precision mediump float;
|
||||
varying vec3 fragColor;
|
||||
void main() {
|
||||
if (length(gl_PointCoord.xy - 0.5) > 0.5) {
|
||||
discard;
|
||||
}
|
||||
gl_FragColor = vec4(fragColor, 1);
|
||||
}`,
|
||||
|
||||
attributes: {
|
||||
position: regl.prop("position"),
|
||||
color: regl.prop("color"),
|
||||
size: regl.prop("size")
|
||||
},
|
||||
|
||||
uniforms: {
|
||||
distance: regl.prop("distance"),
|
||||
view: regl.prop("view"),
|
||||
projection: (context, props) => {
|
||||
return mat4.perspective(
|
||||
[],
|
||||
Math.PI / 2,
|
||||
context.viewportWidth * props.scale / context.viewportHeight,
|
||||
0.01,
|
||||
1000
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
count: regl.prop("count"),
|
||||
|
||||
primitive: "points"
|
||||
});
|
||||
}
|
||||
@@ -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,296 @@
|
||||
// jshint esversion: 6
|
||||
// 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 mat4 from "gl-mat4";
|
||||
import fit from "canvas-fit";
|
||||
import _camera from "../../util/camera.js";
|
||||
import _regl from "regl";
|
||||
import _drawPoints from "./drawPointsRegl";
|
||||
import { scaleLinear } from "../../util/scaleLinear";
|
||||
|
||||
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,
|
||||
scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
|
||||
scatterplotYYaccessor: state.controls.scatterplotYYaccessor,
|
||||
opacityForDeselectedCells: state.controls.opacityForDeselectedCells,
|
||||
crossfilter: state.controls.crossfilter,
|
||||
differential: state.differential,
|
||||
expression: state.expression
|
||||
};
|
||||
})
|
||||
class Scatterplot extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.count = 0;
|
||||
this.state = {
|
||||
svg: null,
|
||||
// ctx: null,
|
||||
axes: null,
|
||||
dimensions: null,
|
||||
xScale: null,
|
||||
yScale: null
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { svg } = setupScatterplot(width, height, margin);
|
||||
this.setState({
|
||||
svg
|
||||
});
|
||||
|
||||
const camera = _camera(this.reglCanvas, { scale: true, rotate: false });
|
||||
const regl = _regl(this.reglCanvas);
|
||||
|
||||
const drawPoints = _drawPoints(regl);
|
||||
|
||||
// preallocate buffers
|
||||
const pointBuffer = regl.buffer();
|
||||
const colorBuffer = regl.buffer();
|
||||
const sizeBuffer = regl.buffer();
|
||||
|
||||
regl.frame(({ viewportWidth, viewportHeight }) => {
|
||||
regl.clear({
|
||||
depth: 1,
|
||||
color: [1, 1, 1, 1]
|
||||
});
|
||||
|
||||
drawPoints({
|
||||
distance: camera.distance,
|
||||
color: colorBuffer,
|
||||
position: pointBuffer,
|
||||
size: sizeBuffer,
|
||||
count: this.count,
|
||||
view: camera.view(),
|
||||
scale: viewportHeight / viewportWidth
|
||||
});
|
||||
|
||||
camera.tick();
|
||||
});
|
||||
|
||||
this.setState({
|
||||
regl,
|
||||
sizeBuffer,
|
||||
pointBuffer,
|
||||
colorBuffer
|
||||
});
|
||||
}
|
||||
componentWillReceiveProps(nextProps) {
|
||||
this.maybeSetupScalesAndDrawAxes(nextProps);
|
||||
}
|
||||
componentDidUpdate(prevProps) {
|
||||
if (
|
||||
this.state.xScale &&
|
||||
this.state.yScale &&
|
||||
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.regl &&
|
||||
this.state.pointBuffer &&
|
||||
this.state.colorBuffer &&
|
||||
this.state.sizeBuffer &&
|
||||
this.props.expression.data &&
|
||||
this.props.expression.data.genes &&
|
||||
this.props.scatterplotXXaccessor &&
|
||||
this.props.scatterplotYYaccessor &&
|
||||
this.state.xScale &&
|
||||
this.state.yScale
|
||||
) {
|
||||
const crossfilter = this.props.crossfilter.cells;
|
||||
const data = this.props.expression.data;
|
||||
const cells = data.cells;
|
||||
const genes = data.genes;
|
||||
const cellCount = cells.length;
|
||||
const positions = new Float32Array(2 * cellCount);
|
||||
const colors = new Float32Array(3 * cellCount);
|
||||
const sizes = new Float32Array(cellCount);
|
||||
|
||||
// d3.scaleLinear().domain([0, width]).range([-0.95, 0.95])
|
||||
const glScaleX = scaleLinear([0, width], [-0.95, 0.95]);
|
||||
|
||||
// d3.scaleLinear().domain([0, height]).range([-1, 1])
|
||||
const glScaleY = scaleLinear([0, height], [-1, 1]);
|
||||
|
||||
const geneXXaccessorIndex = genes.indexOf(
|
||||
this.props.scatterplotXXaccessor
|
||||
);
|
||||
const geneYYaccessorIndex = genes.indexOf(
|
||||
this.props.scatterplotYYaccessor
|
||||
);
|
||||
|
||||
/*
|
||||
Construct Vectors
|
||||
*/
|
||||
for (let i = 0; i < cellCount; i++) {
|
||||
const cell = cells[i];
|
||||
|
||||
positions[2 * i] = glScaleX(
|
||||
this.state.xScale(cell.e[geneXXaccessorIndex])
|
||||
); /* scale each point first to the window as we calculate extents separately below, so no need to repeat */
|
||||
positions[2 * i + 1] = glScaleY(
|
||||
this.state.yScale(cell.e[geneYYaccessorIndex])
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < cellCount; i++) {
|
||||
const metadata = this.props.metadata[i];
|
||||
colors.set(metadata.__colorRGB__, 3 * i);
|
||||
}
|
||||
|
||||
crossfilter.fillByIsFiltered(sizes, 4, 0.2);
|
||||
|
||||
this.state.pointBuffer({ data: positions, dimension: 2 });
|
||||
this.state.colorBuffer({ data: colors, dimension: 3 });
|
||||
this.state.sizeBuffer({ data: sizes, dimension: 1 });
|
||||
this.count = cellCount;
|
||||
}
|
||||
}
|
||||
maybeSetupScalesAndDrawAxes(nextProps) {
|
||||
if (
|
||||
nextProps.expression &&
|
||||
nextProps.expression.data &&
|
||||
nextProps.scatterplotXXaccessor &&
|
||||
nextProps.scatterplotYYaccessor
|
||||
) {
|
||||
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
|
||||
style={{
|
||||
backgroundColor: "white",
|
||||
paddingBottom: 20
|
||||
}}
|
||||
id="scatterplot_wrapper"
|
||||
>
|
||||
<div
|
||||
className={styles.scatterplot}
|
||||
id="scatterplot"
|
||||
style={{
|
||||
width: width + margin.left + margin.right + "px",
|
||||
height: height + margin.top + margin.bottom + "px"
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
width={width}
|
||||
height={height}
|
||||
style={{
|
||||
marginLeft: margin.left - 7,
|
||||
marginTop: margin.top
|
||||
}}
|
||||
ref={canvas => {
|
||||
this.reglCanvas = canvas;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Scatterplot;
|
||||
|
||||
// <SectionHeader text="Continuous Metadata"/>
|
||||
@@ -0,0 +1,23 @@
|
||||
// jshint esversion: 6
|
||||
/*****************************************
|
||||
******************************************
|
||||
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 + ")");
|
||||
|
||||
return {
|
||||
svg
|
||||
};
|
||||
};
|
||||
|
||||
export default setupScatterplot;
|
||||
@@ -0,0 +1,12 @@
|
||||
// jshint esversion: 6
|
||||
import _ from "lodash";
|
||||
|
||||
const paddingRight = 120;
|
||||
const continuousChartWidth = 340;
|
||||
|
||||
export const margin = { top: 66, right: 110, bottom: 20, left: 60 };
|
||||
export const width = 340;
|
||||
export const height = 340 - margin.top - margin.bottom;
|
||||
export const innerHeight = height - 2;
|
||||
|
||||
export const devicePixelRatio = window.devicePixelRatio || 1;
|
||||
@@ -0,0 +1,184 @@
|
||||
// jshint esversion: 6
|
||||
/* these will be either (preferably) specified or inferred */
|
||||
export const categories = [
|
||||
"Sample.type",
|
||||
"Selection",
|
||||
"Location",
|
||||
"Sample.name",
|
||||
"Class",
|
||||
"Neoplastic"
|
||||
];
|
||||
export const continuous = [
|
||||
"Total_reads",
|
||||
"Unique_reads",
|
||||
"Unique_reads_percent",
|
||||
"ERCC_reads",
|
||||
"Non_ERCC_reads",
|
||||
"ERCC_to_non_ERCC",
|
||||
"Genes_detected",
|
||||
"Multimapping_reads_percent",
|
||||
"Splice_sites_AT.AC",
|
||||
"Splice_sites_Annotated",
|
||||
"Splice_sites_GC.AG",
|
||||
"Splice_sites_GT.AG",
|
||||
"Splice_sites_non_canonical",
|
||||
"Splice_sites_total",
|
||||
"Unmapped_mismatch",
|
||||
"Unmapped_other",
|
||||
"Unmapped_short"
|
||||
];
|
||||
|
||||
/* colors */
|
||||
export const blue = "#4a90e2";
|
||||
export const hcaBlue = "#1c7cc7";
|
||||
export const lighterGrey = "rgb(245,245,245)";
|
||||
export const lightGrey = "rgb(211,211,211)";
|
||||
export const mediumGrey = "rgb(153,153,153)";
|
||||
export const darkGrey = "rgb(102,102,102)";
|
||||
export const darkerGrey = "rgb(51,51,51)";
|
||||
|
||||
export const brightBlue = "#4a90e2";
|
||||
export const brightGreen = "#A2D729";
|
||||
export const darkGreen = "#448C4D";
|
||||
|
||||
export const tiniestFontSize = 12;
|
||||
|
||||
export const bolder = 700;
|
||||
|
||||
export let API = {
|
||||
// prefix: "http://api.clustering.czi.technology/api/",
|
||||
//prefix: "http://tabulamuris.cxg.czi.technology/api/",
|
||||
prefix: "http://pbmc3k.cxg.czi.technology/api/",
|
||||
// prefix: "http://pbmc33k.cxg.czi.technology/api/",
|
||||
|
||||
// prefix: "http://api-staging.clustering.czi.technology/api/",
|
||||
version: "v0.1/"
|
||||
};
|
||||
|
||||
if (window.CELLXGENE && window.CELLXGENE.API) API = window.CELLXGENE.API;
|
||||
|
||||
export let datasetTitle = "";
|
||||
|
||||
if (window.CELLXGENE && window.CELLXGENE.datasetTitle)
|
||||
datasetTitle = window.CELLXGENE.datasetTitle;
|
||||
|
||||
export const accentFont = "Georgia,Times,Times New Roman,serif";
|
||||
export const maxParagraphWidth = 600;
|
||||
export const maxControlsWidth = 800;
|
||||
|
||||
export const graphMargin = { top: 20, right: 10, bottom: 30, left: 40 };
|
||||
// export const graphWidth = 1440 /* window width */ - 410 /* sidebar */ - (15 + 15) /* left right padding */ /* but responsive */;
|
||||
// export const graphHeight = 500;
|
||||
export const graphWidth = 700;
|
||||
export const graphHeight = 700;
|
||||
|
||||
export const ordinalColors = [
|
||||
"#0ac115",
|
||||
"#c10ab6",
|
||||
"#c1710a",
|
||||
"#0a5ac1",
|
||||
"#c1150a",
|
||||
"#0ab6c1",
|
||||
"#5ac10a",
|
||||
"#710ac1",
|
||||
"#0ac171",
|
||||
"#c10a5a",
|
||||
"#b6c10a",
|
||||
"#150ac1",
|
||||
"#b2ffb7",
|
||||
"#ffb2fa",
|
||||
"#ffddb2",
|
||||
"#b2d4ff",
|
||||
"#ffb7b2",
|
||||
"#b2faff",
|
||||
"#d4ffb2",
|
||||
"#ddb2ff",
|
||||
"#b2ffdd",
|
||||
"#ffb2d4",
|
||||
"#faffb2",
|
||||
"#b7b2ff",
|
||||
"#27a908",
|
||||
"#8b08a9",
|
||||
"#a93a08",
|
||||
"#0877a9",
|
||||
"#a90827",
|
||||
"#08a98b",
|
||||
"#77a908",
|
||||
"#3a08a9",
|
||||
"#08a93a",
|
||||
"#a90877",
|
||||
"#a98b08",
|
||||
"#0827a9",
|
||||
"#00ff0f",
|
||||
"#ff00ef",
|
||||
"#ff8e00",
|
||||
"#0070ff",
|
||||
"#ff0f00",
|
||||
"#00efff",
|
||||
"#70ff00",
|
||||
"#8e00ff",
|
||||
"#00ff8e",
|
||||
"#ff0070",
|
||||
"#efff00",
|
||||
"#0f00ff",
|
||||
"#006606",
|
||||
"#66005f",
|
||||
"#663900",
|
||||
"#002c66",
|
||||
"#660600",
|
||||
"#005f66",
|
||||
"#2c6600",
|
||||
"#390066",
|
||||
"#006639",
|
||||
"#66002c",
|
||||
"#5f6600",
|
||||
"#060066",
|
||||
"#83ff65",
|
||||
"#e165ff",
|
||||
"#ff9565",
|
||||
"#65cfff",
|
||||
"#ff6583",
|
||||
"#65ffe1",
|
||||
"#cfff65",
|
||||
"#9565ff",
|
||||
"#65ff95",
|
||||
"#ff65cf",
|
||||
"#ffe165",
|
||||
"#6583ff",
|
||||
"#009909",
|
||||
"#99008f",
|
||||
"#995500",
|
||||
"#004399",
|
||||
"#990900",
|
||||
"#008f99",
|
||||
"#439900",
|
||||
"#550099",
|
||||
"#009955",
|
||||
"#990043",
|
||||
"#8f9900",
|
||||
"#090099",
|
||||
"#d9fecc",
|
||||
"#f1ccfe",
|
||||
"#fed7cc",
|
||||
"#ccf3fe",
|
||||
"#feccd9",
|
||||
"#ccfef1",
|
||||
"#f3fecc",
|
||||
"#d7ccfe",
|
||||
"#ccfed7",
|
||||
"#feccf3",
|
||||
"#fef1cc",
|
||||
"#ccd9fe",
|
||||
"#47ea51",
|
||||
"#ea47e0",
|
||||
"#eaa247",
|
||||
"#478fea",
|
||||
"#ea5147",
|
||||
"#47e0ea",
|
||||
"#8fea47",
|
||||
"#a247ea",
|
||||
"#47eaa2",
|
||||
"#ea478f",
|
||||
"#e0ea47",
|
||||
"#5147ea"
|
||||
];
|
||||
@@ -0,0 +1,35 @@
|
||||
// jshint esversion: 6
|
||||
/* eslint-disable no-console */
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { AppContainer } from "react-hot-loader";
|
||||
import { Provider } from "react-redux";
|
||||
import Redbox from "redbox-react";
|
||||
|
||||
/* our code */
|
||||
import App from "./components/app";
|
||||
import store from "./reducers";
|
||||
|
||||
ReactDOM.render(
|
||||
<AppContainer errorReporter={Redbox}>
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
</Provider>
|
||||
</AppContainer>,
|
||||
document.getElementById("root")
|
||||
);
|
||||
|
||||
// Hot Module Replacement API
|
||||
if (module.hot) {
|
||||
module.hot.accept("./components/app", () => {
|
||||
const NextApp = require("./components/app").default;
|
||||
ReactDOM.render(
|
||||
<AppContainer>
|
||||
<Provider store={store}>
|
||||
<NextApp />
|
||||
</Provider>
|
||||
</AppContainer>,
|
||||
document.getElementById("root")
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// jshint esversion: 6
|
||||
import uri from "urijs";
|
||||
import * as globals from "../globals";
|
||||
import _ from "lodash";
|
||||
import { parseRGB } from "../util/parseRGB";
|
||||
|
||||
/*
|
||||
https://medium.com/@jacobp100/you-arent-using-redux-middleware-enough-94ffe991e6
|
||||
storeInstance => functionToCallWithAnActionThatWillSendItToTheNextMiddleware => actionThatDispatchWasCalledWith => valueToUseAsTheReturnValueOfTheDispatchCall
|
||||
*/
|
||||
|
||||
/*
|
||||
What this file does:
|
||||
|
||||
1. fire a filter action anywhere in the app
|
||||
2. ** this middleware checks to see the state of all the currently selected filters, including the new one
|
||||
3. ** create updated selection from a copy of all the cells presently on the client (this may be a subset of 'all')
|
||||
4. ** append that new selection to the action so that it magically appears in the reducer just because the action was fired
|
||||
|
||||
This is nice because we keep a lot of filtering business logic centralized (what it means in practice to be selected)
|
||||
*/
|
||||
|
||||
const updateCellColorsMiddleware = store => {
|
||||
return next => {
|
||||
return action => {
|
||||
const s = store.getState();
|
||||
|
||||
/* this is a hardcoded map of the things we need to keep an eye on and update global cell selection in response to */
|
||||
const filterJustChanged =
|
||||
action.type === "color by expression" ||
|
||||
action.type === "color by continuous metadata" ||
|
||||
action.type === "color by categorical metadata";
|
||||
|
||||
if (!filterJustChanged || !s.controls.cellsMetadata) {
|
||||
return next(
|
||||
action
|
||||
); /* if the cells haven't loaded or the action wasn't a color change, bail */
|
||||
}
|
||||
|
||||
let cellsMetadataWithUpdatedColors = s.controls.cellsMetadata.slice(0);
|
||||
let colorScale;
|
||||
|
||||
/*
|
||||
in plain language...
|
||||
|
||||
(a) once the cells have loaded.
|
||||
(b) each time a user changes a color control we need to update cellsMetadata colors
|
||||
|
||||
This is available to all the draw functions as cell["__color__"] and cell["__colorRGB__"]
|
||||
*/
|
||||
|
||||
if (action.type === "color by categorical metadata") {
|
||||
colorScale = d3.scaleOrdinal().range(globals.ordinalColors);
|
||||
|
||||
for (let i = 0; i < cellsMetadataWithUpdatedColors.length; i++) {
|
||||
const cell = cellsMetadataWithUpdatedColors[i];
|
||||
let c = colorScale(cell[action.colorAccessor]);
|
||||
cell.__color__ = c;
|
||||
cell.__colorRGB__ = parseRGB(c);
|
||||
}
|
||||
}
|
||||
|
||||
if (action.type === "color by continuous metadata") {
|
||||
colorScale = d3
|
||||
.scaleLinear()
|
||||
.domain([0, action.rangeMaxForColorAccessor])
|
||||
.range([1, 0]);
|
||||
|
||||
_.each(cellsMetadataWithUpdatedColors, (cell, i) => {
|
||||
let c = d3.interpolateViridis(colorScale(cell[action.colorAccessor]));
|
||||
cellsMetadataWithUpdatedColors[i]["__color__"] = c;
|
||||
cellsMetadataWithUpdatedColors[i]["__colorRGB__"] = parseRGB(c);
|
||||
});
|
||||
}
|
||||
|
||||
if (action.type === "color by expression") {
|
||||
const indexOfGene = 0; /* we only get one, this comes from server as needed now */
|
||||
|
||||
const expressionMap = {};
|
||||
/*
|
||||
converts [{cellname: cell123, e}, {}]
|
||||
|
||||
expressionMap = {
|
||||
cell123: [123, 2],
|
||||
cell789: [0, 8]
|
||||
}
|
||||
*/
|
||||
_.each(action.data.data.cells, cell => {
|
||||
/* this action is coming directly from the server */
|
||||
expressionMap[cell.cellname] = cell.e;
|
||||
});
|
||||
|
||||
const minExpressionCell = _.minBy(action.data.data.cells, cell => {
|
||||
return cell.e[indexOfGene];
|
||||
});
|
||||
|
||||
const maxExpressionCell = _.maxBy(action.data.data.cells, cell => {
|
||||
return cell.e[indexOfGene];
|
||||
});
|
||||
|
||||
// console.log('middle', action, expressionMap, minExpressionCell)
|
||||
|
||||
colorScale = d3
|
||||
.scaleLinear()
|
||||
.domain([
|
||||
minExpressionCell.e[indexOfGene],
|
||||
maxExpressionCell.e[indexOfGene]
|
||||
])
|
||||
.range([
|
||||
1,
|
||||
0
|
||||
]); /* invert viridis... probably pass this scale through to others */
|
||||
|
||||
_.each(cellsMetadataWithUpdatedColors, (cell, i) => {
|
||||
let c = d3.interpolateViridis(
|
||||
colorScale(expressionMap[cell.CellName][indexOfGene])
|
||||
);
|
||||
cellsMetadataWithUpdatedColors[i]["__color__"] = c;
|
||||
cellsMetadataWithUpdatedColors[i]["__colorRGB__"] = parseRGB(c);
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
append the result of all the filters to the action the user just triggered
|
||||
*/
|
||||
let modifiedAction = Object.assign({}, action, {
|
||||
cellsMetadataWithUpdatedColors,
|
||||
colorScale
|
||||
});
|
||||
|
||||
return next(modifiedAction);
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export default updateCellColorsMiddleware;
|
||||
@@ -0,0 +1,105 @@
|
||||
// jshint esversion: 6
|
||||
import uri from "urijs";
|
||||
import * as globals from "../globals";
|
||||
|
||||
/*
|
||||
XXX: this file should be obsolete. We just need to complete the refactoring
|
||||
of parallel.js and it can be removed entirely.
|
||||
|
||||
It is currently not in use - the middleware constructor does not include include it
|
||||
*/
|
||||
|
||||
/*
|
||||
https://medium.com/@jacobp100/you-arent-using-redux-middleware-enough-94ffe991e6
|
||||
storeInstance => functionToCallWithAnActionThatWillSendItToTheNextMiddleware => actionThatDispatchWasCalledWith => valueToUseAsTheReturnValueOfTheDispatchCall
|
||||
*/
|
||||
|
||||
/*
|
||||
What this file does:
|
||||
|
||||
1. fire a filter action anywhere in the app
|
||||
2. ** this middleware checks to see the state of all the currently selected filters, including the new one
|
||||
3. ** create updated selection from a copy of all the cells presently on the client (this may be a subset of 'all')
|
||||
4. ** append that new selection to the action so that it magically appears in the reducer just because the action was fired
|
||||
|
||||
This is nice because we keep a lot of filtering business logic centralized (what it means in practice to be selected)
|
||||
*/
|
||||
|
||||
const updateCellSelectionMiddleware = store => {
|
||||
return next => {
|
||||
return action => {
|
||||
const s = store.getState();
|
||||
|
||||
/* this is a hardcoded map of the things we need to keep an eye on and update global cell selection in response to */
|
||||
const filterJustChanged =
|
||||
action.type === "continuous selection using parallel coords brushing" ||
|
||||
action.type === "continuous metadata histogram brush" ||
|
||||
action.type === "graph brush selection change" ||
|
||||
action.type === "graph brush deselect" ||
|
||||
action.type === "categorical metadata filter deselect" ||
|
||||
action.type === "categorical metadata filter select" ||
|
||||
action.type === "categorical metadata filter none of these" ||
|
||||
action.type === "categorical metadata filter all of these";
|
||||
|
||||
if (!filterJustChanged || !s.controls.cellsMetadata) {
|
||||
return next(
|
||||
action
|
||||
); /* if the cells haven't loaded or the action wasn't a filter, bail */
|
||||
}
|
||||
|
||||
/*
|
||||
- make a FRESH copy of all of the cells
|
||||
- metadata has cellname and index, and that's all we ever need to reference cell info
|
||||
*/
|
||||
let newSelection = s.controls.cellsMetadata.slice(0);
|
||||
// _.forEach(newSelection, cell => (cell.__selected__ = true));
|
||||
for (let i = 0; i < newSelection.length; i++) {
|
||||
newSelection[i].__selected__ = true;
|
||||
}
|
||||
|
||||
/*
|
||||
in plain language...
|
||||
|
||||
(a) once the cells have loaded.
|
||||
(b) each time a user changes ANY control we need to update cellsMetadata
|
||||
there are two states:
|
||||
|
||||
1. control state we already know about (state.foo)
|
||||
2. control states that override states we already know about (action.foo applied instead of state.foo)
|
||||
|
||||
*/
|
||||
|
||||
if (
|
||||
(action.type ===
|
||||
"continuous selection using parallel coords brushing" &&
|
||||
s.controls.continuousSelection) ||
|
||||
s.controls.continuousSelection
|
||||
) {
|
||||
_.each(newSelection, (cell, i) => {
|
||||
const cellExtentsAreWithinContinuousSelectionBounds = s.controls.continuousSelection.every(
|
||||
active => {
|
||||
// test if point is within extents for each active brush
|
||||
return active.dimension.type.within(
|
||||
cell[active.dimension.key],
|
||||
active.extent,
|
||||
active.dimension
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
if (!cellExtentsAreWithinContinuousSelectionBounds) {
|
||||
newSelection[i]["__selected__"] = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let modifiedAction = Object.assign({}, action, {
|
||||
newSelection
|
||||
}); /* append the result of all the filters to the action the user just triggered */
|
||||
|
||||
return next(modifiedAction);
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export default updateCellSelectionMiddleware;
|
||||
@@ -0,0 +1,83 @@
|
||||
// jshint esversion: 6
|
||||
import uri from "urijs";
|
||||
|
||||
/*
|
||||
https://medium.com/@jacobp100/you-arent-using-redux-middleware-enough-94ffe991e6
|
||||
storeInstance => functionToCallWithAnActionThatWillSendItToTheNextMiddleware => actionThatDispatchWasCalledWith => valueToUseAsTheReturnValueOfTheDispatchCall
|
||||
*/
|
||||
|
||||
const updateURLMiddleware = store => {
|
||||
return next => {
|
||||
return action => {
|
||||
const oldState = store.getState();
|
||||
const nextAction = next(action);
|
||||
|
||||
if (action.type === "url changed") {
|
||||
/* we don't handle pop state here - we handle it in the url reducer */
|
||||
return nextAction;
|
||||
}
|
||||
|
||||
const state = store.getState();
|
||||
|
||||
/************************************************************************
|
||||
*************************************************************************
|
||||
1. Redux app state just changed. Clear URL, and then update it.
|
||||
1a. We get the whole state tree to construct the url!
|
||||
1b. But (see reducers/url.js) we try to centralize it because...
|
||||
1c. ...the back button / initial load case ('url changed' return above)
|
||||
means that we have to listen for 'url changed' and construct state
|
||||
from the browser
|
||||
*************************************************************************
|
||||
************************************************************************/
|
||||
|
||||
// const oldURI = URI(window.location.href)
|
||||
// const newURI = URI(oldURI).setQuery({})
|
||||
|
||||
// if (window.location.search === "") {
|
||||
// newURL = uri.addQuery(category, value).toString(); /* #1 */
|
||||
// } else if (uri.hasQuery(category, value) || uri.hasQuery(category, value, true)) { /* true param here means check arrays as well http://medialize.github.io/URI.js/docs.html#search-has */
|
||||
// newURL = uri.removeQuery(category, value).toString(); /* #4 */
|
||||
// } else {
|
||||
// newURL = uri.addQuery(category, value).toString(); /* #2 & #3 are handled by URI */
|
||||
// }
|
||||
//
|
||||
// window.history.pushState("", "", newURL)
|
||||
|
||||
//
|
||||
// // Internal helper for working with URIs
|
||||
// const oldURI = new URI(window.location.href);
|
||||
// const newURI = new URI(oldURI).setQueryData({});
|
||||
//
|
||||
// newURI.setPath('/foo/bar');
|
||||
//
|
||||
// // Set the path based on state
|
||||
// if (!state.isOnLandingPage && state.project.id) {
|
||||
// newURI.setPath(newURI.getPath() + state.project.id + '/');
|
||||
// newURI.addQueryData('baz', state.mode);
|
||||
// newURI.addQueryData('bat', state.selection.activePageID);
|
||||
// } else {
|
||||
// newURI.setPath(newURI.getPath() + state.landingSection + '/');
|
||||
// }
|
||||
//
|
||||
// // Avoid URL thrashing by replacing state while loading instead of pushing
|
||||
// const newPath = newURI.toString();
|
||||
// const oldPath = oldURI.toString();
|
||||
// if (newPath !== oldPath) {
|
||||
// if (
|
||||
// (oldState.mode === 'asdf' &&
|
||||
// state.mode === 'asdf' &&
|
||||
// !oldState.isOnLandingPage) ||
|
||||
// oldState.isLoadingProject !== state.isLoadingProject
|
||||
// ) {
|
||||
// window.history.replaceState(null, null, newPath);
|
||||
// } else {
|
||||
// window.history.pushState(null, null, newPath);
|
||||
// }
|
||||
// }
|
||||
|
||||
return nextAction;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export default updateURLMiddleware;
|
||||
@@ -0,0 +1,41 @@
|
||||
// jshint esversion: 6
|
||||
const Cells = (
|
||||
state = {
|
||||
cells: null /* world */,
|
||||
loading: null,
|
||||
error: null,
|
||||
|
||||
allCells: null /* this comes from cells endpoint, this is universe */
|
||||
},
|
||||
action
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "request cells started":
|
||||
return Object.assign({}, state, {
|
||||
loading: true,
|
||||
error: null
|
||||
});
|
||||
case "request cells success":
|
||||
return Object.assign({}, state, {
|
||||
error: null,
|
||||
loading: false,
|
||||
cells: action.data, // most recently loaded cells
|
||||
|
||||
/* Universe - initialize once */
|
||||
allCells: state.allCells ? state.allCells : action.data
|
||||
});
|
||||
case "request cells error":
|
||||
return Object.assign({}, state, {
|
||||
loading: false,
|
||||
error: action.data
|
||||
});
|
||||
case "reset graph":
|
||||
return Object.assign({}, state, {
|
||||
cells: state.allCells
|
||||
});
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default Cells;
|
||||
Vendored
+367
@@ -0,0 +1,367 @@
|
||||
// jshint esversion: 6
|
||||
import _ from "lodash";
|
||||
import { parseRGB } from "../util/parseRGB";
|
||||
import { createSchemaByDataSniffing } from "../util/schema";
|
||||
var crossfilter = require("../util/typedCrossfilter");
|
||||
|
||||
// Deduce the correct crossfilter dimension type from a metadata
|
||||
// schema description.
|
||||
//
|
||||
function deduceDimensionType(attributes, fieldName) {
|
||||
let dimensionType;
|
||||
if (attributes.type === "string") {
|
||||
dimensionType = "enum";
|
||||
} else if (attributes.type === "int") {
|
||||
dimensionType = Int32Array;
|
||||
} else if (attributes.type === "float") {
|
||||
dimensionType = Float32Array;
|
||||
} else {
|
||||
console.error(
|
||||
`Warning - REST API returned unknown metadata schema (${
|
||||
attributes.type
|
||||
}) for field ${fieldName}.`
|
||||
);
|
||||
// skip it - we don't know what to do with this type
|
||||
}
|
||||
return dimensionType;
|
||||
}
|
||||
|
||||
// Create view state from /cells data response. Used both during a data
|
||||
// load and during a graph reset.
|
||||
//
|
||||
function createViewState(schema, data) {
|
||||
const cellsMetadata = data.metadata.slice(0);
|
||||
|
||||
/*
|
||||
construct a copy of the ranges object that only has categorical
|
||||
replace all counts with bool flags
|
||||
ie., everything starts out checked
|
||||
we mutate this map in the actions below
|
||||
*/
|
||||
const categoricalAsBooleansMap = {};
|
||||
_.each(data.ranges, (value, key) => {
|
||||
if (
|
||||
key !== "CellName" &&
|
||||
value.options /* it's categorical, it has options instead of ranges */
|
||||
) {
|
||||
const optionsAsBooleans = {};
|
||||
_.each(value.options, (_value, _key) => {
|
||||
optionsAsBooleans[_key] = true;
|
||||
});
|
||||
categoricalAsBooleansMap[key] = optionsAsBooleans;
|
||||
}
|
||||
});
|
||||
|
||||
const graph = data.graph;
|
||||
_.each(cellsMetadata, (cell, idx) => {
|
||||
cell.__cellIndex__ = idx;
|
||||
cell.__color__ =
|
||||
"rgba(0,0,0,1)"; /* initial color for all cells in all charts */
|
||||
cell.__colorRGB__ = parseRGB(cell.__color__);
|
||||
cell.__x__ = graph[idx][1];
|
||||
cell.__y__ = graph[idx][2];
|
||||
});
|
||||
|
||||
// Build the selection crossfilter.
|
||||
//
|
||||
let cellsCrossfilter = crossfilter(cellsMetadata);
|
||||
let cellsDimensionsMap = {};
|
||||
cellsDimensionsMap.x = cellsCrossfilter.dimension(r => r.__x__, Float32Array);
|
||||
cellsDimensionsMap.y = cellsCrossfilter.dimension(r => r.__y__, Float32Array);
|
||||
|
||||
// Now walk the schema and make an appropriate dimension for each
|
||||
// metadata field. This is a simplistic mapping, and could be
|
||||
// optmized to use smaller scalars (to save memory) or larger
|
||||
// floating point where precision is needed.
|
||||
//
|
||||
_.forEach(schema, (attributes, key) => {
|
||||
if (key !== "CellName") {
|
||||
const dimensionType = deduceDimensionType(attributes, key);
|
||||
if (dimensionType) {
|
||||
cellsDimensionsMap[key] = cellsCrossfilter.dimension(
|
||||
r => r[key],
|
||||
dimensionType
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
cellsMetadata,
|
||||
crossfilter: {
|
||||
cells: cellsCrossfilter,
|
||||
dimensionMap: cellsDimensionsMap
|
||||
},
|
||||
categoricalAsBooleansMap
|
||||
};
|
||||
}
|
||||
|
||||
const Controls = (
|
||||
state = {
|
||||
/* Universe - all cells known to us. Set once, during initial load */
|
||||
_ranges: null /* this comes from initialize, this is universe */,
|
||||
allGeneNames: null,
|
||||
allCells: null /* this comes from cells endpoint, this is universe */,
|
||||
allCellsMetadata: null /* this comes from cells endpoint, and is just the metadata for universe */,
|
||||
allCellsMetadataMap: null,
|
||||
|
||||
/* View / World - all cells currently being displayed. May be a subset of Universe. */
|
||||
cellsMetadata: null,
|
||||
crossfilter: null /* the current user selection state */,
|
||||
categoricalAsBooleansMap: null,
|
||||
|
||||
colorAccessor: null,
|
||||
colorScale: null,
|
||||
opacityForDeselectedCells: 0.2,
|
||||
graphBrushSelection: null,
|
||||
continuousSelection: null,
|
||||
scatterplotXXaccessor: null, // just easier to read
|
||||
scatterplotYYaccessor: null,
|
||||
axesHaveBeenDrawn: false,
|
||||
__storedStateForCelllist1__: null /* will need procedural control of brush ie., brush.extent https://bl.ocks.org/micahstubbs/3cda05ca68cba260cb81 */,
|
||||
__storedStateForCelllist2__: null
|
||||
},
|
||||
action
|
||||
) => {
|
||||
switch (action.type) {
|
||||
/**********************************
|
||||
Keep a copy of 'universe'
|
||||
***********************************/
|
||||
case "initialize success": {
|
||||
if (!action.data.data.schema) {
|
||||
console.error("Warning - REST API omitted schema description.");
|
||||
}
|
||||
return Object.assign({}, state, {
|
||||
_ranges: action.data.data.ranges,
|
||||
allGeneNames: action.data.data.genes,
|
||||
schema: action.data.data.schema
|
||||
});
|
||||
}
|
||||
case "request cells success": {
|
||||
// If we don't have a schema (bad server!), fake it by inferring
|
||||
// important fields from the ranges element.
|
||||
//
|
||||
if (!state.schema) {
|
||||
state.schema = createSchemaByDataSniffing(action.data.data.ranges);
|
||||
}
|
||||
|
||||
/* Set viewable world to the provided cell data */
|
||||
const viewState = createViewState(state.schema, action.data.data);
|
||||
return Object.assign({}, state, {
|
||||
/* Universe - initialize once */
|
||||
allCells: state.allCells ? state.allCells : action.data,
|
||||
allCellsMetadata: state.allCellsMetadata
|
||||
? state.allCellsMetadata
|
||||
: viewState.cellsMetadata,
|
||||
allCellsMetadataMap: state.allCellsMetadataMap
|
||||
? state.allCellsMetadataMap
|
||||
: _.keyBy(viewState.cellsMetadata, "CellName"),
|
||||
|
||||
/* World */
|
||||
...viewState,
|
||||
|
||||
graphBrushSelection: null /* if we are getting new cells from the server, the layout (probably? definitely?) just changed, so this is now irrelevant, and we WILL need to call a function to reset state of this kind when cells success happens */
|
||||
});
|
||||
}
|
||||
/* * * * * * * * * * * * * * * * * *
|
||||
User events
|
||||
* * * * * * * * * * * * * * * * * */
|
||||
case "reset graph": {
|
||||
/* Reset viewable world to the entire Universe */
|
||||
const viewState = createViewState(state.schema, state.allCells.data);
|
||||
return Object.assign({}, state, {
|
||||
...viewState
|
||||
});
|
||||
}
|
||||
case "parallel coordinates axes have been drawn": {
|
||||
return Object.assign({}, state, {
|
||||
axesHaveBeenDrawn: true
|
||||
});
|
||||
}
|
||||
case "continuous selection using parallel coords brushing": {
|
||||
return Object.assign({}, state, {
|
||||
continuousSelection: action.data,
|
||||
crossfilter: {
|
||||
...state.crossfilter
|
||||
}
|
||||
});
|
||||
}
|
||||
case "graph brush selection change": {
|
||||
state.crossfilter.dimensionMap.x.filterRange([
|
||||
action.brushCoords.northwest[0],
|
||||
action.brushCoords.southeast[0]
|
||||
]);
|
||||
state.crossfilter.dimensionMap.y.filterRange([
|
||||
action.brushCoords.southeast[1],
|
||||
action.brushCoords.northwest[1]
|
||||
]);
|
||||
return Object.assign({}, state, {
|
||||
graphBrushSelection: action.brushCoords,
|
||||
crossfilter: {
|
||||
...state.crossfilter
|
||||
}
|
||||
});
|
||||
}
|
||||
case "graph brush deselect": {
|
||||
state.crossfilter.dimensionMap.x.filterAll();
|
||||
state.crossfilter.dimensionMap.y.filterAll();
|
||||
return Object.assign({}, state, {
|
||||
graphBrushSelection: null,
|
||||
crossfilter: {
|
||||
...state.crossfilter
|
||||
}
|
||||
});
|
||||
}
|
||||
case "continuous metadata histogram brush": {
|
||||
// action.selection: metadata name being selected
|
||||
// action.range: filter range, or null if deselected
|
||||
if (!action.range) {
|
||||
state.crossfilter.dimensionMap[action.selection].filterAll();
|
||||
} else {
|
||||
state.crossfilter.dimensionMap[action.selection].filterRange(
|
||||
action.range
|
||||
);
|
||||
}
|
||||
return Object.assign({}, state, {
|
||||
crossfilter: {
|
||||
...state.crossfilter
|
||||
}
|
||||
});
|
||||
}
|
||||
case "change opacity deselected cells in 2d graph background":
|
||||
return Object.assign({}, state, {
|
||||
opacityForDeselectedCells: action.data
|
||||
});
|
||||
/*******************************
|
||||
Categorical metadata
|
||||
*******************************/
|
||||
case "categorical metadata filter select": {
|
||||
const newCategoricalAsBooleansMap = {
|
||||
...state.categoricalAsBooleansMap,
|
||||
[action.metadataField]: {
|
||||
...state.categoricalAsBooleansMap[action.metadataField],
|
||||
[action.value]: true
|
||||
}
|
||||
};
|
||||
// update the filter for the one category that changed state
|
||||
state.crossfilter.dimensionMap[action.metadataField].filterEnum(
|
||||
_.filter(
|
||||
_.map(
|
||||
newCategoricalAsBooleansMap[action.metadataField],
|
||||
(val, key) => (val ? key : false)
|
||||
)
|
||||
)
|
||||
);
|
||||
return Object.assign({}, state, {
|
||||
categoricalAsBooleansMap: newCategoricalAsBooleansMap,
|
||||
crossfilter: {
|
||||
...state.crossfilter
|
||||
}
|
||||
});
|
||||
}
|
||||
case "categorical metadata filter deselect": {
|
||||
const newCategoricalAsBooleansMap = {
|
||||
...state.categoricalAsBooleansMap,
|
||||
[action.metadataField]: {
|
||||
...state.categoricalAsBooleansMap[action.metadataField],
|
||||
[action.value]: false
|
||||
}
|
||||
};
|
||||
// update the filter for the one category that changed state
|
||||
state.crossfilter.dimensionMap[action.metadataField].filterEnum(
|
||||
_.filter(
|
||||
_.map(
|
||||
newCategoricalAsBooleansMap[action.metadataField],
|
||||
(val, key) => (val ? key : false)
|
||||
)
|
||||
)
|
||||
);
|
||||
return Object.assign({}, state, {
|
||||
categoricalAsBooleansMap: newCategoricalAsBooleansMap,
|
||||
crossfilter: {
|
||||
...state.crossfilter
|
||||
}
|
||||
});
|
||||
}
|
||||
case "categorical metadata filter none of these": {
|
||||
const newCategoricalAsBooleansMap = {
|
||||
...state.categoricalAsBooleansMap
|
||||
};
|
||||
_.forEach(
|
||||
newCategoricalAsBooleansMap[action.metadataField],
|
||||
(v, k, c) => {
|
||||
c[k] = false;
|
||||
}
|
||||
);
|
||||
state.crossfilter.dimensionMap[action.metadataField].filterNone();
|
||||
return Object.assign({}, state, {
|
||||
categoricalAsBooleansMap: newCategoricalAsBooleansMap,
|
||||
crossfilter: {
|
||||
...state.crossfilter
|
||||
}
|
||||
});
|
||||
}
|
||||
case "categorical metadata filter all of these": {
|
||||
const newCategoricalAsBooleansMap = {
|
||||
...state.categoricalAsBooleansMap
|
||||
};
|
||||
_.forEach(
|
||||
newCategoricalAsBooleansMap[action.metadataField],
|
||||
(v, k, c) => {
|
||||
c[k] = true;
|
||||
}
|
||||
);
|
||||
state.crossfilter.dimensionMap[action.metadataField].filterAll();
|
||||
return Object.assign({}, state, {
|
||||
categoricalAsBooleansMap: newCategoricalAsBooleansMap,
|
||||
crossfilter: {
|
||||
...state.crossfilter
|
||||
}
|
||||
});
|
||||
}
|
||||
/*******************************
|
||||
Color Scale
|
||||
*******************************/
|
||||
case "color by continuous metadata":
|
||||
return Object.assign({}, state, {
|
||||
colorAccessor: action.colorAccessor,
|
||||
cellsMetadata:
|
||||
action.cellsMetadataWithUpdatedColors /* this comes from middleware */,
|
||||
colorScale: action.colorScale
|
||||
});
|
||||
case "color by expression":
|
||||
return Object.assign({}, state, {
|
||||
colorAccessor: action.gene,
|
||||
cellsMetadata:
|
||||
action.cellsMetadataWithUpdatedColors /* this comes from middleware */,
|
||||
colorScale: action.colorScale
|
||||
});
|
||||
case "color by categorical metadata":
|
||||
return Object.assign({}, state, {
|
||||
colorAccessor:
|
||||
action.colorAccessor /* pass the scale through additionally, and it's a legend! */,
|
||||
cellsMetadata:
|
||||
action.cellsMetadataWithUpdatedColors /* this comes from middleware */,
|
||||
colorScale: action.colorScale
|
||||
});
|
||||
case "store current cell selection as differential set 1":
|
||||
return Object.assign({}, state, {
|
||||
__storedStateForCelllist1__: action.data
|
||||
});
|
||||
/*******************************
|
||||
Scatterplot
|
||||
*******************************/
|
||||
case "set scatterplot x":
|
||||
return Object.assign({}, state, {
|
||||
scatterplotXXaccessor: action.data
|
||||
});
|
||||
case "set scatterplot y":
|
||||
return Object.assign({}, state, {
|
||||
scatterplotYYaccessor: action.data
|
||||
});
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default Controls;
|
||||
@@ -0,0 +1,42 @@
|
||||
// jshint esversion: 6
|
||||
const Differential = (
|
||||
state = {
|
||||
diffExp: null,
|
||||
loading: null,
|
||||
error: null,
|
||||
celllist1: null,
|
||||
celllist2: null
|
||||
},
|
||||
action
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "request differential expression started":
|
||||
return Object.assign({}, state, {
|
||||
loading: true,
|
||||
error: null
|
||||
});
|
||||
case "request differential expression success":
|
||||
return Object.assign({}, state, {
|
||||
error: null,
|
||||
loading: false,
|
||||
diffExp: action.data
|
||||
});
|
||||
case "request differential expression error":
|
||||
return Object.assign({}, state, {
|
||||
loading: false,
|
||||
error: action.data
|
||||
});
|
||||
case "store current cell selection as differential set 1":
|
||||
return Object.assign({}, state, {
|
||||
celllist1: action.data
|
||||
});
|
||||
case "store current cell selection as differential set 2":
|
||||
return Object.assign({}, state, {
|
||||
celllist2: action.data
|
||||
});
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default Differential;
|
||||
@@ -0,0 +1,33 @@
|
||||
// jshint esversion: 6
|
||||
const Expression = (
|
||||
state = {
|
||||
data: null,
|
||||
loading: null,
|
||||
error: null
|
||||
},
|
||||
action
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "get expression started":
|
||||
return Object.assign({}, state, {
|
||||
loading: true,
|
||||
error: null
|
||||
});
|
||||
case "get expression success":
|
||||
return Object.assign({}, state, {
|
||||
error: null,
|
||||
loading: false,
|
||||
data: action.data.data
|
||||
});
|
||||
case "get expression error":
|
||||
return Object.assign({}, state, {
|
||||
data: null,
|
||||
loading: false,
|
||||
error: action.data
|
||||
});
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default Expression;
|
||||
@@ -0,0 +1,36 @@
|
||||
// jshint esversion: 6
|
||||
import { combineReducers, createStore, applyMiddleware } from "redux";
|
||||
import updateURLMiddleware from "../middleware/updateURLMiddleware";
|
||||
// import updateCellSelectionMiddleware from "../middleware/updateCellSelectionMiddleware";
|
||||
import updateCellColors from "../middleware/updateCellColors";
|
||||
|
||||
import thunk from "redux-thunk";
|
||||
|
||||
import initialize from "./initialize";
|
||||
import cells from "./cells";
|
||||
import expression from "./expression";
|
||||
import controls from "./controls";
|
||||
import differential from "./differential";
|
||||
import responsive from "./responsive";
|
||||
|
||||
const Reducer = combineReducers({
|
||||
initialize,
|
||||
cells,
|
||||
expression,
|
||||
controls,
|
||||
differential,
|
||||
responsive
|
||||
});
|
||||
|
||||
let store = createStore(
|
||||
Reducer,
|
||||
window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__(),
|
||||
applyMiddleware(
|
||||
thunk,
|
||||
updateURLMiddleware,
|
||||
// updateCellSelectionMiddleware,
|
||||
updateCellColors
|
||||
)
|
||||
);
|
||||
|
||||
export default store;
|
||||
@@ -0,0 +1,33 @@
|
||||
// jshint esversion: 6
|
||||
const Initialize = (
|
||||
state = {
|
||||
data: null,
|
||||
loading: null,
|
||||
error: null
|
||||
},
|
||||
action
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "initialize started":
|
||||
return Object.assign({}, state, {
|
||||
loading: true,
|
||||
error: null
|
||||
});
|
||||
case "initialize success":
|
||||
return Object.assign({}, state, {
|
||||
error: null,
|
||||
loading: false,
|
||||
data: action.data
|
||||
});
|
||||
case "initialize error":
|
||||
return Object.assign({}, state, {
|
||||
data: null,
|
||||
loading: false,
|
||||
error: action.data
|
||||
});
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default Initialize;
|
||||
@@ -0,0 +1,20 @@
|
||||
// jshint esversion: 6
|
||||
const Responsive = (
|
||||
state = {
|
||||
width: null,
|
||||
height: null
|
||||
},
|
||||
action
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "window resize":
|
||||
return Object.assign({}, state, {
|
||||
width: action.data.width,
|
||||
height: action.data.height
|
||||
});
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default Responsive;
|
||||
@@ -0,0 +1,74 @@
|
||||
// jshint esversion: 6
|
||||
var createCamera = require("orbit-camera");
|
||||
var createScroll = require("scroll-speed");
|
||||
var mp = require("mouse-position");
|
||||
var mb = require("mouse-pressed");
|
||||
var key = require("key-pressed");
|
||||
|
||||
const panSpeed = 0.4;
|
||||
const scaleSpeed = 0.5;
|
||||
const scaleMax = 3;
|
||||
// const scaleMin = 1.15
|
||||
const scaleMin = 1.03;
|
||||
|
||||
function attachCamera(canvas, opts) {
|
||||
opts = opts || {};
|
||||
opts.pan = opts.pan !== false;
|
||||
opts.scale = opts.scale !== false;
|
||||
opts.rotate = opts.rotate !== false;
|
||||
|
||||
var scroll = createScroll(canvas, opts.scale);
|
||||
var mbut = mb(canvas, opts.rotate);
|
||||
var mpos = mp(canvas);
|
||||
var camera = createCamera([0, 0, 1], [0, 0, -1], [0, 1, 0]);
|
||||
|
||||
camera.tick = tick;
|
||||
|
||||
return camera;
|
||||
|
||||
function tick() {
|
||||
var ctrl = key("<control>") || key("<alt>");
|
||||
var alt = key("<shift>");
|
||||
var height = canvas.height;
|
||||
var width = canvas.width;
|
||||
|
||||
if (opts.rotate && mbut.left && ctrl && !alt) {
|
||||
camera.rotate(
|
||||
[mpos.x / width - 0.5, mpos.y / height - 0.5],
|
||||
[mpos.prevX / width - 0.5, mpos.prevY / height - 0.5]
|
||||
);
|
||||
}
|
||||
|
||||
if ((opts.pan && mbut.right) || (mbut.left && !ctrl && !alt)) {
|
||||
camera.pan([
|
||||
panSpeed *
|
||||
(mpos[0] - mpos.prev[0]) /
|
||||
width *
|
||||
Math.pow(camera.distance, 1),
|
||||
panSpeed *
|
||||
(mpos[1] - mpos.prev[1]) /
|
||||
height *
|
||||
Math.pow(camera.distance, 1)
|
||||
]);
|
||||
}
|
||||
|
||||
if (opts.scale && scroll[1]) {
|
||||
camera.distance *= Math.exp(scroll[1] * scaleSpeed / height);
|
||||
}
|
||||
|
||||
if (opts.scale && (mbut.middle || (mbut.left && !ctrl && alt))) {
|
||||
var d = mpos.y - mpos.prevY;
|
||||
if (!d) return;
|
||||
|
||||
camera.distance *= Math.exp(d / height);
|
||||
}
|
||||
|
||||
if (camera.distance > scaleMax) camera.distance = scaleMax;
|
||||
if (camera.distance < scaleMin) camera.distance = scaleMin;
|
||||
|
||||
scroll.flush();
|
||||
mpos.flush();
|
||||
}
|
||||
}
|
||||
|
||||
export default attachCamera;
|
||||
@@ -0,0 +1,31 @@
|
||||
// jshint esversion: 6
|
||||
import { scaleRGB } from "./scaleRGB";
|
||||
|
||||
// maintain a cache of already parsed RGB names, as it is reasonably expensive
|
||||
// to do this operation. This lets us have speed, but keep the pleasant ability
|
||||
// to talk about colors by their text description eg, 'rgb(0,0,1)'
|
||||
//
|
||||
const colorCache = new Object(null); // no prototype
|
||||
|
||||
function parseColorName(c) {
|
||||
if (c[0] !== "#") {
|
||||
const _c = c.replace(/[^\d,.]/g, "").split(",");
|
||||
return [scaleRGB(+_c[0]), scaleRGB(+_c[1]), scaleRGB(+_c[2])];
|
||||
} else {
|
||||
var parsedHex = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(c);
|
||||
return [
|
||||
scaleRGB(parseInt(parsedHex[1], 16)),
|
||||
scaleRGB(parseInt(parsedHex[2], 16)),
|
||||
scaleRGB(parseInt(parsedHex[3], 16))
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export const parseRGB = c => {
|
||||
var cv = colorCache[c];
|
||||
if (!cv) {
|
||||
cv = parseColorName(c);
|
||||
colorCache[c] = cv;
|
||||
}
|
||||
return cv;
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
// jshint esversion: 6
|
||||
/*****************************************
|
||||
******************************************
|
||||
Render Queue via http://bl.ocks.org/syntagmatic/raw/3341641/render-queue.js
|
||||
******************************************
|
||||
******************************************/
|
||||
|
||||
const renderQueue = function(callback1234) {
|
||||
var _queue = [], // data to be rendered
|
||||
_rate = 300, // number of calls per frame
|
||||
_invalidate = function() {}, // invalidate last render queue
|
||||
_clear = function() {}; // clearing function
|
||||
|
||||
var rq = function(ARRAY_FROM_CELLXGENE) {
|
||||
if (ARRAY_FROM_CELLXGENE) rq.data(ARRAY_FROM_CELLXGENE);
|
||||
_invalidate();
|
||||
_clear();
|
||||
rq.render();
|
||||
};
|
||||
|
||||
rq.render = function() {
|
||||
var valid = true;
|
||||
_invalidate = rq.invalidate = function() {
|
||||
valid = false;
|
||||
};
|
||||
|
||||
function doFrame() {
|
||||
if (!valid) return true;
|
||||
var chunk = _queue.splice(0, _rate);
|
||||
chunk.map(callback1234);
|
||||
timer_frame(doFrame);
|
||||
}
|
||||
|
||||
doFrame();
|
||||
};
|
||||
|
||||
rq.data = function(ARRAY_FROM_CELLXGENE) {
|
||||
_invalidate();
|
||||
_queue = ARRAY_FROM_CELLXGENE.slice(0); // creates a copy of the data
|
||||
return rq;
|
||||
};
|
||||
|
||||
rq.add = function(data) {
|
||||
_queue = _queue.concat(data);
|
||||
};
|
||||
|
||||
rq.rate = function(value) {
|
||||
if (!arguments.length) return _rate;
|
||||
_rate = value;
|
||||
return rq;
|
||||
};
|
||||
|
||||
rq.remaining = function() {
|
||||
return _queue.length;
|
||||
};
|
||||
|
||||
// clear the canvas
|
||||
rq.clear = function(func) {
|
||||
if (!arguments.length) {
|
||||
_clear();
|
||||
return rq;
|
||||
}
|
||||
_clear = func;
|
||||
return rq;
|
||||
};
|
||||
|
||||
rq.invalidate = _invalidate;
|
||||
|
||||
var timer_frame =
|
||||
window.requestAnimationFrame ||
|
||||
window.webkitRequestAnimationFrame ||
|
||||
window.mozRequestAnimationFrame ||
|
||||
window.oRequestAnimationFrame ||
|
||||
window.msRequestAnimationFrame ||
|
||||
function(callback) {
|
||||
setTimeout(callback, 17);
|
||||
};
|
||||
|
||||
return rq;
|
||||
};
|
||||
|
||||
export default renderQueue;
|
||||
@@ -0,0 +1,18 @@
|
||||
// jshint esversion: 6
|
||||
|
||||
// Substitute for a d3 linear scale - less flexible, more performant.
|
||||
// Returns a function which will scale a value.
|
||||
//
|
||||
// Example will scale [0,1] to [-1,1]
|
||||
// var myScale = scaleLinear([0, 1], [-1, 1]);
|
||||
// myScale(0) === -1
|
||||
// this is is equivalent to d3.scaleLinear().domain([0,1]).range([-1,1])
|
||||
|
||||
export const scaleLinear = (domain, range) => {
|
||||
const domainStart = domain[0];
|
||||
const scale = (range[1] - range[0]) / (domain[1] - domain[0]);
|
||||
const rangeStart = range[0];
|
||||
return function(value) {
|
||||
return (value - domainStart) * scale + rangeStart;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
// jshint esversion: 6
|
||||
export const scaleRGB = input => {
|
||||
const outputMax = 1;
|
||||
const outputMin = 0;
|
||||
|
||||
const inputMax = 255;
|
||||
const inputMin = 0;
|
||||
|
||||
const percent = (input - inputMin) / (inputMax - inputMin);
|
||||
return percent * (outputMax - outputMin) + outputMin;
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
// jshint esversion: 6
|
||||
|
||||
// In the case where the REST server does not implement data schema
|
||||
// declaration, we attempt to deduce it by sniffing the data.
|
||||
//
|
||||
export function createSchemaByDataSniffing(ranges) {
|
||||
let schema = {};
|
||||
_.forEach(ranges, (value, key) => {
|
||||
schema[key] = {
|
||||
displayname: key,
|
||||
variabletype: value.options ? "categorical" : "continuous"
|
||||
};
|
||||
|
||||
// Metadata field type is inferred by sniffing the data. This has some risks.
|
||||
// Caveats:
|
||||
// * Values have been converted to native JS objects by the JSON parser.
|
||||
// * Lots of assumptions about he REST API behaving properly (eg, min/max
|
||||
// are the same type, etc).
|
||||
let type;
|
||||
if (schema[key].variabletype === "continuous" && value.range) {
|
||||
// Use min/max as a proxy for all data.
|
||||
const min = value.range.min;
|
||||
const max = value.range.max;
|
||||
type =
|
||||
typeof min !== "number" || typeof max !== "number"
|
||||
? "string"
|
||||
: Number.isSafeInteger(min) && Number.isSafeInteger(max)
|
||||
? "int"
|
||||
: "float";
|
||||
} else {
|
||||
// use an option value as a proxy for all data
|
||||
const aVal = value.options[0];
|
||||
type =
|
||||
typeof aVal !== "number"
|
||||
? "string"
|
||||
: Number.isSafeInteger(aVal) ? "int" : "float";
|
||||
}
|
||||
schema[key].type = type;
|
||||
});
|
||||
return schema;
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
"use strict";
|
||||
// jshint esversion: 6
|
||||
|
||||
// BitArray is a 2D bitarray with size [length, nBitWidth].
|
||||
// Each bit is referred to as a `dimension`. Dimensions may be
|
||||
// dynamically allocated and deallocated. The overall length
|
||||
// of the BitArray is fixed at creation time (for simplicity).
|
||||
//
|
||||
// Organization of the bitarray is dimension-major. As dimensions
|
||||
// are added, the underlying store is grown 32 bits at a time.
|
||||
// NOTE: currently does not deallocate / shrink.
|
||||
//
|
||||
// Primary operations on the BitArray are:
|
||||
// - set & clear dimension
|
||||
// - test dimension
|
||||
// - various performance or convenience operations to optimize bulk ops
|
||||
//
|
||||
// The underlying data structure uses TypedArrays for performance.
|
||||
//
|
||||
class BitArray {
|
||||
constructor(length) {
|
||||
// Initially allocate a 32 bit wide array. allocDimension() will expand
|
||||
// as necessary.
|
||||
//
|
||||
// Int32Array is (counterintuitively) used to accomadate JS numeric casting
|
||||
// (to/from primitive number type).
|
||||
//
|
||||
|
||||
// Fixed for the life of this object.
|
||||
this.length = length;
|
||||
|
||||
// Bitarray width. width is always greater than 32*dimensionCount.
|
||||
this.width = 1; // underlying number of 32 bit arrays
|
||||
this.dimensionCount = 0; // num allocated dimensions
|
||||
|
||||
this.bitmask = new Int32Array(this.width); // dimension allocation mask
|
||||
this.bitarray = new Int32Array(this.width * this.length);
|
||||
}
|
||||
|
||||
// Return the number of records that are selected, ie, have a one bit in
|
||||
// all allocated dimensions.
|
||||
//
|
||||
get selectionCount() {
|
||||
return this.countAllOnes();
|
||||
}
|
||||
|
||||
// Count all records that have a 'one' bit in allocated dimensions.
|
||||
//
|
||||
countAllOnes() {
|
||||
let count = 0;
|
||||
for (let i = 0; i < this.width; i++) {
|
||||
const bitmask = this.bitmask[i];
|
||||
for (let j = i * this.length, len = j + this.length; j < len; j++) {
|
||||
if (this.bitarray[i * this.length + j] === bitmask) count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// count trailing zeros - hard to do fast in JS!
|
||||
// https://en.wikipedia.org/wiki/Find_first_set#CTZ
|
||||
static ctz(v) {
|
||||
let c = 32;
|
||||
v &= -v; // isolate lowest non-zero bit
|
||||
if (v) c--;
|
||||
if (v & 0x0000ffff) c -= 16;
|
||||
if (v & 0x00ff00ff) c -= 8;
|
||||
if (v & 0x0f0f0f0f) c -= 4;
|
||||
if (v & 0x33333333) c -= 2;
|
||||
if (v & 0x55555555) c -= 1;
|
||||
return c;
|
||||
}
|
||||
|
||||
// find a free dimension. Return undefined if none
|
||||
_findFreeDimension() {
|
||||
let dim;
|
||||
for (let col = 0; col < this.width; col++) {
|
||||
const bitmask = this.bitmask[col];
|
||||
const lowestZeroBit = ~this.bitmask[col] & -~this.bitmask[col];
|
||||
if (lowestZeroBit) {
|
||||
this.bitmask[col] |= lowestZeroBit;
|
||||
dim = 32 * col + BitArray.ctz(lowestZeroBit);
|
||||
}
|
||||
}
|
||||
return dim;
|
||||
}
|
||||
|
||||
// allocate and return the dimension ID (bit position)
|
||||
//
|
||||
allocDimension() {
|
||||
let dim = this._findFreeDimension();
|
||||
|
||||
// if we did not find free dimension, expand the bitarray.
|
||||
if (dim === undefined) {
|
||||
this.width++;
|
||||
|
||||
const biggerBitArray = new Int32Array(this.width * this.length);
|
||||
biggerBitArray.set(this.bitarray);
|
||||
this.bitarray = biggerBitArray;
|
||||
|
||||
const biggerBitmask = new Int32Array(this.width);
|
||||
biggerBitmask.set(this.bitmask);
|
||||
this.bitmask = biggerBitmask;
|
||||
|
||||
dim = this._findFreeDimension();
|
||||
}
|
||||
|
||||
this.dimensionCount++;
|
||||
return dim;
|
||||
}
|
||||
|
||||
// free a dimension for later use. MUST deselect the dimension, as other
|
||||
// code assume the column will be zero valued.
|
||||
//
|
||||
freeDimension(dim) {
|
||||
// all selection tests assume unallocated dimensions are zero valued.
|
||||
this.deselectAll(dim);
|
||||
const col = dim >>> 5;
|
||||
this.bitmask[col] &= ~(1 << (dim % 32));
|
||||
this.dimensionCount--;
|
||||
}
|
||||
|
||||
// return true if this index is selected in ALL dimensions.
|
||||
//
|
||||
isSelected(index) {
|
||||
const width = this.width;
|
||||
const length = this.length;
|
||||
const bitarray = this.bitarray;
|
||||
|
||||
for (let w = 0; w < width; w++) {
|
||||
const bitmask = this.bitmask[w];
|
||||
if (!bitmask || bitarray[w * length + index] !== bitmask) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// select index on dimension
|
||||
//
|
||||
selectOne(dim, index) {
|
||||
const col = dim >>> 5;
|
||||
const before = this.bitarray[col * this.length + index];
|
||||
const after = before | (1 << (dim % 32));
|
||||
this.bitarray[col] = after;
|
||||
}
|
||||
|
||||
// deselect index on dimension
|
||||
//
|
||||
deselectOne(dim, index) {
|
||||
const col = dim >>> 5;
|
||||
const before = this.bitarray[col * this.length + index];
|
||||
const after = before & ~(1 << (dim % 32));
|
||||
this.bitarray[col] = after;
|
||||
}
|
||||
|
||||
// select all indices on dimension.
|
||||
//
|
||||
selectAll(dim) {
|
||||
let col = dim >> 5;
|
||||
const bitmask = this.bitmask[col];
|
||||
const bitarray = this.bitarray;
|
||||
const one = 1 << (dim % 32);
|
||||
for (let i = col * this.length, len = i + this.length; i < len; i++) {
|
||||
bitarray[i] |= one;
|
||||
}
|
||||
}
|
||||
|
||||
// deselect all indices on dimension
|
||||
//
|
||||
deselectAll(dim) {
|
||||
let col = dim >> 5;
|
||||
const bitmask = this.bitmask[col];
|
||||
const bitarray = this.bitarray;
|
||||
const zero = ~(1 << (dim % 32));
|
||||
for (let i = col * this.length, len = i + this.length; i < len; i++) {
|
||||
bitarray[i] &= zero;
|
||||
}
|
||||
}
|
||||
|
||||
// select range of indices on a dimension, indirect through a sort map.
|
||||
// Indirect functions are used to map between sort and natural order.
|
||||
//
|
||||
selectIndirectFromRange(dim, indirect, range) {
|
||||
const col = dim >>> 5;
|
||||
const first = range[0];
|
||||
const last = range[1];
|
||||
const bitarray = this.bitarray;
|
||||
const one = 1 << (dim % 32);
|
||||
const offset = col * this.length;
|
||||
for (let i = first; i < last; i++) {
|
||||
bitarray[offset + indirect[i]] |= one;
|
||||
}
|
||||
}
|
||||
|
||||
// deselect range of indices on a dimension, indirect through a sort map.
|
||||
//
|
||||
deselectIndirectFromRange(dim, indirect, range) {
|
||||
const col = dim >>> 5;
|
||||
const first = range[0];
|
||||
const last = range[1];
|
||||
const bitarray = this.bitarray;
|
||||
const zero = ~(1 << (dim % 32));
|
||||
const offset = col * this.length;
|
||||
for (let i = first; i < last; i++) {
|
||||
bitarray[offset + indirect[i]] &= zero;
|
||||
}
|
||||
}
|
||||
|
||||
// Fill the array with selected|deselected value based upon the
|
||||
// current selection state.
|
||||
//
|
||||
fillBySelection(result, selectedValue, deselectedValue) {
|
||||
// special case (width === 1) for performance
|
||||
if (this.width === 1) {
|
||||
const bitmask = this.bitmask[0];
|
||||
const bitarray = this.bitarray;
|
||||
for (let i = 0, len = this.length; i < len; i++) {
|
||||
result[i] = bitarray[i] === bitmask ? selectedValue : deselectedValue;
|
||||
}
|
||||
} else {
|
||||
for (let i = 0, len = this.length; i < len; i++) {
|
||||
result[i] = this.isSelected(i) ? selectedValue : deselectedValue;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BitArray;
|
||||
@@ -0,0 +1,394 @@
|
||||
"use strict";
|
||||
// jshint esversion: 6
|
||||
|
||||
/*
|
||||
Typedarray Crossfilter - a re-implementation of a subset of crossfilter, with
|
||||
time/space optimizations predicated upon the following assumptions:
|
||||
- dimensions are uniformly typed, and all values must be of that type
|
||||
- dimension values must be a primitive type (int, float, string). Arrays
|
||||
or other complex types not supported.
|
||||
- dimension creation requires call-provided type declaration
|
||||
- no support for adding/removing data to an existing crossfilter. If you
|
||||
want to do that, you have to create the new crossfilter, using the new
|
||||
data, from scratch.
|
||||
|
||||
The actual backing store for a dimension is a TypedArray, enabling significant
|
||||
performance improvements over the original crossfilter.
|
||||
|
||||
There are also a handful of new methods, primarily to take advantage of the
|
||||
performance (eg, crossfilter.fillBySelection)
|
||||
|
||||
Helpful documents (this module tries to follow the original API as much
|
||||
as is feasable):
|
||||
https://github.com/square/crossfilter/
|
||||
http://square.github.io/crossfilter/
|
||||
|
||||
There is also a newer, community supported fork of crossfilter, with a
|
||||
more complex API. In a few cases, elements of that API were incorporated.
|
||||
https://github.com/square/crossfilter/
|
||||
|
||||
*/
|
||||
|
||||
var PositiveIntervals = require("./positiveIntervals");
|
||||
var BitArray = require("./bitArray");
|
||||
var Util = require("./util");
|
||||
|
||||
class TypedCrossfilter {
|
||||
constructor(data) {
|
||||
this.data = data;
|
||||
|
||||
// filters: array of { id, dimension }
|
||||
this.filters = [];
|
||||
this.selection = new BitArray(data.length);
|
||||
}
|
||||
|
||||
size() {
|
||||
return this.data.length;
|
||||
}
|
||||
|
||||
all() {
|
||||
return this.data;
|
||||
}
|
||||
|
||||
dimension(value, valueArrayType) {
|
||||
const id = this.selection.allocDimension();
|
||||
let dim;
|
||||
if (valueArrayType === "enum") {
|
||||
dim = new EnumDimension(value, this, id);
|
||||
} else {
|
||||
dim = new ScalarDimension(value, valueArrayType, this, id);
|
||||
}
|
||||
this.filters.push({ id, dim });
|
||||
dim.filterAll();
|
||||
return dim;
|
||||
}
|
||||
|
||||
_freeDimension(id) {
|
||||
this.selection.freeDimension(id);
|
||||
this.filters = this.filters.filter(f => f.id != id);
|
||||
}
|
||||
|
||||
// return array of all records that are selected/filtered
|
||||
// by all dimensions.
|
||||
allFiltered() {
|
||||
const selection = this.selection;
|
||||
const res = [];
|
||||
for (let i = 0, len = this.data.length; i < len; i++) {
|
||||
if (selection.isSelected(i)) {
|
||||
res.push(this.data[i]);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
countFiltered() {
|
||||
return this.selection.selectionCount;
|
||||
}
|
||||
|
||||
isElementFiltered(i) {
|
||||
return this.selection.isSelected(i);
|
||||
}
|
||||
|
||||
// fill array with one of two values, based upon selection state
|
||||
fillByIsFiltered(array, selectedValue, deselectedValue) {
|
||||
return this.selection.fillBySelection(
|
||||
array,
|
||||
selectedValue,
|
||||
deselectedValue
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Base dimension type - value must be a scalar type (eg, int, float),
|
||||
// and value array must be a TypedArray.
|
||||
//
|
||||
class ScalarDimension {
|
||||
constructor(value, valueArrayType, crossfilter, id) {
|
||||
this.crossfilter = crossfilter;
|
||||
this.id = id;
|
||||
|
||||
// current selection filter, expressed as PostiveIntervals.
|
||||
this.currentFilter = [];
|
||||
|
||||
// Create value array
|
||||
const array = this._createValueArray(
|
||||
value,
|
||||
new valueArrayType(this.crossfilter.data.length)
|
||||
);
|
||||
this.value = array;
|
||||
|
||||
// create sort index
|
||||
this.index = Util.fillRange(new Uint32Array(this.crossfilter.data.length));
|
||||
this.index.sort((a, b) => array[a] - array[b]);
|
||||
}
|
||||
|
||||
_createValueArray(value, array) {
|
||||
// create dimension value array
|
||||
const data = this.crossfilter.data;
|
||||
const len = data.length;
|
||||
for (let i = 0; i < len; i++) {
|
||||
array[i] = value(data[i]);
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.crossfilter._freeDimension(this.id);
|
||||
}
|
||||
|
||||
id() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
_updateFilters(newFilter) {
|
||||
newFilter = PositiveIntervals.canonicalize(newFilter);
|
||||
|
||||
// special case optimization - select all/none can bypass
|
||||
// more complex work and just clobber everything.
|
||||
//
|
||||
if (newFilter.length === 0) {
|
||||
this.crossfilter.selection.deselectAll(this.id);
|
||||
} else if (
|
||||
newFilter.length === 1 &&
|
||||
newFilter[0][0] === 0 &&
|
||||
newFilter[0][1] == this.index.length
|
||||
) {
|
||||
this.crossfilter.selection.selectAll(this.id);
|
||||
} else {
|
||||
const adds = PositiveIntervals.difference(newFilter, this.currentFilter);
|
||||
const dels = PositiveIntervals.difference(this.currentFilter, newFilter);
|
||||
dels.forEach(interval =>
|
||||
this.crossfilter.selection.deselectIndirectFromRange(
|
||||
this.id,
|
||||
this.index,
|
||||
interval
|
||||
)
|
||||
);
|
||||
adds.forEach(interval =>
|
||||
this.crossfilter.selection.selectIndirectFromRange(
|
||||
this.id,
|
||||
this.index,
|
||||
interval
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
this.currentFilter = newFilter;
|
||||
}
|
||||
|
||||
// filter by value - exact match
|
||||
filterExact(value) {
|
||||
const newFilter = [
|
||||
Util.lowerBoundIndirect(
|
||||
this.value,
|
||||
this.index,
|
||||
value,
|
||||
0,
|
||||
this.value.length
|
||||
),
|
||||
Util.upperBoundIndirect(
|
||||
this.value,
|
||||
this.index,
|
||||
value,
|
||||
0,
|
||||
this.value.length
|
||||
)
|
||||
];
|
||||
if (newFilter[0] <= newFilter[1]) {
|
||||
this._updateFilters([newFilter]);
|
||||
} else {
|
||||
this._updateFilters([]);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
// filter by a set of values, eg. enum.
|
||||
filterEnum(values) {
|
||||
const newFilter = [];
|
||||
for (let v = 0, len = values.length; v < len; v++) {
|
||||
const intv = [
|
||||
Util.lowerBoundIndirect(
|
||||
this.value,
|
||||
this.index,
|
||||
values[v],
|
||||
0,
|
||||
this.value.length
|
||||
),
|
||||
Util.upperBoundIndirect(
|
||||
this.value,
|
||||
this.index,
|
||||
values[v],
|
||||
0,
|
||||
this.value.length
|
||||
)
|
||||
];
|
||||
if (intv[0] <= intv[1]) newFilter.push(intv);
|
||||
}
|
||||
this._updateFilters(newFilter);
|
||||
return this;
|
||||
}
|
||||
|
||||
// filter by value range [lo, hi)
|
||||
// lo: inclusive, hi: exclusive
|
||||
filterRange(range) {
|
||||
const newFilter = [];
|
||||
const intv = [
|
||||
Util.lowerBoundIndirect(
|
||||
this.value,
|
||||
this.index,
|
||||
range[0],
|
||||
0,
|
||||
this.value.length
|
||||
),
|
||||
Util.upperBoundIndirect(
|
||||
this.value,
|
||||
this.index,
|
||||
range[1],
|
||||
0,
|
||||
this.value.length
|
||||
)
|
||||
];
|
||||
if (intv[0] < intv[1]) newFilter.push(intv);
|
||||
this._updateFilters(newFilter);
|
||||
return this;
|
||||
}
|
||||
|
||||
// select all - equivalent of selecting all in this dimension
|
||||
filterAll() {
|
||||
this._updateFilters([[0, this.value.length]]);
|
||||
return this;
|
||||
}
|
||||
|
||||
// select none
|
||||
filterNone() {
|
||||
this._updateFilters([]);
|
||||
}
|
||||
|
||||
// return top k records, starting with offset, in descending order.
|
||||
// Order is this dimension's sort order
|
||||
top(k, offset = 0) {
|
||||
const data = this.crossfilter.data;
|
||||
const selection = this.crossfilter.selection;
|
||||
const index = this.index;
|
||||
const len = index.length;
|
||||
const ret = [];
|
||||
let i = 0;
|
||||
let skip = 0;
|
||||
let found = 0;
|
||||
|
||||
// skip up to offset records
|
||||
for (i = len - 1; 0 <= i && skip < offset; i--) {
|
||||
if (selection.isSelected(index[i])) {
|
||||
skip++;
|
||||
}
|
||||
}
|
||||
|
||||
// grab up to k records
|
||||
for (; 0 <= i && found < k; i--) {
|
||||
if (selection.isSelected(index[i])) {
|
||||
ret.push(data[index[i]]);
|
||||
found++;
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
// return bottom k records, starting with offset, in ascending order.
|
||||
// Order is this dimension's sort order
|
||||
bottom(k, offset = 0) {
|
||||
const data = this.crossfilter.data;
|
||||
const selection = this.crossfilter.selection;
|
||||
const index = this.index;
|
||||
const len = index.length;
|
||||
const ret = [];
|
||||
let skip = 0;
|
||||
let found = 0;
|
||||
let i = 0;
|
||||
|
||||
// skip up to offset records
|
||||
for (i = 0; i < len && skip < offset; i++) {
|
||||
if (selection.isSelected(index[i])) {
|
||||
skip++;
|
||||
}
|
||||
}
|
||||
|
||||
// grab up to k records
|
||||
for (; i < len && found < k; i++) {
|
||||
if (selection.isSelected(index[i])) {
|
||||
ret.push(data[index[i]]);
|
||||
found++;
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
|
||||
// Ordered enumeration - supports any sortable enumerable type, eg,
|
||||
// strings, which can be mapped into an fixed numeric range [0..n).
|
||||
//
|
||||
class EnumDimension extends ScalarDimension {
|
||||
constructor(value, crossfilter, id) {
|
||||
super(value, Uint32Array, crossfilter, id);
|
||||
}
|
||||
|
||||
_createValueArray(value, array) {
|
||||
const data = this.crossfilter.data;
|
||||
const len = data.length;
|
||||
|
||||
// create enumeration table - mapping between the value
|
||||
// and the enum.
|
||||
const s = new Set();
|
||||
for (let i = 0; i < len; i++) {
|
||||
s.add(value(data[i]));
|
||||
}
|
||||
this.enumIndex = Array.from(s);
|
||||
this.enumIndex.sort();
|
||||
|
||||
// create dimension value array
|
||||
const enumLen = this.enumIndex.length;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const v = value(data[i]);
|
||||
const e = Util.lowerBound(this.enumIndex, v, 0, enumLen);
|
||||
array[i] = e;
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
filterExact(value) {
|
||||
return super.filterExact(
|
||||
Util.lowerBound(this.enumIndex, value, 0, this.enumIndex.length)
|
||||
);
|
||||
}
|
||||
|
||||
filterEnum(values) {
|
||||
return super.filterEnum(
|
||||
values.map(v =>
|
||||
Util.lowerBound(this.enumIndex, v, 0, this.enumIndex.length)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
filterRange(range) {
|
||||
return super.filterEnum(
|
||||
range.map(v =>
|
||||
Util.lowerBound(this.enumIndex, v, 0, this.enumIndex.length)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapper for backwards compat with crossfilter.
|
||||
//
|
||||
function crossfilter(data) {
|
||||
return new TypedCrossfilter(data);
|
||||
}
|
||||
|
||||
crossfilter.PositiveIntervals = PositiveIntervals;
|
||||
crossfilter.BitArray = BitArray;
|
||||
crossfilter.TypedCrossfilter = TypedCrossfilter;
|
||||
crossfilter.ScalarDimension = ScalarDimension;
|
||||
crossfilter.EnumDimension = EnumDimension;
|
||||
|
||||
module.exports = crossfilter;
|
||||
@@ -0,0 +1,132 @@
|
||||
"use strict";
|
||||
// jshint esversion: 6
|
||||
|
||||
// Interval operations - very simple version of interval set relationship
|
||||
// operators. An interval is a multi-interval list of [min, max),
|
||||
// where min and max are mandatory. Constraints:
|
||||
// * min <= max, min >= 0
|
||||
// * empty interval groups are OK, ie, []
|
||||
// * Legal intervals: [], [ [0, 1], ... ]
|
||||
// * Not legal: [ [] ]
|
||||
//
|
||||
// All intervals are represented by simple JS arrays/numbers.
|
||||
//
|
||||
// Code assumes intervals have a low cardinality; many operations are done
|
||||
// with a brute force scan. Little attempt to reduce GC pressure.
|
||||
//
|
||||
class PositiveIntervals {
|
||||
// Canonicalize - ensure that:
|
||||
// 1. no overlapping intervals
|
||||
// 2. sorted in order of interval min.
|
||||
//
|
||||
static canonicalize(A) {
|
||||
if (A.length <= 1) return A;
|
||||
let copy = A.slice();
|
||||
copy.sort((a, b) => a[0] - b[0]);
|
||||
const res = [];
|
||||
res.push(copy[0]);
|
||||
for (let i = 1, len = copy.length; i < len; i++) {
|
||||
if (copy[i][0] > res[res.length - 1][1]) {
|
||||
// non-overlapping, add to result
|
||||
res.push(copy[i]);
|
||||
} else if (copy[i][1] > res[res.length - 1][1]) {
|
||||
// merge this into previous
|
||||
res[res.length - 1][1] = copy[i][1];
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// Return interval with values belonging to both A and B. Essentially
|
||||
// a set union operation.
|
||||
//
|
||||
static union(A, B) {
|
||||
return PositiveIntervals.canonicalize([...A, ...B]);
|
||||
}
|
||||
|
||||
static _flatten(A, B) {
|
||||
let points = []; /* point, A, start */
|
||||
for (let a = 0; a < A.length; a++) {
|
||||
points.push([A[a][0], true, true]);
|
||||
points.push([A[a][1], true, false]);
|
||||
}
|
||||
for (let b = 0; b < B.length; b++) {
|
||||
points.push([B[b][0], false, true]);
|
||||
points.push([B[b][1], false, false]);
|
||||
}
|
||||
// Sort order: point, then start
|
||||
points.sort((a, b) => (a[0] !== b[0] ? a[0] - b[0] : a[2] ? 1 : -1));
|
||||
return points;
|
||||
}
|
||||
|
||||
// A - B, ie, the interval with all values in A that are not in B. Essentially
|
||||
// a set difference operation.
|
||||
//
|
||||
static difference(A, B) {
|
||||
// Corner cases
|
||||
if (A.length === 0 || B.length === 0) {
|
||||
return PositiveIntervals.canonicalize(A);
|
||||
}
|
||||
|
||||
A = PositiveIntervals.canonicalize(A);
|
||||
B = PositiveIntervals.canonicalize(B);
|
||||
|
||||
const points = PositiveIntervals._flatten(A, B);
|
||||
const res = [];
|
||||
let aDepth = 0;
|
||||
let depth = 0;
|
||||
let intervalStart;
|
||||
let prevPoint;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const p = points[i];
|
||||
const before = depth;
|
||||
const delta = p[2] ? 1 : -1;
|
||||
depth += delta;
|
||||
if (p[1]) aDepth += delta;
|
||||
|
||||
if (i === points.length - 1 || p[0] !== points[i + 1][0]) {
|
||||
if (aDepth === 1 && depth === 1) {
|
||||
intervalStart = p[0];
|
||||
} else if (intervalStart !== undefined) {
|
||||
res.push([intervalStart, p[0]]);
|
||||
intervalStart = undefined;
|
||||
}
|
||||
}
|
||||
prevPoint = p[0];
|
||||
}
|
||||
// guaranteed to be in canonical form
|
||||
return res;
|
||||
}
|
||||
|
||||
// Return interval with values belonging to A or B. Essentially a set
|
||||
// intersection.
|
||||
//
|
||||
static intersection(A, B) {
|
||||
if (A.length === 0 || B.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
A = PositiveIntervals.canonicalize(A);
|
||||
B = PositiveIntervals.canonicalize(B);
|
||||
|
||||
const points = PositiveIntervals._flatten(A, B);
|
||||
const res = [];
|
||||
let depth = 0;
|
||||
let intervalStart;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const p = points[i];
|
||||
const before = depth;
|
||||
depth += p[2] ? 1 : -1;
|
||||
if (depth === 2) {
|
||||
intervalStart = p[0];
|
||||
} else if (intervalStart !== undefined) {
|
||||
res.push([intervalStart, p[0]]);
|
||||
intervalStart = undefined;
|
||||
}
|
||||
}
|
||||
// guaranteed to be in canonical form
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PositiveIntervals;
|
||||
@@ -0,0 +1,106 @@
|
||||
"use strict";
|
||||
// jshint esversion: 6
|
||||
|
||||
/*
|
||||
Utility functions, private to this module.
|
||||
*/
|
||||
|
||||
// fill an array or typedarray with a sequential range of numbers,
|
||||
// starting with `start`
|
||||
//
|
||||
function fillRange(arr, start = 0) {
|
||||
for (let i = 0, len = arr.length; i < len; i++) {
|
||||
arr[i] = i + start;
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
// Search for `value` in the sorted array `arr`, in the range [first, last).
|
||||
// Return the first (left most) index where arr[index] >= value.
|
||||
//
|
||||
// In other words, return array index I where:
|
||||
// arr[i] < value for all tarr[lo:I]
|
||||
// arr[i] >= value for all tarr[I:last]
|
||||
//
|
||||
// The same semantics/behavior as:
|
||||
// C++: lower_bound()
|
||||
// Python: bisect.bisect_left()
|
||||
//
|
||||
// XXX: it is likely that there would be minimal performance hit from creating
|
||||
// a factory version of lowerBound that takes an accessor (rather than having
|
||||
// a special-cased version for lining the indirection).
|
||||
//
|
||||
function lowerBound(valueArray, value, first, last) {
|
||||
// this is just a binary search
|
||||
while (first < last) {
|
||||
const middle = (first + last) >>> 1;
|
||||
if (valueArray[middle] < value) {
|
||||
first = middle + 1;
|
||||
} else {
|
||||
last = middle;
|
||||
}
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
// Inlined performance optimization - used to indirect through a sort map.
|
||||
//
|
||||
function lowerBoundIndirect(valueArray, indexArray, value, first, last) {
|
||||
// this is just a binary search
|
||||
while (first < last) {
|
||||
const middle = (first + last) >>> 1;
|
||||
if (valueArray[indexArray[middle]] < value) {
|
||||
first = middle + 1;
|
||||
} else {
|
||||
last = middle;
|
||||
}
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
// Search for `value in the sorted array `arr`, in the range [first, last).
|
||||
// Return the first value where arr[index] > value.
|
||||
//
|
||||
// In other words, return array index I, where:
|
||||
// arr[i] <= value for all tarr[lo:I]
|
||||
// arr[i] > value for all tarr[I:last]
|
||||
//
|
||||
// The same semantics/behavior as:
|
||||
// C++: upper_bound()
|
||||
// Python: bisect.bisect_right()
|
||||
//
|
||||
function upperBound(valueArray, value, first, last) {
|
||||
// this is just a binary search
|
||||
while (first < last) {
|
||||
const middle = (first + last) >>> 1;
|
||||
if (valueArray[middle] > value) {
|
||||
last = middle;
|
||||
} else {
|
||||
first = middle + 1;
|
||||
}
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
// Inline performance optimization
|
||||
//
|
||||
function upperBoundIndirect(valueArray, indexArray, value, first, last) {
|
||||
// this is just a binary search
|
||||
while (first < last) {
|
||||
const middle = (first + last) >>> 1;
|
||||
if (valueArray[indexArray[middle]] > value) {
|
||||
last = middle;
|
||||
} else {
|
||||
first = middle + 1;
|
||||
}
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fillRange,
|
||||
lowerBound,
|
||||
lowerBoundIndirect,
|
||||
upperBound,
|
||||
upperBoundIndirect
|
||||
};
|
||||
Reference in New Issue
Block a user