diff --git a/packages/d3chart/CHANGELOG.md b/packages/d3chart/CHANGELOG.md new file mode 100644 index 00000000..8ff36790 --- /dev/null +++ b/packages/d3chart/CHANGELOG.md @@ -0,0 +1,6 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + + diff --git a/packages/d3chart/README.md b/packages/d3chart/README.md new file mode 100644 index 00000000..ae676cdb --- /dev/null +++ b/packages/d3chart/README.md @@ -0,0 +1,12 @@ +# ![](docs/static/favicon.png) Candela D3Chart + +Candela D3Chart provides a convenient mixin for constructing freeform +visualization components conforming to the D3 reusable chart paradigm. + +Candela is an open-source framework for creating interoperable, reusable +visualization components for the web. Candela is a part of +[Kitware](http://www.kitware.com)'s [Resonant](http://resonant.kitware.com) +platform. Candela focuses on making scalable, rich visualizations available with +a normalized API for use in real-world data science applications. + +Please see our documentation at https://candela.readthedocs.io. diff --git a/packages/d3chart/karma.conf.js b/packages/d3chart/karma.conf.js new file mode 100644 index 00000000..5b88f6b6 --- /dev/null +++ b/packages/d3chart/karma.conf.js @@ -0,0 +1,45 @@ +var path = require('path'); + +var kconfig = { + singleRun: true, + client: { + captureConsole: false + }, + browsers: [ + 'ChromeHeadless' + ], + frameworks: [ + 'tap' + ], + reporters: [ + 'tap-pretty' + ], + tapReporter: { + prettify: require('tap-spec') + }, + files: [ + 'build/test.unit.js' + ] +}; + +if (process.env.COVERAGE) { + kconfig.files = [ + 'build/test.coverage.js' + ]; + kconfig.reporters = [ + 'coverage-istanbul' + ]; + kconfig.coverageIstanbulReporter = { + reports: ['text-summary', 'html'], + dir: path.resolve('../../build/coverage'), + 'report-config': { + html: { + subdir: 'html/core' + } + } + }; +} + +module.exports = function (config) { + config.set(kconfig); +}; diff --git a/packages/d3chart/package.json b/packages/d3chart/package.json new file mode 100644 index 00000000..5ea69b0c --- /dev/null +++ b/packages/d3chart/package.json @@ -0,0 +1,58 @@ +{ + "name": "@candela/d3chart", + "version": "0.23.3", + "description": "Candela D3Chart mixin library", + "main": "dist/candela-d3chart.js", + "scripts": { + "build": "webpack --mode development", + "build:prod": "webpack --mode production", + "clean": "rm -rf dist build", + "lint": "semistandard | snazzy", + "build:test": "webpack --config webpack.config.test.babel.js --mode development", + "test": "karma start --colors --log-level error" + }, + "publishConfig": { + "access": "public" + }, + "babel": { + "presets": [ + "@babel/env" + ] + }, + "keywords": [], + "author": "Kitware Inc.", + "license": "Apache-2.0", + "semistandard": { + "ignore": [ + "dist", + "build" + ] + }, + "dependencies": { + "@candela/core": "^0.23.3", + "@candela/events": "^0.23.3", + "@candela/size": "^0.23.3", + "d3-selection": "^1.1.0", + "d3-transition": "^1.1.0" + }, + "devDependencies": { + "@babel/core": "^7.0.0-beta.44", + "@babel/preset-env": "^7.0.0-beta.44", + "@babel/register": "^7.0.0-beta.44", + "babel-loader": "^8.0.0-beta.2", + "d3-selection": "^1.1.0", + "istanbul-instrumenter-loader": "^3.0.1", + "karma": "^2.0.2", + "karma-chrome-launcher": "^2.2.0", + "karma-coverage-istanbul-reporter": "^1.4.2", + "karma-tap": "^4.1.3", + "karma-tap-pretty-reporter": "^4.0.0", + "semistandard": "^7.0.5", + "snazzy": "^4.0.0", + "tap-spec": "^4.1.1", + "tape": "^4.9.0", + "tape-catch": "^1.0.6", + "webpack": "^4.6.0", + "webpack-cli": "^2.0.14" + } +} diff --git a/packages/d3chart/src/Axes.js b/packages/d3chart/src/Axes.js new file mode 100644 index 00000000..a1c57c41 --- /dev/null +++ b/packages/d3chart/src/Axes.js @@ -0,0 +1,116 @@ +import { axisLeft, axisBottom } from 'd3-axis'; + +import { D3Chart } from './D3Chart'; + +const scaleFuncMap = { + left: axisLeft, + bottom: axisBottom +}; + +function getLabelOffset (dir, bounds) { + switch (dir) { + case 'left': + return { + rotate: 'rotate(-90)', + x: -bounds.height / 2, + y: -40 + }; + + case 'bottom': + return { + x: bounds.width / 2, + y: 40 + }; + + default: + throw new Error(`illegal direction parameter: "${dir}"`); + } +} + +class AxesImpl { + constructor (that) { + this.that = that; + + this.scale = { + left: null, + bottom: null + }; + + this.axis = { + left: null, + bottom: null + }; + + this.group = { + left: null, + bottom: null + }; + } + + setScale (direction, scale) { + const scaleFunc = scaleFuncMap[direction]; + + const bounds = this.that.margin.bounds('plot'); + + if (direction === 'left' || direction === 'right') { + scale.range([bounds.height, 0]); + } else { + scale.range([0, bounds.width]); + } + + this.scale[direction] = scale; + + let axis = this.group[direction]; + if (!axis) { + axis = this.group[direction] = this.that.d3chart[direction].append('g'); + axis.append('text') + .attr('fill', '#000') + .style('font-size', '11pt') + .style('font-weight', 'bold'); + + if (direction === 'left') { + const margin = this.that.margin.get(); + axis.attr('transform', `translate(${margin.left},0)`); + } + } else { + axis.selectAll('*').remove(); + } + + axis.call(this.axis[direction] = scaleFunc(scale)); + + return this; + } + + getAxis (dir) { + return this.axis[dir]; + } + + getScale (dir) { + return this.scale[dir]; + } + + setLabel (dir, label) { + const bounds = this.that.margin.bounds(dir); + const text = this.group[dir].select('text'); + const offset = getLabelOffset(dir, bounds); + + text.attr('transform', offset.rotate ? offset.rotate : null) + .attr('x', offset.x) + .attr('y', offset.y) + .text(label); + + return this; + } + + renderAxis (dir) { + this.group[dir].call(this.axis[dir]); + return this; + } +} + +export const Axes = Base => class extends D3Chart(Base) { + constructor () { + super(...arguments); + this.axes = new AxesImpl(this); + } +}; diff --git a/packages/d3chart/src/Crosshairs.js b/packages/d3chart/src/Crosshairs.js new file mode 100644 index 00000000..894009da --- /dev/null +++ b/packages/d3chart/src/Crosshairs.js @@ -0,0 +1,93 @@ +import { D3Chart } from './D3Chart'; + +class CrosshairsImpl { + constructor (that) { + this.that = that; + + this.target = null; + this.crosshairX = null; + this.crosshairY = null; + } + + init () { + const that = this.that; + + const plotBounds = that.margin.bounds('plot'); + this.target = that.d3chart.plot.append('rect', ':first-child') + .classed('crosshairs-target', true) + .attr('width', plotBounds.width) + .attr('height', plotBounds.height) + .style('opacity', 0.0); + + const g = that.d3chart.plot.append('g') + .classed('crosshairs', true) + .style('pointer-events', 'none'); + + this.crosshairX = g.append('line') + .classed('crosshair-x', true) + .style('opacity', 0) + .style('stroke', 'lightgray') + .attr('x1', 0) + .attr('x2', plotBounds.width); + + this.crosshairY = g.append('line') + .classed('crosshair-y', true) + .style('opacity', 0) + .style('stroke', 'lightgray') + .attr('y1', 0) + .attr('y2', plotBounds.height); + + this.target.on('mouseover.crosshairs', () => { + this.show(); + }).on('mousemove.crosshairs', () => { + const mouse = this.mouseCoords(); + this.setPosition(mouse.x, mouse.y); + }).on('mouseout.crosshairs', () => { + this.hide(); + }); + } + + mouseCoords () { + const event = window.event; + if (event) { + const bbox = this.target.node().getBoundingClientRect(); + return { + x: event.pageX - bbox.left, + y: event.pageY - bbox.top + }; + } + } + + setPosition (x, y) { + this.crosshairX.attr('y1', y) + .attr('y2', y); + + this.crosshairY.attr('x1', x) + .attr('x2', x); + + return this; + } + + show () { + this.crosshairX.style('opacity', 1); + this.crosshairY.style('opacity', 1); + + return this; + } + + hide () { + this.crosshairX.style('opacity', 0); + this.crosshairY.style('opacity', 0); + + return this; + } +} + +export const Crosshairs = Base => class extends D3Chart(Base) { + constructor () { + super(...arguments); + if (!this.crosshairs) { + this.crosshairs = new CrosshairsImpl(this); + } + } +}; diff --git a/packages/d3chart/src/D3Chart.js b/packages/d3chart/src/D3Chart.js new file mode 100644 index 00000000..f799dd9b --- /dev/null +++ b/packages/d3chart/src/D3Chart.js @@ -0,0 +1,113 @@ +import { VisComponent } from '@candela/core'; +import { InitSize } from '@candela/size'; + +import { select } from 'd3-selection'; +import 'd3-transition'; + +import { Margin } from './Margin'; + +class D3ChartImpl { + constructor (that) { + this.that = that; + + this.svg = select(this.that.el) + .append('svg') + .attr('xmlns', 'http://www.w3.org/2000/svg'); + + // A root-level group element. + this.root = this.svg.append('g'); + + // Group elements to represent all four margins of the plot. + this.left = this.root.append('g') + .classed('left', true); + this.bottom = this.root.append('g') + .classed('bottom', true); + this.right = this.root.append('g') + .classed('right', true); + this.top = this.root.append('g') + .classed('top', true); + + // The central area where the main plot will go. + this.plot = this.root.append('g') + .classed('plot', true); + } + + init () { + this.svg.attr('width', this.that.width) + .attr('height', this.that.height); + + const margin = this.that.margin.get(); + + this.left.attr('transform', `translate(0,${margin.top})`); + this.bottom.attr('transform', `translate(${margin.left},${this.that.height - margin.bottom})`); + this.right.attr('transform', `translate(${this.that.width - margin.right},${margin.top})`); + this.top.attr('transform', `translate(${margin.left},0)`); + this.plot.attr('transform', `translate(${margin.left},${margin.top})`); + } +} + +export const D3Chart = Base => class extends Margin(InitSize(Base)) { + constructor () { + super(...arguments); + if (!this.d3chart) { + this.d3chart = new D3ChartImpl(this); + } + } +}; + +export class Swatches extends D3Chart(VisComponent) { + constructor (el, options) { + super(el, options); + + this.width = options.width; + this.height = options.height; + this.margin.set(options.margin); + this.d3chart.init(); + + const margin = this.margin.get(); + + this.d3chart.left.append('rect') + .attr('x', 0) + .attr('y', 0) + .attr('width', margin.left) + .attr('height', this.height - margin.bottom - margin.top) + .style('stroke', 'black') + .style('fill', 'red'); + + this.d3chart.bottom.append('rect') + .attr('x', 0) + .attr('y', 0) + .attr('width', this.width - margin.left - margin.right) + .attr('height', margin.bottom) + .style('stroke', 'black') + .style('fill', 'green'); + + this.d3chart.right.append('rect') + .attr('x', 0) + .attr('y', 0) + .attr('width', margin.right) + .attr('height', this.height - margin.bottom - margin.top) + .style('stroke', 'black') + .style('fill', 'cyan'); + + this.d3chart.top.append('rect') + .attr('x', 0) + .attr('y', 0) + .attr('width', this.width - margin.left - margin.right) + .attr('height', margin.top) + .style('stroke', 'black') + .style('fill', 'yellow'); + + this.d3chart.plot.append('rect') + .attr('x', 0) + .attr('y', 0) + .attr('width', this.width - margin.left - margin.right) + .attr('height', this.height - margin.top - margin.bottom) + .style('stroke', 'black') + .style('fill', 'blue'); + } + + render () { + console.log('render()'); + } +} diff --git a/packages/d3chart/src/Margin.js b/packages/d3chart/src/Margin.js new file mode 100644 index 00000000..df86093e --- /dev/null +++ b/packages/d3chart/src/Margin.js @@ -0,0 +1,100 @@ +class MarginImpl { + constructor (that) { + this.that = that; + + this.margin = { + top: 0, + right: 0, + bottom: 0, + left: 0 + }; + } + + get () { + return {...this.margin}; + } + + set (m) { + let mm = {...m}; + for (let key in mm) { + if (!(key in this.margin)) { + delete mm[key]; + } + } + + this.margin = { + ...this.margin, + ...mm + }; + + return this; + } + + bounds (region) { + const margin = this.get(); + const width = this.that.width; + const height = this.that.height; + let bounds; + + switch (region) { + case 'left': + bounds = { + x: 0, + y: margin.top, + width: margin.left, + height: height - margin.top - margin.bottom + }; + break; + + case 'right': + bounds = { + x: width - margin.right, + y: margin.top, + width: margin.right, + height: height - margin.top - margin.bottom + }; + break; + + case 'top': + bounds = { + x: margin.left, + y: 0, + width: width - margin.left - margin.right, + height: margin.top + }; + break; + + case 'bottom': + bounds = { + x: margin.left, + y: height - margin.bottom, + width: width - margin.left - margin.right, + height: margin.bottom + }; + break; + + case 'plot': + bounds = { + x: margin.left, + y: margin.top, + width: width - margin.left - margin.right, + height: height - margin.top - margin.bottom + }; + break; + + default: + throw new Error(`illegal region identifier: "${region}"`); + } + + return bounds; + } +} + +export const Margin = Base => class extends Base { + constructor () { + super(...arguments); + if (!this.margin) { + this.margin = new MarginImpl(this); + } + } +}; diff --git a/packages/d3chart/src/Tooltip.js b/packages/d3chart/src/Tooltip.js new file mode 100644 index 00000000..3194b776 --- /dev/null +++ b/packages/d3chart/src/Tooltip.js @@ -0,0 +1,63 @@ +import { select } from 'd3-selection'; +import 'd3-transition'; + +class TooltipImpl { + constructor (that) { + this.that = that; + this.tooltip = null; + } + + init (options = {}) { + this.tooltip = select(this.that.el) + .append('div') + .style('opacity', 0) + .style('position', 'absolute') + .style('text-align', options.textAlign || 'center') + .style('width', options.width || '80px') + .style('height', options.height || '30px') + .style('padding', '2px') + .style('font', options.font || '12px sans-serif') + .style('background', options.background || 'lightgreen') + .style('border', '0px') + .style('border-radius', '8px') + .style('pointer-events', 'none'); + + return this.that; + } + + show () { + this.tooltip.style('opacity', 1); + return this; + } + + hide () { + this.tooltip.style('opacity', 0); + return this; + } + + setX (x) { + this.tooltip.style('left', `${x}px`); + return this; + } + + setY (y) { + this.tooltip.style('top', `${y}px`); + return this; + } + + setPosition (x, y) { + this.setX(x) + .setY(y); + + return this; + } +} + +export const Tooltip = Base => class extends Base { + constructor () { + super(...arguments); + if (!this.tooltip) { + this.tooltip = new TooltipImpl(this); + } + } +}; diff --git a/packages/d3chart/src/index.js b/packages/d3chart/src/index.js new file mode 100644 index 00000000..a97d8a2a --- /dev/null +++ b/packages/d3chart/src/index.js @@ -0,0 +1,5 @@ +export * from './Axes'; +export * from './Crosshairs'; +export * from './D3Chart'; +export * from './Margin'; +export * from './Tooltip'; diff --git a/packages/d3chart/tests.js b/packages/d3chart/tests.js new file mode 100644 index 00000000..da9600c0 --- /dev/null +++ b/packages/d3chart/tests.js @@ -0,0 +1,2 @@ +var context = require.context('./src', true, /\.js$/); +context.keys().forEach(context); diff --git a/packages/d3chart/webpack.config.babel.js b/packages/d3chart/webpack.config.babel.js new file mode 100644 index 00000000..08063bf1 --- /dev/null +++ b/packages/d3chart/webpack.config.babel.js @@ -0,0 +1,33 @@ +import path from 'path'; + +export default { + entry: './src/index.js', + output: { + libraryTarget: 'umd', + path: path.resolve('dist'), + filename: 'candela-d3chart.js' + }, + externals: [ + '@candela/core', + '@candela/events', + '@candela/size', + 'd3-selection', + 'd3-transition' + ], + module: { + rules: [ + { + test: /\.js$/, + exclude: /node_modules/, + use: { + loader: 'babel-loader', + options: { + presets: [ + '@babel/env' + ] + } + } + } + ] + } +}; diff --git a/packages/d3chart/webpack.config.test.babel.js b/packages/d3chart/webpack.config.test.babel.js new file mode 100644 index 00000000..2dcb1396 --- /dev/null +++ b/packages/d3chart/webpack.config.test.babel.js @@ -0,0 +1,52 @@ +import path from 'path'; + +import moduleConfig from './webpack.config.babel'; + +let config = { + entry: './tests.js', + output: { + path: path.resolve('build'), + filename: 'test.unit.js' + }, + module: moduleConfig.module, + node: { + fs: 'empty' + } +}; + +if (process.env.COVERAGE) { + config.devtool = 'cheap-module-source-map'; + config.output.filename = 'test.coverage.js'; + config.module = { + rules: [ + { + enforce: 'post', + test: /\.js$/, + use: { + loader: 'istanbul-instrumenter-loader', + options: { + esModules: true + } + }, + include: [ + path.resolve('src/') + ], + exclude: /\.spec\.js$/ + }, + { + test: /\.js$/, + exclude: /node_modules/, + use: { + loader: 'babel-loader', + options: { + presets: [ + '@babel/env' + ] + } + } + } + ] + }; +} + +export default config; diff --git a/packages/examples/d3chart/index.js b/packages/examples/d3chart/index.js new file mode 100644 index 00000000..354dd797 --- /dev/null +++ b/packages/examples/d3chart/index.js @@ -0,0 +1,15 @@ +import { Swatches } from '@candela/d3chart'; +import showComponent from '../util/showComponent'; + +window.onload = () => { + showComponent(Swatches, { + width: 300, + height: 300, + margin: { + top: 20, + right: 30, + bottom: 40, + left: 50 + } + }); +}; diff --git a/packages/examples/package.json b/packages/examples/package.json index 2cea77f9..51237414 100644 --- a/packages/examples/package.json +++ b/packages/examples/package.json @@ -26,19 +26,20 @@ ] }, "dependencies": { - "@candela/events": "^0.23.4", - "@candela/geojs": "^0.23.4", - "@candela/glo": "^0.23.4", - "@candela/lineup": "^0.23.4", - "@candela/onset": "^0.23.4", - "@candela/sententree": "^0.23.4", - "@candela/similaritygraph": "^0.23.4", - "@candela/size": "^0.23.4", - "@candela/stats": "^0.23.4", - "@candela/trackerdash": "^0.23.4", - "@candela/treeheatmap": "^0.23.4", - "@candela/upset": "^0.23.4", - "@candela/vega": "^0.23.4" + "@candela/d3chart": "^0.23.3", + "@candela/events": "^0.23.3", + "@candela/geojs": "^0.23.3", + "@candela/glo": "^0.23.3", + "@candela/lineup": "^0.23.3", + "@candela/onset": "^0.23.3", + "@candela/sententree": "^0.23.3", + "@candela/similaritygraph": "^0.23.3", + "@candela/size": "^0.23.3", + "@candela/stats": "^0.23.3", + "@candela/trackerdash": "^0.23.3", + "@candela/treeheatmap": "^0.23.3", + "@candela/upset": "^0.23.3", + "@candela/vega": "^0.23.3" }, "devDependencies": { "@babel/core": "^7.0.0-beta.44", diff --git a/shell.nix b/shell.nix index a3d63573..1756edcb 100644 --- a/shell.nix +++ b/shell.nix @@ -1,56 +1,18 @@ -{ pkgs ? import {} }: +{ + pkgs ? import (fetchTarball { + url = https://github.com/NixOS/nixpkgs-channels/archive/14a9ca27e69e33ac8ffb708de08883f8079f954a.tar.gz; + sha256 = "1grsq8mcpl88v6kz8dp0vsybr0wzfg4pvhamj42dpd3vgr93l2ib"; + }) {} +}: -with pkgs; - -let libs = [ - alsaLib - atk - cairo - cups - dbus - expat - fontconfig.lib - fontconfig.dev - gdk_pixbuf - glib - gnome3.gconf - gtk2 - nspr - nss - pango - python27Packages.sphinx - python27Packages.sphinx_rtd_theme - freetype - stdenv.cc.cc.lib - zlib -] ++ (with xorg; [ - libX11 - libxcb - libXcomposite - libXcursor - libXdamage - libXext - libXi - libXfixes - libXrandr - libXrender - libXScrnSaver - libXtst -]); -in stdenv.mkDerivation { +pkgs.stdenv.mkDerivation { name = "candela"; - buildInputs = [ - electron - nodejs-7_x - phantomjs2 + buildInputs = with pkgs; [ + cairo + nodejs-9_x + nodePackages_8_x.node-gyp + nodePackages.lerna pkgconfig - python2 - ] ++ libs; - - C_INCLUDE_PATH = "${fontconfig.dev}/include"; - - shellHook = '' - echo "Candela nix dev environment" - ''; + ]; }