commit f6d7cc3e239132d73b4bdcd97062db27fe36b785 Author: Colin Megill Date: Wed Aug 23 16:28:01 2017 -0700 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..cd52f01f --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# dependencies +node_modules + +# coverage +coverage +.nyc_output + +# production +build + +# misc +.DS_Store +npm-debug.log +.vscode + +data diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..b5c4d700 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,29 @@ +language: node_js + +node_js: + - 4 + - 5 + - 6 + +# Use container-based Travis infrastructure. +sudo: false + +branches: + only: + - master + - /^greenkeeper-.*$/ + +notifications: + email: + on_success: change + on_failure: always + +before_install: + - npm install -g npm@3 + - export DISPLAY=:99.0 + - sh -e /etc/init.d/xvfb start + +script: + - npm run lint + - npm run test + - cat coverage/lcov.info | node_modules/.bin/coveralls || echo "Coveralls upload failed" diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 00000000..e0bb8c7d --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2013 + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..8740e0a6 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +#cellxgene + +##Quickstart: + +* `npm install` +* `npm run dev` +* `localhost:3000` diff --git a/configuration/babel/babel.dev.js b/configuration/babel/babel.dev.js new file mode 100644 index 00000000..858ff07b --- /dev/null +++ b/configuration/babel/babel.dev.js @@ -0,0 +1,10 @@ +module.exports = { + babelrc: false, + cacheDirectory: true, + presets: [ + [ 'es2015', { loose: true, modules: false } ], + 'stage-0', + 'react' + ], + plugins: [ 'react-hot-loader/babel' ] +}; diff --git a/configuration/babel/babel.prod.js b/configuration/babel/babel.prod.js new file mode 100644 index 00000000..5c2e7c65 --- /dev/null +++ b/configuration/babel/babel.prod.js @@ -0,0 +1,11 @@ +module.exports = { + babelrc: false, + presets: [ + [ 'es2015', { loose: true, modules: false } ], + 'stage-0', + 'react' + ], + plugins: [ 'babel-plugin-transform-react-constant-elements' ] + .map(require.resolve) + .concat([ [ require.resolve('babel-plugin-transform-runtime') ] ]) +}; diff --git a/configuration/babel/babel.test.js b/configuration/babel/babel.test.js new file mode 100644 index 00000000..e18d5e58 --- /dev/null +++ b/configuration/babel/babel.test.js @@ -0,0 +1,9 @@ +module.exports = { + babelrc: false, + presets: [ + 'babel-preset-es2015', + 'babel-preset-stage-0', + 'babel-preset-react' + ], + plugins: [ 'istanbul' ] +}; diff --git a/configuration/eslint/eslint.js b/configuration/eslint/eslint.js new file mode 100644 index 00000000..5648696b --- /dev/null +++ b/configuration/eslint/eslint.js @@ -0,0 +1,29 @@ +module.exports = { + root: true, + parser: 'babel-eslint', + extends: 'formidable/configurations/es6-react', + env: { browser: true, commonjs: true, es6: true, node: true, mocha: true }, + globals: { expect: true }, + parserOptions: { + ecmaVersion: 6, + sourceType: 'module', + ecmaFeatures: { + jsx: true, + generators: true, + experimentalObjectRestSpread: true + } + }, + rules: { + quotes: [ 2, 'single', { allowTemplateLiterals: true } ], + 'no-magic-numbers': 'off', + 'func-style': 'off', + 'arrow-parens': 'off', + 'no-use-before-define': 'off', + 'react/jsx-filename-extension': 'off', + 'react/require-extension': 'off', + 'react/no-multi-comp': 'warn', + 'react/prop-types': 'warn', + 'react/sort-comp': 'warn', + 'react/sort-prop-types': 'warn' + } +}; diff --git a/configuration/polyfills/polyfills.js b/configuration/polyfills/polyfills.js new file mode 100644 index 00000000..1aa797e9 --- /dev/null +++ b/configuration/polyfills/polyfills.js @@ -0,0 +1,6 @@ +if (typeof Promise === 'undefined') { + require('promise/lib/rejection-tracking').enable(); + window.Promise = require('promise/lib/es6-extensions.js'); +} + +require('whatwg-fetch'); diff --git a/configuration/webpack/webpack.config.dev.js b/configuration/webpack/webpack.config.dev.js new file mode 100644 index 00000000..07e485e0 --- /dev/null +++ b/configuration/webpack/webpack.config.dev.js @@ -0,0 +1,74 @@ +const path = require('path'); +const autoprefixer = require('autoprefixer'); +const webpack = require('webpack'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); + +const src = path.resolve('src'); +const nodeModules = path.resolve('node_modules'); + +module.exports = { + devtool: 'eval', + entry: [ + 'webpack-hot-middleware/client?quiet=true&noInfo=true', + require.resolve('react-hot-loader/patch'), + require.resolve('../polyfills/polyfills'), + path.join(src, 'index') + ], + output: { + path: path.resolve('build'), + pathinfo: true, + filename: 'static/js/bundle.js', + publicPath: '/' + }, + resolve: { extensions: [ '.js', '.json' ] }, + module: { + loaders: [ + { + test: /\.js$/, + include: src, + loader: 'babel-loader', + query: require('../babel/babel.dev') + }, + { + test: /\.css$/, + include: [ src, nodeModules ], + loader: 'style-loader!css-loader?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]!postcss-loader' + }, + { test: /\.json$/, include: [ src, nodeModules ], loader: 'json-loader' }, + { + test: /\.(jpg|png|gif|eot|svg|ttf|woff|woff2)(\?.*)?$/, + include: [ src, nodeModules ], + loader: 'file-loader', + query: { name: 'static/media/[name].[ext]' } + }, + { + test: /\.(mp4|webm)(\?.*)?$/, + include: [ src, nodeModules ], + loader: 'url-loader', + query: { limit: 10000, name: 'static/media/[name].[ext]' } + } + ] + }, + plugins: [ + new HtmlWebpackPlugin({ + inject: true, + template: path.resolve('index.html'), + favicon: path.resolve('favicon.png') + }), + new webpack.LoaderOptionsPlugin({ + options: { + eslint: { + configFile: path.resolve('./configuration/eslint/eslint.js'), + useEslintrc: false + }, + postcss() { + return [ autoprefixer ]; + } + } + }), + new webpack.DefinePlugin({ 'process.env.NODE_ENV': '"development"' }), + // Note: only CSS is currently hot reloaded + new webpack.HotModuleReplacementPlugin(), + new webpack.NoEmitOnErrorsPlugin() + ] +}; diff --git a/configuration/webpack/webpack.config.prod.js b/configuration/webpack/webpack.config.prod.js new file mode 100644 index 00000000..9649c166 --- /dev/null +++ b/configuration/webpack/webpack.config.prod.js @@ -0,0 +1,112 @@ +const path = require('path'); +const autoprefixer = require('autoprefixer'); +const webpack = require('webpack'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); +const ExtractTextPlugin = require('extract-text-webpack-plugin'); +const SWPrecacheWebpackPlugin = require('sw-precache-webpack-plugin'); +const CopyWebpackPlugin = require('copy-webpack-plugin'); +const HtmlWebpackInlineSourcePlugin = require( + 'html-webpack-inline-source-plugin' +); + +const src = path.resolve('src'); +const nodeModules = path.resolve('node_modules'); + +const publicPath = '/'; + +module.exports = { + bail: true, + devtool: 'source-map', + entry: [ require.resolve('../polyfills/polyfills'), path.join(src, 'index') ], + output: { + path: path.resolve('build'), + filename: 'static/js/[name].[chunkhash:8].js', + chunkFilename: 'static/js/[name].[chunkhash:8].chunk.js', + publicPath + }, + resolve: { extensions: [ '.js', '.json' ] }, + module: { + loaders: [ + { + test: /\.js$/, + include: src, + loader: 'babel-loader', + query: require('../babel/babel.prod') + }, + { + test: /\.css$/, + include: [ src, nodeModules ], + loader: ExtractTextPlugin.extract({ + fallbackLoader: 'style-loader', + loader: 'css-loader?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]-autoprefixer!postcss-loader' + }) + }, + { + test: /\.json$/, + include: [ src, nodeModules ], + loader: 'json-loader', + exclude: /manifest.json$/ + }, + { + test: /\.(jpg|png|gif|eot|svg|ttf|woff|woff2)(\?.*)?$/, + include: [ src, nodeModules ], + loader: 'file-loader', + query: { name: 'static/media/[name].[hash:8].[ext]' } + }, + { + test: /\.(mp4|webm)(\?.*)?$/, + include: [ src, nodeModules ], + loader: 'url-loader', + query: { limit: 10000, name: 'static/media/[name].[hash:8].[ext]' } + } + ] + }, + plugins: [ + new HtmlWebpackPlugin({ + inject: 'body', + template: path.resolve('index.html'), + favicon: path.resolve('favicon.png'), + inlineSource: '.(js|css)$', + minify: { + removeComments: true, + collapseWhitespace: true, + removeRedundantAttributes: true, + useShortDoctype: true, + removeEmptyAttributes: true, + removeStyleLinkTypeAttributes: true, + keepClosingSlash: true, + minifyJS: true, + minifyCSS: true, + minifyURLs: true + } + }), + new HtmlWebpackInlineSourcePlugin(), + new webpack.LoaderOptionsPlugin({ + options: { + eslint: { + configFile: path.resolve('./configuration/eslint/eslint.js'), + useEslintrc: false + }, + postcss() { + return [ autoprefixer ]; + } + } + }), + new webpack.DefinePlugin({ 'process.env.NODE_ENV': '"production"' }), + new webpack.optimize.OccurrenceOrderPlugin(), + new webpack.optimize.UglifyJsPlugin({ + compress: { screw_ie8: true, warnings: false }, + mangle: { screw_ie8: true }, + output: { comments: false, screw_ie8: true } + }), + new ExtractTextPlugin('static/css/[name].[contenthash:8].css'), + new CopyWebpackPlugin([ + { from: 'public' }, + { from: 'manifest.webmanifest' } + ]), + new SWPrecacheWebpackPlugin({ + cacheId: 'formidable-react-starter', + filename: 'service-worker.js' + }) + ] +}; diff --git a/favicon.png b/favicon.png new file mode 100644 index 00000000..e1a8bd21 Binary files /dev/null and b/favicon.png differ diff --git a/index.html b/index.html new file mode 100644 index 00000000..ddf78831 --- /dev/null +++ b/index.html @@ -0,0 +1,15 @@ + + + + + + + Cellx + + + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 00000000..15ee7091 --- /dev/null +++ b/package.json @@ -0,0 +1,91 @@ +{ + "name": "cellxgene", + "version": "0.0.1", + "license": "MIT", + "repository": "https://github.com/FormidableLabs/formidable-react-starter", + "scripts": { + "build": "npm run clean && webpack --config configuration/webpack/webpack.config.prod.js", + "clean": "rimraf build", + "dev": "node server/development.js", + "lint": "eslint src", + "prod": "cross-env NODE_ENV=production PORT=3000 node server/production.js", + "test": "nyc --reporter=lcov --reporter=text mocha test/.setup.js test/**/*.spec.js" + }, + "engineStrict": true, + "engines": { + "npm": ">=3.0.0" + }, + "eslintConfig": { + "extends": "./configuration/eslint/eslint.js" + }, + "nyc": { + "sourceMap": false, + "instrument": false + }, + "dependencies": { + "d3": "^4.10.0", + "express": "^4.14.0", + "lodash": "^4.17.4", + "react": "^15.3.0", + "react-dom": "^15.3.0", + "react-helmet": "^4.0.0", + "react-hot-loader": "^3.0.0-beta.2", + "react-router-dom": "4.1.2", + "serve-favicon": "^2.3.0" + }, + "devDependencies": { + "autoprefixer": "^6.4.0", + "babel-core": "^6.13.2", + "babel-eslint": "^7.1.1", + "babel-loader": "^6.2.4", + "babel-plugin-istanbul": "^3.1.2", + "babel-plugin-transform-react-constant-elements": "^6.9.1", + "babel-plugin-transform-runtime": "^6.12.0", + "babel-preset-es2015": "^6.13.2", + "babel-preset-react": "^6.11.1", + "babel-preset-stage-0": "^6.5.0", + "babel-register": "^6.11.6", + "babel-runtime": "^6.11.6", + "chai": "^3.5.0", + "chalk": "^1.1.3", + "connect-history-api-fallback": "^1.3.0", + "copy-webpack-plugin": "^4.0.1", + "coveralls": "^2.11.12", + "cross-env": "^3.1.4", + "css-loader": "^0.26.1", + "css-modules-require-hook": "^4.0.1", + "enzyme": "^2.4.1", + "eslint": "^2.10.2", + "eslint-config-formidable": "^2.0.1", + "eslint-loader": "^1.5.0", + "eslint-plugin-filenames": "^1.1.0", + "eslint-plugin-import": "^2.2.0", + "eslint-plugin-jsx-a11y": "^3.0.2", + "eslint-plugin-react": "^6.0.0", + "extract-text-webpack-plugin": "^2.0.0-beta.3", + "file-loader": "^0.9.0", + "filesize": "^3.3.0", + "gzip-size": "^3.0.0", + "html-webpack-inline-source-plugin": "0.0.6", + "html-webpack-plugin": "^2.22.0", + "jsdom": "^9.4.1", + "json-loader": "^0.5.4", + "mocha": "^3.0.2", + "nyc": "^10.0.0", + "postcss-loader": "^1.2.2", + "promise": "^7.1.1", + "react-addons-test-utils": "^15.3.0", + "redbox-react": "^1.3.0", + "rimraf": "^2.5.4", + "sinon": "^1.17.5", + "sinon-chai": "^2.8.0", + "style-loader": "^0.13.1", + "sw-precache-webpack-plugin": "^0.7.1", + "url-loader": "^0.5.7", + "web-app-manifest-loader": "^0.1.1", + "webpack": "^2.2.0-rc.4", + "webpack-dev-middleware": "^1.6.1", + "webpack-hot-middleware": "^2.12.2", + "whatwg-fetch": "^2.0.1" + } +} diff --git a/server/development.js b/server/development.js new file mode 100644 index 00000000..6d95e1f6 --- /dev/null +++ b/server/development.js @@ -0,0 +1,56 @@ +/* eslint-disable */ +var path = require('path'); +var historyApiFallback = require('connect-history-api-fallback'); +var chalk = require('chalk'); +var express = require('express'); +var favicon = require('serve-favicon'); +var webpack = require('webpack'); +var config = require('../configuration/webpack/webpack.config.dev'); +var utils = require('./utils'); + +process.env.NODE_ENV = 'development'; + +var PORT = process.env.PORT || 3000; + +// Set up compiler +var compiler = webpack(config); + +compiler.plugin('invalid', () => { + utils.clearConsole(); + console.log('Compiling...'); +}); + +compiler.plugin('done', stats => { + utils.formatStats(stats, PORT); +}); + +// Launch server +var app = express(); + +app.use(historyApiFallback({ verbose: false })); + +app.use( + require('webpack-dev-middleware')(compiler, { + noInfo: true, + publicPath: config.output.publicPath + }) +); + +app.use(require('webpack-hot-middleware')(compiler)); + +app.use(favicon('./favicon.png')); + +app.get('*', (req, res) => { + res.sendFile(path.resolve('index.html')); +}); + +app.listen(PORT, err => { + if (err) { + console.log(err); + return; + } + + utils.clearConsole(); + console.log(chalk.cyan('Starting the development server...')); + console.log(); +}); diff --git a/server/production.js b/server/production.js new file mode 100644 index 00000000..61b3b3d4 --- /dev/null +++ b/server/production.js @@ -0,0 +1,31 @@ +/* eslint-disable */ +var path = require('path'); +var fs = require('fs'); +var chalk = require('chalk'); +var express = require('express'); +var favicon = require('serve-favicon'); +var utils = require('./utils'); + +var PORT = process.env.PORT || 80; + +// Launch server +var app = express(); + +app.use(express.static('./build')); + +app.use(favicon('./build/favicon.png')); + +app.get('*', (req, res) => { + res.sendFile(path.resolve('./build/index.html')); +}); + +app.listen(PORT, err => { + if (err) { + console.log(err); + return; + } + + utils.clearConsole(); + console.log(chalk.cyan('Production server started on port ' + PORT)); + console.log(); +}); diff --git a/server/utils.js b/server/utils.js new file mode 100644 index 00000000..59160484 --- /dev/null +++ b/server/utils.js @@ -0,0 +1,79 @@ +/* eslint-disable */ +var chalk = require('chalk'); + +var friendlySyntaxErrorLabel = 'Syntax error:'; + +function isLikelyASyntaxError(message) { + return message.indexOf(friendlySyntaxErrorLabel) !== -1; +} + +function formatMessage(message) { + return message + .replace('Module build failed: SyntaxError:', friendlySyntaxErrorLabel) + .replace( + /Module not found: Error: Cannot resolve 'file' or 'directory'/, + 'Module not found:' + ) + .replace(/^\s*at\s.*:\d+:\d+[\s\)]*\n/gm, '') + .replace('./~/css-loader!./~/postcss-loader!', ''); +} +var clearConsole = () => { + process.stdout.write('\x1bc'); +}; + +var formatStats = (stats, port) => { + clearConsole(); + var hasErrors = stats.hasErrors(); + var hasWarnings = stats.hasWarnings(); + if (!hasErrors && !hasWarnings) { + console.log(chalk.green('Compiled successfully!')); + console.log(); + console.log('The app is running at http://localhost:' + port + '/'); + console.log(); + return; + } + + var json = stats.toJson(); + var formattedErrors = json.errors.map( + message => 'Error in ' + formatMessage(message) + ); + var formattedWarnings = json.warnings.map( + message => 'Warning in ' + formatMessage(message) + ); + + if (hasErrors) { + console.log(chalk.red('Failed to compile.')); + console.log(); + if (formattedErrors.some(isLikelyASyntaxError)) { + formattedErrors = formattedErrors.filter(isLikelyASyntaxError); + } + formattedErrors.forEach(message => { + console.log(message); + console.log(); + }); + return; + } + + if (hasWarnings) { + console.log(chalk.yellow('Compiled with warnings.')); + console.log(); + formattedWarnings.forEach(message => { + console.log(message); + console.log(); + }); + + console.log('You may use special comments to disable some warnings.'); + console.log( + 'Use ' + + chalk.yellow('// eslint-disable-next-line') + + ' to ignore the next line.' + ); + console.log( + 'Use ' + + chalk.yellow('/* eslint-disable */') + + ' to ignore all warnings in a file.' + ); + } +}; + +module.exports = { formatStats: formatStats, clearConsole: clearConsole }; diff --git a/src/components/categorical/categorical.js b/src/components/categorical/categorical.js new file mode 100644 index 00000000..ddd6d06e --- /dev/null +++ b/src/components/categorical/categorical.js @@ -0,0 +1,68 @@ +import React from "react"; +import _ from "lodash"; +import * as globals from "../../globals"; + +import cells from "../../../data/GBM_metadata.js"; +import createCategoryCounts from "./createCategoryCounts"; + +const Button = ({category, c, i}) => ( + +) + +const Category = ({category, title}) => ( +
+

{title}

+ { _.map(Object.keys(category), (c, i) =>
+) + +const Categorical = () => { + + return ( +
+

Categorical Metadata

+ { + _.map(createCategoryCounts(cells), + (value, key) => { + return + }) + } +
+ ) +}; + +export default Categorical; + +/* + + [on off] toggle hide deselected filters (shows a menu vs shows what you ordered in compact/narrative form. fold out animation.) + +

+

+

+

+ +*/ diff --git a/src/components/categorical/createCategoryCounts.js b/src/components/categorical/createCategoryCounts.js new file mode 100644 index 00000000..e70c049a --- /dev/null +++ b/src/components/categorical/createCategoryCounts.js @@ -0,0 +1,45 @@ +import * as globals from "../../globals.js" + +/* + end result: + + counts: { + Sample.type: { + glioblastoma: 3452 + }, + Selection: { + foo: 234, + bar: 21 + } + } + +*/ + +const categories = globals.categories; + +const createCategoryCounts = (cells) => { + + /* instantiate counts obj */ + const counts = {}; + + categories.forEach((category) => { + counts[category] = {}; + }) + + cells.forEach((cell) => { + categories.forEach((category) => { + + /* if, for the given category (ie., categories.Location), we do not have ie., glioblastoma already, create it. Otherwise increment it. */ + if (!counts[category][cell[category]]) { + counts[category][cell[category]] = 1; + } else { + counts[category][cell[category]]++ + } + + }) + }); + + return counts; +} + +export default createCategoryCounts; diff --git a/src/components/container.css b/src/components/container.css new file mode 100644 index 00000000..d5e3c461 --- /dev/null +++ b/src/components/container.css @@ -0,0 +1,5 @@ +.container { + max-width: 1200px; + margin: auto; + min-height: 100%; +} diff --git a/src/components/container.js b/src/components/container.js new file mode 100644 index 00000000..bd257989 --- /dev/null +++ b/src/components/container.js @@ -0,0 +1,11 @@ +import React from 'react'; + +import styles from './container.css'; + +const Container = props => ( +
+ {props.children} +
+); + +export default Container; diff --git a/src/components/continuous/continuous.js b/src/components/continuous/continuous.js new file mode 100644 index 00000000..200ee2e4 --- /dev/null +++ b/src/components/continuous/continuous.js @@ -0,0 +1,18 @@ +import React from 'react'; +import _ from "lodash"; +import cells from "../../../data/GBM_metadata.js"; +import drawParallelCoordinates from "./drawParallelCoordinates"; +import createContinuousRanges from "./createContinuousRanges"; + +const Continuous = () => { + + drawParallelCoordinates(cells) + + return ( +
+

Continuous Metadata

+
+ ) +}; + +export default Continuous; diff --git a/src/components/continuous/createContinuousRanges.js b/src/components/continuous/createContinuousRanges.js new file mode 100644 index 00000000..adf26524 --- /dev/null +++ b/src/components/continuous/createContinuousRanges.js @@ -0,0 +1,25 @@ +import * as globals from "../../globals.js" + +/* + end result: + + ranges: { + Total_reads: { + min: 11111, + max: 33333 + }, + Genes_detected: { + min: 444, + max: 555 + } + } + +*/ + +const createContinuousRanges = (cells) => { + const ranges = {}; + + return ranges; +} + +export default createContinuousRanges; diff --git a/src/components/continuous/drawParallelCoordinates.js b/src/components/continuous/drawParallelCoordinates.js new file mode 100644 index 00000000..7323dd15 --- /dev/null +++ b/src/components/continuous/drawParallelCoordinates.js @@ -0,0 +1,314 @@ +import styles from './parallelCoordinates.css'; +import { + margin, + width, + height, + innerHeight, + color, + dimensions, + types, + xscale, + yAxis, +} from "./util"; + +/***************************************** +****************************************** +Canvas Parallel coordinates with svg brushing via /* via https://bl.ocks.org/syntagmatic/05a5b0897a48890133beb59c815bd953 +****************************************** +******************************************/ + +/***************************************** +****************************************** +Render Queue via http://bl.ocks.org/syntagmatic/raw/3341641/render-queue.js +****************************************** +******************************************/ + +const renderQueue = (function(callback1234) { + var _queue = [], // data to be rendered + _rate = 1000, // number of calls per frame + _invalidate = function() {}, // invalidate last render queue + _clear = function() {}; // clearing function + + var rq = function(data) { + if (data) rq.data(data); + _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(data) { + _invalidate(); + _queue = data.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; +}); + +/***************************************** +****************************************** + MAIN: drawParallelCoordinates +****************************************** +******************************************/ + +const drawParallelCoordinates = (data) => { + + const d3_functor = (v) => { + return typeof v === "function" ? v : function() { return v; }; + }; + + /***************************************** + ****************************************** + Setup SVG & Canvas elements + ****************************************** + ******************************************/ + + var container = d3.select("body").append("div") + .attr("class", styles.parcoords) + .style("width", width + margin.left + margin.right + "px") + .style("height", height + margin.top + margin.bottom + "px"); + + 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); + + var output = d3.select("body").append("pre"); + + var axes = svg.selectAll(".axis") + .data(dimensions) + .enter().append("g") + .attr("class", styles.axis) + .attr("transform", function(d,i) { return "translate(" + xscale(i) + ")"; }); + + /***************************************** + ****************************************** + Setup SVG & Canvas elements + ****************************************** + ******************************************/ + + d3.csv("https://gist.githubusercontent.com/syntagmatic/05a5b0897a48890133beb59c815bd953/raw/310a68d713eb4d351f809b6cfbcffe3d9d96a205/nutrient.csv", (error, data) => { + if (error) throw error; + + // shuffle the data! + data = d3.shuffle(data); + + data.forEach(function(d) { + dimensions.forEach(function(p) { + d[p.key] = !d[p.key] ? null : p.type.coerce(d[p.key]); + }); + + // truncate long text strings to fit in data table + for (var key in d) { + if (d[key] && d[key].length > 35) d[key] = d[key].slice(0,36); + } + }); + + // type/dimension default setting happens here + dimensions.forEach(function(dim) { + if (!("domain" in dim)) { + // detect domain using dimension type's extent function + dim.domain = d3_functor(dim.type.extent)(data.map(function(d) { return d[dim.key]; })); + } + if (!("scale" in dim)) { + // use type's default scale for dimension + dim.scale = dim.type.defaultScale.copy(); + } + dim.scale.domain(dim.domain); + }); + + const draw = (d) => { + ctx.strokeStyle = "rgba(0,0,0,.4)" /* color(d.food_group); */ + ctx.beginPath(); + var coords = project(d); + 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(); + } + + /***************************************** + ****************************************** + Handles a brush event, toggling the display of foreground lines. + ****************************************** + ******************************************/ + + const brush = () => { + render.invalidate(); + + var actives = []; + svg.selectAll(".axis .brush") + .filter((d) => { + return d3.brushSelection(this); + }) + .each(function(d) { + actives.push({ + dimension: d, + extent: d3.brushSelection(this) + }); + }); + + var selected = data.filter(function(d) { + if (actives.every(function(active) { + var dim = active.dimension; + // test if point is within extents for each active brush + return dim.type.within(d[dim.key], active.extent, dim); + })) { + return true; + } + }); + + ctx.clearRect(0,0,width,height); + ctx.globalAlpha = d3.min([0.85/Math.pow(selected.length,0.3),1]); + render(selected); + + output.text(d3.tsvFormat(selected.slice(0,24))); + console.log('fires brush', actives) + } + + function brushstart() { + d3.event.sourceEvent.stopPropagation(); + } + + var render = renderQueue(draw).rate(50); + + ctx.clearRect(0,0,width,height); + ctx.globalAlpha = d3.min([0.85/Math.pow(data.length,0.3),1]); + + render(data); + + 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") + .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) + .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); + + output.text(d3.tsvFormat(data.slice(0,24))); + + function project(d) { + return dimensions.map(function(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])]; + }); + }; + + }); + +} + +export default drawParallelCoordinates; diff --git a/src/components/continuous/parallelCoordinates.css b/src/components/continuous/parallelCoordinates.css new file mode 100644 index 00000000..c5cb3021 --- /dev/null +++ b/src/components/continuous/parallelCoordinates.css @@ -0,0 +1,91 @@ +/* + +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; +} + +.axis line, +.axis path { + fill: none; + stroke: #ccc; + stroke-width: 1px; +} + +.axis .tick text { + fill: #222; + opacity: 0; + 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; +} diff --git a/src/components/continuous/util.js b/src/components/continuous/util.js new file mode 100644 index 00000000..b6bf91c7 --- /dev/null +++ b/src/components/continuous/util.js @@ -0,0 +1,162 @@ +export const margin = {top: 66, right: 110, bottom: 20, left: 188}; +export const width = document.body.clientWidth - margin.left - margin.right; +export const height = 340 - margin.top - margin.bottom; +export const innerHeight = height - 2; + +export const devicePixelRatio = window.devicePixelRatio || 1; + +export const color = d3.scaleOrdinal() + .range(["#5DA5B3","#D58323","#DD6CA7","#54AF52","#8C92E8","#E15E5A","#725D82","#776327","#50AB84","#954D56","#AB9C27","#517C3F","#9D5130","#357468","#5E9ACF","#C47DCB","#7D9E33","#DB7F85","#BA89AD","#4C6C86","#B59248","#D8597D","#944F7E","#D67D4B","#8F86C2"]); + +export const types = { + "Number": { + key: "Number", + coerce: function(d) { return +d; }, + extent: d3.extent, + within: function(d, extent, dim) { return extent[0] <= dim.scale(d) && dim.scale(d) <= extent[1]; }, + defaultScale: d3.scaleLinear().range([innerHeight, 0]) + }, + "String": { + key: "String", + coerce: String, + extent: function (data) { return data.sort(); }, + within: function(d, extent, dim) { return extent[0] <= dim.scale(d) && dim.scale(d) <= extent[1]; }, + defaultScale: d3.scalePoint().range([0, innerHeight]) + }, + "Date": { + key: "Date", + coerce: function(d) { return new Date(d); }, + extent: d3.extent, + within: function(d, extent, dim) { return extent[0] <= dim.scale(d) && dim.scale(d) <= extent[1]; }, + defaultScale: d3.scaleTime().range([0, innerHeight]) + } +}; + +export const dimensions = [ + { + key: "Total lipid (fat) (g)", + type: types["Number"], + scale: d3.scaleSqrt().range([innerHeight, 0]) + }, + { + key: "Sugars, total (g)", + type: types["Number"], + scale: d3.scaleSqrt().range([innerHeight, 0]) + }, + { + key: "Calcium, Ca (mg)", + type: types["Number"], + scale: d3.scaleSqrt().range([innerHeight, 0]) + }, + { + key: "Sodium, Na (mg)", + type: types["Number"], + scale: d3.scaleSqrt().range([innerHeight, 0]) + }, + { + key: "Phosphorus, P (mg)", + type: types["Number"], + scale: d3.scaleSqrt().range([innerHeight, 0]) + }, + { + key: "Potassium, K (mg)", + type: types["Number"], + scale: d3.scaleSqrt().range([innerHeight, 0]) + }, + { + key: "Thiamin (mg)", + type: types["Number"], + scale: d3.scaleSqrt().range([innerHeight, 0]) + }, + { + key: "Riboflavin (mg)", + type: types["Number"], + scale: d3.scaleSqrt().range([innerHeight, 0]) + }, + { + key: "Niacin (mg)", + type: types["Number"], + scale: d3.scaleSqrt().range([innerHeight, 0]) + }, + { + key: "Iron, Fe (mg)", + type: types["Number"], + scale: d3.scaleSqrt().range([innerHeight, 0]) + }, + { + key: "Magnesium, Mg (mg)", + type: types["Number"], + scale: d3.scaleSqrt().range([innerHeight, 0]) + }, + { + key: "Protein (g)", + type: types["Number"], + scale: d3.scaleSqrt().range([innerHeight, 0]) + }, + { + key: "Zinc, Zn (mg)", + type: types["Number"], + scale: d3.scaleSqrt().range([innerHeight, 0]) + }, + { + key: "Vitamin B-6 (mg)", + type: types["Number"], + scale: d3.scaleSqrt().range([innerHeight, 0]) + }, + { + key: "Vitamin B-12 (mcg)", + type: types["Number"], + scale: d3.scaleSqrt().range([innerHeight, 0]) + }, + { + key: "Folic acid (mcg)", + type: types["Number"], + scale: d3.scaleSqrt().range([innerHeight, 0]) + }, + { + key: "Selenium, Se (mcg)", + type: types["Number"], + scale: d3.scaleSqrt().range([innerHeight, 0]) + }, + { + key: "Vitamin A, IU (IU)", + type: types["Number"], + scale: d3.scaleSqrt().range([innerHeight, 0]) + }, + { + key: "Vitamin K (phylloquinone) (mcg)", + type: types["Number"], + scale: d3.scaleSqrt().range([innerHeight, 0]) + }, + { + key: "Vitamin C, total ascorbic acid (mg)", + type: types["Number"], + scale: d3.scaleSqrt().range([innerHeight, 0]) + }, + { + key: "Vitamin D (IU)", + type: types["Number"], + scale: d3.scaleSqrt().range([innerHeight, 0]) + }, + { + key: "Cholesterol (mg)", + type: types["Number"], + scale: d3.scaleSqrt().range([innerHeight, 0]) + }, + { + key: "Fiber, total dietary (g)", + type: types["Number"], + scale: d3.scaleSqrt().range([innerHeight, 0]) + }, + { + key: "Carbohydrate, by difference (g)", + type: types["Number"], + scale: d3.scaleSqrt().range([innerHeight, 0]) + } +]; + +export const xscale = d3.scalePoint() + .domain(d3.range(dimensions.length)) + .range([0, width]); + +export const yAxis = d3.axisLeft(); diff --git a/src/components/header.css b/src/components/header.css new file mode 100644 index 00000000..41907a8b --- /dev/null +++ b/src/components/header.css @@ -0,0 +1,3 @@ +.header { + background: red; +} diff --git a/src/components/header.js b/src/components/header.js new file mode 100644 index 00000000..f2ddf89f --- /dev/null +++ b/src/components/header.js @@ -0,0 +1,15 @@ +import React from 'react'; + +import Container from './container'; +import styles from './header.css'; + + +const Header = () => ( +
+ + + +
+); + +export default Header; diff --git a/src/components/home.js b/src/components/home.js new file mode 100644 index 00000000..75abccb5 --- /dev/null +++ b/src/components/home.js @@ -0,0 +1,27 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import _ from "lodash"; +import Helmet from 'react-helmet'; +import Container from './container'; + +import Categorical from "./categorical/categorical"; +import Continuous from "./continuous/continuous"; + +const Home = () => { + + return ( + + +

cellxgene

+ + +
+ ) +}; + +export default Home; + +// +// +// +// diff --git a/src/components/page2.css b/src/components/page2.css new file mode 100644 index 00000000..5ffa58d9 --- /dev/null +++ b/src/components/page2.css @@ -0,0 +1,33 @@ +.heroContainer { + width: 100%; + height: 95vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.hero { + font-size: 48; + margin-bottom: 0; +} + +.subHero { + font-size: 18; + max-width: 600; + text-align: "center"; +} + +.primaryButton { + border: 1px solid black; + padding: 10px 15px; + background: none; + cursor: pointer; + font-size: 14px; +} + +.or { + margin: 20px; + font-family: Georgia; + font-style: italic; +} diff --git a/src/components/page2.js b/src/components/page2.js new file mode 100644 index 00000000..f73d8070 --- /dev/null +++ b/src/components/page2.js @@ -0,0 +1,25 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import Helmet from 'react-helmet'; +import styles from './page2.css'; + +import Container from './container'; + +const Page2 = () => ( + + +
+

cellxgene

+

+ A data exploration interface for single cell genetic expression matrices. Subset, filter, cluster & validate, all in one place. +

+
+ + or + +
+
+
+); + +export default Page2; diff --git a/src/containers/root.css b/src/containers/root.css new file mode 100644 index 00000000..2e26aa05 --- /dev/null +++ b/src/containers/root.css @@ -0,0 +1,19 @@ +.root { + background: #fff; + min-height: 100%; +} + +h1, h2, h3, h4, h5, h6 { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; +} + +p { + font-family: Georgia,serif; + line-height: 1.6; +} + +/* + a { + color: #ad1b11; + } +*/ diff --git a/src/containers/root.js b/src/containers/root.js new file mode 100644 index 00000000..0cbb0d82 --- /dev/null +++ b/src/containers/root.js @@ -0,0 +1,11 @@ +import React from 'react'; + +import styles from './root.css'; + +const Root = props => ( +
+ {props.children} +
+); + +export default Root; diff --git a/src/globals.js b/src/globals.js new file mode 100644 index 00000000..19c6d554 --- /dev/null +++ b/src/globals.js @@ -0,0 +1,27 @@ +/* these will be either (preferably) specified or inferred */ +export const categories = ["Sample.type", "Selection", "Location", "Sample.name", "Class", "Neoplastic"]; +export const continuous = [ + "ERCC_reads", + "ERCC_to_non_ERCC", + "Genes_detected", + "Multimapping_reads_percent", + "Non_ERCC_reads", + "Splice_sites_AT.AC", + "Splice_sites_Annotated", + "Splice_sites_GC.AG", + "Splice_sites_GT.AG", + "Splice_sites_non_canonical", + "Splice_sites_total", + "Total_reads", + "Unique_reads", + "Unique_reads_percent", + "Unmapped_mismatch", + "Unmapped_other", + "Unmapped_short" +] + +/* colors */ +export const hcaBlue = "#1c7cc7" +export const lightgrey = "rgb(211,211,211)"; + +export const bolder = 700; diff --git a/src/index.css b/src/index.css new file mode 100644 index 00000000..56a96c28 --- /dev/null +++ b/src/index.css @@ -0,0 +1,9 @@ +body { + margin: 0; + padding: 0; + font-family: sans-serif; +} + +* { + box-sizing: border-box; +} \ No newline at end of file diff --git a/src/index.js b/src/index.js new file mode 100644 index 00000000..75887d63 --- /dev/null +++ b/src/index.js @@ -0,0 +1,48 @@ +/* eslint-disable no-console */ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { AppContainer } from 'react-hot-loader'; +import Redbox from 'redbox-react'; + +import Routes from './routes'; +import './index.css'; + +if (process.env.NODE_ENV !== 'development') { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/service-worker.js'); + } + + ReactDOM.render(, document.getElementById('root')); +} else { + ReactDOM.render( + + + , + document.getElementById('root') + ); + + if (module.hot) { + module.hot.accept('./routes', () => { + // TODO: Remove console override when + // https://github.com/reactjs/react-router/issues/2704 is fixed + const orgError = console.error; + console.error = message => { + if ( + message && + message.indexOf('You cannot change ;') === -1 + ) { + orgError.apply(console, [ message ]); + } + }; + + const RoutesUpdate = require('./routes').default; + + ReactDOM.render( + + + , + document.getElementById('root') + ); + }); + } +} diff --git a/src/routes/async-route.js b/src/routes/async-route.js new file mode 100644 index 00000000..c6dd25ff --- /dev/null +++ b/src/routes/async-route.js @@ -0,0 +1,40 @@ +import React from 'react'; + +export default getComponent => { + return class AsyncComponent extends React.Component { + static Component = null; + mounted = false; + + state = { Component: AsyncComponent.Component }; + + componentWillMount() { + if (this.state.Component === null) { + getComponent() + .then(m => m.default) + .then(Component => { + AsyncComponent.Component = Component; + if (this.mounted) { + this.setState({ Component }); + } + }); + } + } + + componentDidMount() { + this.mounted = true; + } + + componentWillUnmount() { + this.mounted = false; + } + + render() { + const { Component } = this.state; + + if (Component !== null) { + return ; + } + return null; + } + }; +}; diff --git a/src/routes/index.js b/src/routes/index.js new file mode 100644 index 00000000..8f100763 --- /dev/null +++ b/src/routes/index.js @@ -0,0 +1,27 @@ +/* eslint-disable no-console */ +import React from 'react'; + +import { BrowserRouter as Router, Route } from 'react-router-dom'; + +import asyncRoute from './async-route'; +import Root from '../containers/root'; +import Header from '../components/header'; + +const Routes = () => ( + + +
+ System.import('../components/home'))} + /> + System.import('../components/page2'))} + /> + + +); + +export default Routes; diff --git a/test/.setup.js b/test/.setup.js new file mode 100644 index 00000000..4fd1c060 --- /dev/null +++ b/test/.setup.js @@ -0,0 +1,32 @@ +var babelConfig = require('../configuration/babel/babel.test.js'); +require('babel-register')(babelConfig); + +// Deobfuscate CSS modules classes for testing +require('css-modules-require-hook')({ generateScopedName: '[local]' }); + +require.extensions['.svg'] = () => null; + +var jsdom = require('jsdom').jsdom; + +var chai = require('chai'); +var sinonChai = require('sinon-chai'); + +var exposedProperties = [ 'window', 'navigator', 'document' ]; + +global.document = jsdom(''); +global.window = document.defaultView; + +global.expect = chai.expect; + +chai.use(sinonChai); + +Object.keys(document.defaultView).forEach(property => { + if (typeof global[property] === 'undefined') { + exposedProperties.push(property); + global[property] = document.defaultView[property]; + } +}); + +global.navigator = { userAgent: 'node.js' }; + +documentRef = document; diff --git a/test/specs/routes.spec.js b/test/specs/routes.spec.js new file mode 100644 index 00000000..0967e621 --- /dev/null +++ b/test/specs/routes.spec.js @@ -0,0 +1,11 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { BrowserRouter as Router } from 'react-router-dom'; + +import Routes from '../../src/routes'; + +describe('Routes', () => { + it('contains spec with an expectation', () => { + expect(shallow().find(Router)).to.have.length(1); + }); +});